Parent directory

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}