orchestrator.go
14879 bytes
1package game
2
3import (
4 "context"
5 "errors"
6 "strings"
7 "sync"
8 "unicode/utf8"
9
10 "git.theedgeofrage.com/TheEdgeOfRage/kaiwari/internal/llm"
11)
12
13// SpeechInput captures push-to-talk audio and returns the raw transcript. Begin
14// starts capture; End stops it and transcribes to the raw Japanese line.
15type SpeechInput interface {
16 Begin(ctx context.Context) error
17 End(ctx context.Context) (string, error)
18}
19
20// SpeechOutput synthesizes and plays an NPC reply's audio from its kana, and can
21// replay the last line it produced without re-synthesizing.
22// gender selects the voice; an empty gender means the speaker is unknown (ambient line).
23type SpeechOutput interface {
24 Speak(ctx context.Context, kana string, gender string) error
25 Replay() error
26}
27
28// JudgeModel scores one spoken line. It is independent of the game model.
29type JudgeModel interface {
30 Judge(ctx context.Context, transcript string) (llm.JudgeResult, error)
31}
32
33// GameModel advances the game loop: it sends the reconstructed prompt and returns
34// the parsed unified reply. Judge output never enters this path.
35type GameModel interface {
36 Advance(ctx context.Context, msgs []llm.Message) (llm.GameReply, error)
37}
38
39// Compactor summarizes one stretch of messages at a location into a short memory.
40type Compactor interface {
41 Summarize(ctx context.Context, location string, msgs []llm.Message) (string, error)
42}
43
44// SheetModel generates one person's character sheet from their introducing reply.
45type SheetModel interface {
46 GenerateSheet(ctx context.Context, brief, location, talk, intro, firstLine string) (llm.Sheet, error)
47}
48
49// ScratchModel answers one scratch question about how to say something in
50// Japanese. It is independent of the game model and sees no world context.
51type ScratchModel interface {
52 Ask(ctx context.Context, question string) (llm.ScratchAnswer, error)
53}
54
55// FlashcardModel generates a Japanese sentence from instructions, turns it into
56// vocabulary flashcards, and appends them to a persistent local deck. It returns
57// how many cards were added.
58type FlashcardModel interface {
59 Generate(ctx context.Context, instructions string) (int, error)
60}
61
62// newPartnerNudge is the user message that pulls a new partner's first line
63// after their character sheet is registered.
64const newPartnerNudge = "(The scene continues. The person the player is now talking to speaks first.)"
65
66// openingNote is the internal directive that produces the very first turn: the
67// model describes the player's starting area with no player action involved.
68const openingNote = "(The adventure begins. Describe the player's starting location and their immediate surroundings.)"
69
70// ErrEmptyTranscript is returned when push-to-talk produced no usable transcript.
71var ErrEmptyTranscript = errors.New("game: empty transcript")
72
73// ErrNoSpeech is returned by ReplayLast before any NPC has spoken yet.
74var ErrNoSpeech = errors.New("game: nothing to replay yet")
75
76// TurnResult reports what happened in one turn. Reply is always set unless Err is
77// non-nil (no reply was produced, so the session did not advance). Judge and
78// JudgeErr apply to spoken turns only. SpeakErr is a recoverable TTS failure.
79type TurnResult struct {
80 Reply llm.GameReply
81 Judge llm.JudgeResult
82 HasJudge bool
83 SpeakErr error
84 JudgeErr error
85 Err error
86}
87
88// Orchestrator runs the turn flow over the adapter interfaces, keeping all game
89// state in the embedded State. It does no I/O itself; every adapter call awaits on
90// the caller's context, so it is safe to run off a UI event loop (for example from
91// a Bubble Tea command goroutine).
92type Orchestrator struct {
93 state *State
94 game GameModel
95 judge JudgeModel
96 compactor Compactor
97 sheet SheetModel
98 ask ScratchModel
99 flash FlashcardModel
100 speechIn SpeechInput
101 speechOut SpeechOutput
102
103 lastKana string
104 staticChars int
105 budget int
106 totalBudget int
107}
108
109// NewOrchestrator wires an orchestrator over a state and the eight adapters.
110func NewOrchestrator(state *State, g GameModel, j JudgeModel, c Compactor, sh SheetModel, ask ScratchModel, flash FlashcardModel, in SpeechInput, out SpeechOutput) *Orchestrator {
111 policy := llm.DefaultHistoryPolicy()
112 return &Orchestrator{
113 state: state,
114 game: g,
115 judge: j,
116 compactor: c,
117 sheet: sh,
118 ask: ask,
119 flash: flash,
120 speechIn: in,
121 speechOut: out,
122 staticChars: utf8.RuneCountInString(llm.GameSystemPrompt(state.Brief())),
123 budget: policy.PromptCharBudget,
124 totalBudget: policy.TotalContextBudget,
125 }
126}
127
128// Begin starts push-to-talk capture for the next spoken turn.
129func (o *Orchestrator) Begin(ctx context.Context) error {
130 return o.speechIn.Begin(ctx)
131}
132
133// ReplayLast re-plays the last line an NPC said from cache (no re-synthesis).
134// It returns ErrNoSpeech if no NPC has spoken yet. It does not touch game state.
135func (o *Orchestrator) ReplayLast() error {
136 if o.lastKana == "" {
137 return ErrNoSpeech
138 }
139 return o.speechOut.Replay()
140}
141
142// GenerateFlashcards runs one "! instructions" action: ask the flashcard model
143// to generate a sentence from the instructions and turn it into vocabulary cards,
144// then append them to the persistent deck. It returns how many cards were added.
145// It does not touch game state.
146func (o *Orchestrator) GenerateFlashcards(ctx context.Context, action string) (int, error) {
147 instructions := strings.TrimSpace(strings.TrimPrefix(action, "!"))
148 return o.flash.Generate(ctx, instructions)
149}
150
151// ActionTurn runs one typed action: record it, then advance the game loop.
152func (o *Orchestrator) ActionTurn(ctx context.Context, action string) TurnResult {
153 o.state.RecordTyped(action)
154 return o.advance(ctx, action)
155}
156
157// StartAdventure runs the opening turn: record an internal begin note and advance
158// once so the model describes the player's starting area. No player action is
159// involved; the reply is a pure narration of where the player begins.
160func (o *Orchestrator) StartAdventure(ctx context.Context) TurnResult {
161 o.state.RecordUser(openingNote)
162 return o.advance(ctx, "")
163}
164
165// advance runs the shared tail of a turn whose user message has already been
166// recorded in state: shrink context, build the prompt, advance the game loop,
167// handle any new partner, play speech if present, and log the display entry.
168func (o *Orchestrator) advance(ctx context.Context, displayAction string) TurnResult {
169 var res TurnResult
170
171 o.shrinkContext(ctx)
172 prompt := o.state.Prompt()
173
174 reply, err := o.game.Advance(ctx, prompt)
175 if err != nil {
176 res.Err = err
177 return res
178 }
179 res.Reply = reply
180 shown, desc, speakErr := o.applyAndSpeak(ctx, reply)
181 res.SpeakErr = speakErr
182 o.state.RecordDisplay(displayFromTurn(displayAction, shown, desc, llm.JudgeResult{}, false))
183 return res
184}
185
186// AskTurn runs one scratch question: ask the scratch model how to say the thing
187// and record one display entry. It never touches world state, plays speech, or
188// runs the judge.
189func (o *Orchestrator) AskTurn(ctx context.Context, action string) TurnResult {
190 var res TurnResult
191 question := strings.TrimSpace(strings.TrimPrefix(action, "?"))
192 answer, err := o.ask.Ask(ctx, question)
193 if err != nil {
194 res.Err = err
195 return res
196 }
197 o.state.RecordDisplay(DisplayEntry{
198 Action: action,
199 HasAsk: true,
200 AskRomaji: answer.Romaji,
201 AskTranslation: answer.Translation,
202 AskBreakdown: answer.Breakdown,
203 })
204 return res
205}
206
207// FinishSpeak runs one spoken turn: stop and transcribe the recording, then run
208// the judge and the game advance in parallel, play speech if present, and log.
209func (o *Orchestrator) FinishSpeak(ctx context.Context) TurnResult {
210 var res TurnResult
211
212 raw, err := o.speechIn.End(ctx)
213 if err != nil {
214 res.Err = err
215 return res
216 }
217 raw = strings.TrimSpace(raw)
218 if raw == "" {
219 res.Err = ErrEmptyTranscript
220 return res
221 }
222
223 o.state.RecordSpoken(raw)
224 o.shrinkContext(ctx)
225 prompt := o.state.Prompt()
226
227 var (
228 wg sync.WaitGroup
229 reply llm.GameReply
230 rerr error
231 jres llm.JudgeResult
232 jerr error
233 )
234 wg.Add(2)
235 go func() {
236 defer wg.Done()
237 reply, rerr = o.game.Advance(ctx, prompt)
238 }()
239 go func() {
240 defer wg.Done()
241 jres, jerr = o.judge.Judge(ctx, raw)
242 }()
243 wg.Wait()
244
245 if rerr != nil {
246 res.Err = rerr
247 return res
248 }
249 res.Reply = reply
250 res.HasJudge = true
251 res.JudgeErr = jerr
252 shown, desc, speakErr := o.applyAndSpeak(ctx, reply)
253 res.SpeakErr = speakErr
254 judge := llm.JudgeResult{}
255 if jerr == nil {
256 judge = jres
257 }
258 o.state.RecordDisplay(displayFromTurn("", shown, desc, judge, jerr == nil))
259 return res
260}
261
262// applyAndSpeak runs the shared post-reply pipeline: record the reply in state,
263// handle any new partner, and play speech if present. Returns the shown reply
264// (after potential new-partner rewrite), its description, and any speech error.
265func (o *Orchestrator) applyAndSpeak(ctx context.Context, reply llm.GameReply) (llm.GameReply, string, error) {
266 o.applyReplyAndCompact(ctx, reply)
267 shown, desc := o.handleNewPartner(ctx, reply)
268 var speakErr error
269 if hasSpeech(shown) {
270 o.lastKana = shown.Kana
271 speakErr = o.speechOut.Speak(ctx, shown.Kana, o.speakerGender(shown.Location, shown.Talk))
272 }
273 return shown, desc, speakErr
274}
275
276// applyReplyAndCompact records the reply in the session and, when it moved the
277// player, compacts the closed segment into a summary. Compaction failures fall
278// back to a minimal placeholder so state always stays consistent.
279func (o *Orchestrator) applyReplyAndCompact(ctx context.Context, reply llm.GameReply) {
280 moved, closedLoc, closedPrefix, closedMsgs := o.state.ApplyReply(reply)
281 if !moved {
282 return
283 }
284 msgs := withPrefix(closedPrefix, toLLMMessages(closedMsgs))
285 summary, err := o.compactor.Summarize(ctx, closedLoc, msgs)
286 if err != nil || strings.TrimSpace(summary) == "" {
287 summary = "Earlier, the player was at " + closedLoc + "."
288 }
289 o.state.RegisterSummary(closedLoc, summary)
290}
291
292// compactOpenIfOverBudget is the safety net for a very long conversation in one
293// place: when the open segment alone would push the prompt past budget, its oldest
294// half is compacted into the segment prefix. Best-effort; failures are ignored.
295func (o *Orchestrator) compactOpenIfOverBudget(ctx context.Context) error {
296 if o.staticChars+o.state.OpenCharCount() <= o.budget {
297 return nil
298 }
299 msgs := o.state.OpenMessages()
300 total := len(msgs)
301 if total < 4 {
302 return nil
303 }
304 cut := total / 2
305 for cut < total && msgs[cut].Role != roleAssistant {
306 cut++
307 }
308 if cut >= total {
309 return nil
310 }
311 summary, err := o.compactor.Summarize(ctx, o.state.Location(), toLLMMessages(msgs[:cut]))
312 if err != nil || strings.TrimSpace(summary) == "" {
313 summary = "Earlier at this location (details elided)."
314 }
315 o.state.ApplyOpenPrefix(summary, cut)
316 return nil
317}
318
319// shrinkContext runs both context guards before a prompt is built: per-location
320// compaction of an over-long open segment, then dropping the oldest closed recaps
321// to keep total prompt size within budget. Both are best-effort.
322func (o *Orchestrator) shrinkContext(ctx context.Context) {
323 _ = o.compactOpenIfOverBudget(ctx)
324 o.dropOldestRecapsIfOverBudget()
325}
326
327// dropOldestRecapsIfOverBudget bounds total prompt growth across locations by
328// dropping the oldest closed-segment recaps until the full prompt fits within
329// TotalContextBudget. It keeps at least one recap so some continuity survives.
330func (o *Orchestrator) dropOldestRecapsIfOverBudget() {
331 for o.state.PromptCharCount() > o.totalBudget && o.state.ClosedCount() > 1 {
332 o.state.DropOldestSummary()
333 }
334}
335
336// handleNewPartner runs the new-partner flow for an applied reply. When the
337// reply introduces a conversation partner with no stored sheet, it generates
338// and registers that sheet; if the intro carried no speech, it then records a
339// nudge and fires one continuation advance so the person speaks from their
340// sheet. It returns the reply to display and speak (the continuation when one
341// fired) plus the merged narration desc for the whole turn. Sheet and
342// continuation failures are best-effort: the intro stands alone.
343func (o *Orchestrator) handleNewPartner(ctx context.Context, reply llm.GameReply) (llm.GameReply, string) {
344 if !o.isNewPartner() {
345 return reply, reply.Desc
346 }
347 o.generateSheet(ctx, reply)
348 if hasSpeech(reply) {
349 // The model put speech in the intro anyway: a continuation would
350 // double-speak, so the sheet applies from the next turn on.
351 return reply, reply.Desc
352 }
353 o.state.RecordUser(newPartnerNudge)
354 cont, err := o.game.Advance(ctx, o.state.Prompt())
355 if err != nil {
356 return reply, reply.Desc
357 }
358 o.applyReplyAndCompact(ctx, cont)
359 shown, desc := o.handleNewPartner(ctx, cont)
360 return shown, joinDescs(reply.Desc, desc)
361}
362
363// isNewPartner reports whether the just-applied reply introduced a conversation
364// partner that has no stored sheet yet.
365func (o *Orchestrator) isNewPartner() bool {
366 if o.state.Talk() == "none" {
367 return false
368 }
369 _, known := o.state.SheetFor(o.state.Location(), o.state.Talk())
370 return !known
371}
372
373// generateSheet generates and registers the character sheet for a new partner
374// from their introducing reply. Best-effort: on failure the conversation
375// continues without a sheet, same stance as compaction failures.
376func (o *Orchestrator) generateSheet(ctx context.Context, intro llm.GameReply) {
377 firstLine := ""
378 if hasSpeech(intro) {
379 firstLine = intro.Romaji
380 }
381 sheet, err := o.sheet.GenerateSheet(ctx, o.state.Brief(), intro.Location, intro.Talk, intro.Desc, firstLine)
382 if err != nil {
383 return
384 }
385 o.state.RegisterSheet(intro.Location, intro.Talk, sheet)
386}
387
388// speakerGender returns the stored sheet's gender for a location + talk pair,
389// or "" when no sheet is known (ambient line -> default voice).
390func (o *Orchestrator) speakerGender(location, talk string) string {
391 sheet, ok := o.state.SheetFor(location, talk)
392 if !ok {
393 return ""
394 }
395 return sheet.Gender
396}
397
398func joinDescs(a, b string) string {
399 return strings.TrimSpace(a + " " + b)
400}
401
402// displayFromTurn builds one display entry: the shown reply's speech triple,
403// the turn's merged narration desc, and judge feedback when present.
404func displayFromTurn(action string, shown llm.GameReply, desc string, j llm.JudgeResult, hasJudge bool) DisplayEntry {
405 e := DisplayEntry{Action: action, Desc: desc, Romaji: shown.Romaji, English: shown.English, HasSpeech: hasSpeech(shown)}
406 if hasJudge {
407 e.HasJudge = true
408 e.PlayerRomaji = j.Romaji
409 e.Score = j.Score
410 e.Feedback = j.Feedback
411 }
412 return e
413}
414
415func toLLMMessages(ms []Message) []llm.Message {
416 out := make([]llm.Message, 0, len(ms))
417 for _, m := range ms {
418 out = append(out, llm.Message{Role: roleOf(m.Role), Content: m.Content})
419 }
420 return out
421}
422
423func withPrefix(prefix string, msgs []llm.Message) []llm.Message {
424 if strings.TrimSpace(prefix) == "" {
425 return msgs
426 }
427 return append([]llm.Message{{Role: llm.RoleUser, Content: "Earlier at this location: " + prefix}}, msgs...)
428}