9da7c517be1dec544511d1bff6868d0bad0e8df7

Author
TheEdgeOfRage <git@theedgeofrage.com>
Committer
TheEdgeOfRage <git@theedgeofrage.com>
Date

Message

Add new-partner conversation flow with character sheets

Diff

This diff is truncated to protect this page.

  1diff --git a/cmd/jp/main.go b/cmd/jp/main.go
  2index 0da8cfa1ad958185d0d5b02001dc707aeb1662b6..c963a648cb69e449299a6b66b4a5602f1325c142 100644
  3--- a/cmd/jp/main.go
  4+++ b/cmd/jp/main.go
  5@@ -56,7 +56,7 @@ func main() {
  6 		ctx, cancel := context.WithTimeout(context.Background(), warmupTimeout)
  7 		err := gameClient.Warmup(ctx, []string{llm.GameSystemPrompt(sc.Brief)})
  8 		if err == nil {
  9-			err = scratchClient.Warmup(ctx, []string{llm.JudgeSystemPrompt(), llm.CompactionPrompt()})
 10+			err = scratchClient.Warmup(ctx, []string{llm.JudgeSystemPrompt(), llm.CompactionPrompt(), llm.SheetSystemPrompt()})
 11 		}
 12 		cancel()
 13 		if err != nil {
 14@@ -77,6 +77,7 @@ func buildOrchestrator(cfg *config.Config, state *game.State, hc *http.Client, g
 15 	gameModel := &adapters.GameModel{Client: gameClient}
 16 	compactor := &adapters.Compactor{Client: scratchClient}
 17 	judgeModel := &adapters.JudgeModel{Client: scratchClient}
 18+	sheetModel := &adapters.SheetModel{Client: scratchClient}
 19 
 20 	speechIn := &adapters.SpeechInput{
 21 		Recorder: stt.NewRecorder(cfg.RecordCommand, recordCap),
 22@@ -87,7 +88,7 @@ func buildOrchestrator(cfg *config.Config, state *game.State, hc *http.Client, g
 23 		Player: tts.NewPlayer(),
 24 	}
 25 
 26-	return game.NewOrchestrator(state, gameModel, judgeModel, compactor, speechIn, speechOut)
 27+	return game.NewOrchestrator(state, gameModel, judgeModel, compactor, sheetModel, speechIn, speechOut)
 28 }
 29 
 30 func fatalf(format string, args ...any) {
 31diff --git a/internal/adapters/llm.go b/internal/adapters/llm.go
 32index 378a097f779c095aa86459980c036cf15cfbd051..d80a5398fabed120959c4b16ea3ac628f41ca8b2 100644
 33--- a/internal/adapters/llm.go
 34+++ b/internal/adapters/llm.go
 35@@ -58,6 +58,22 @@ func (m *Compactor) Summarize(ctx context.Context, location string, msgs []llm.M
 36 	return strings.TrimSpace(raw), nil
 37 }
 38 
 39+// SheetModel adapts an llm.Client to game.SheetModel. It generates one
 40+// person's character sheet from their introducing reply and parses the
 41+// six-field sheet contract.
 42+type SheetModel struct {
 43+	Client *llm.Client
 44+}
 45+
 46+func (m *SheetModel) GenerateSheet(ctx context.Context, brief, location, talk, intro, firstLine string) (llm.Sheet, error) {
 47+	raw, err := m.Client.Generate(ctx, llm.BuildSheetMessages(brief, location, talk, intro, firstLine))
 48+	if err != nil {
 49+		return llm.Sheet{}, err
 50+	}
 51+	dumpRaw("sheet:"+location+":"+talk, "", raw)
 52+	return llm.ParseSheet(raw)
 53+}
 54+
 55 // JudgeModel adapts an llm.Client to game.JudgeModel. It issues the separate,
 56 // persona-free judge request and parses the strict three-field result.
 57 type JudgeModel struct {
 58diff --git a/internal/game/orchestrator.go b/internal/game/orchestrator.go
 59index 1b0696a44dd87c49e6e645985fee000ec9bec517..256fc511244ec4c231915c4616eb1d7a69f70f0a 100644
 60--- a/internal/game/orchestrator.go
 61+++ b/internal/game/orchestrator.go
 62@@ -38,6 +38,15 @@ type Compactor interface {
 63 	Summarize(ctx context.Context, location string, msgs []llm.Message) (string, error)
 64 }
 65 
 66+// SheetModel generates one person's character sheet from their introducing reply.
 67+type SheetModel interface {
 68+	GenerateSheet(ctx context.Context, brief, location, talk, intro, firstLine string) (llm.Sheet, error)
 69+}
 70+
 71+// newPartnerNudge is the user message that pulls a new partner's first line
 72+// after their character sheet is registered.
 73+const newPartnerNudge = "(The scene continues. The person the player is now talking to speaks first.)"
 74+
 75 // ErrEmptyTranscript is returned when push-to-talk produced no usable transcript.
 76 var ErrEmptyTranscript = errors.New("game: empty transcript")
 77 
 78@@ -62,6 +71,7 @@ type Orchestrator struct {
 79 	game      GameModel
 80 	judge     JudgeModel
 81 	compactor Compactor
 82+	sheet     SheetModel
 83 	speechIn  SpeechInput
 84 	speechOut SpeechOutput
 85 
 86@@ -69,13 +79,14 @@ type Orchestrator struct {
 87 	budget      int
 88 }
 89 
 90-// NewOrchestrator wires an orchestrator over a state and the five adapters.
 91-func NewOrchestrator(state *State, g GameModel, j JudgeModel, c Compactor, in SpeechInput, out SpeechOutput) *Orchestrator {
 92+// NewOrchestrator wires an orchestrator over a state and the six adapters.
 93+func NewOrchestrator(state *State, g GameModel, j JudgeModel, c Compactor, sh SheetModel, in SpeechInput, out SpeechOutput) *Orchestrator {
 94 	return &Orchestrator{
 95 		state:       state,
 96 		game:        g,
 97 		judge:       j,
 98 		compactor:   c,
 99+		sheet:       sh,
100 		speechIn:    in,
101 		speechOut:   out,
102 		staticChars: utf8.RuneCountInString(llm.GameSystemPrompt(state.Brief())),
103@@ -104,13 +115,14 @@ func (o *Orchestrator) ActionTurn(ctx context.Context, action string) TurnResult
104 	}
105 	res.Reply = reply
106 	o.applyReplyAndCompact(ctx, reply)
107+	shown, desc := o.handleNewPartner(ctx, reply)
108 
109-	if hasSpeech(reply) {
110-		if err := o.speechOut.Speak(ctx, reply.Kana); err != nil {
111+	if hasSpeech(shown) {
112+		if err := o.speechOut.Speak(ctx, shown.Kana); err != nil {
113 			res.SpeakErr = err
114 		}
115 	}
116-	o.state.RecordDisplay(displayFromReply(action, reply, llm.JudgeResult{}, false))
117+	o.state.RecordDisplay(displayFromTurn(action, shown, desc, llm.JudgeResult{}, false))
118 	return res
119 }
120 
121@@ -160,9 +172,10 @@ func (o *Orchestrator) FinishSpeak(ctx context.Context) TurnResult {
122 	res.HasJudge = true
123 	res.JudgeErr = jerr
124 	o.applyReplyAndCompact(ctx, reply)
125+	shown, desc := o.handleNewPartner(ctx, reply)
126 
127-	if hasSpeech(reply) {
128-		if err := o.speechOut.Speak(ctx, reply.Kana); err != nil {
129+	if hasSpeech(shown) {
130+		if err := o.speechOut.Speak(ctx, shown.Kana); err != nil {
131 			res.SpeakErr = err
132 		}
133 	}
134@@ -170,7 +183,7 @@ func (o *Orchestrator) FinishSpeak(ctx context.Context) TurnResult {
135 	if jerr == nil {
136 		judge = jres
137 	}
138-	o.state.RecordDisplay(displayFromReply("", reply, judge, jerr == nil))
139+	o.state.RecordDisplay(displayFromTurn("", shown, desc, judge, jerr == nil))
140 	return res
141 }
142 
143@@ -217,8 +230,66 @@ func (o *Orchestrator) compactOpenIfOverBudget(ctx context.Context) error {
144 	return nil
145 }
146 
147-func displayFromReply(action string, r llm.GameReply, j llm.JudgeResult, hasJudge bool) DisplayEntry {
148-	e := DisplayEntry{Action: action, Desc: r.Desc, Romaji: r.Romaji, English: r.English, HasSpeech: hasSpeech(r)}
149+// handleNewPartner runs the new-partner flow for an applied reply. When the
150+// reply introduces a conversation partner with no stored sheet, it generates
151+// and registers that sheet; if the intro carried no speech, it then records a
152+// nudge and fires one continuation advance so the person speaks from their
153+// sheet. It returns the reply to display and speak (the continuation when one
154+// fired) plus the merged narration desc for the whole turn. Sheet and
155+// continuation failures are best-effort: the intro stands alone.
156+func (o *Orchestrator) handleNewPartner(ctx context.Context, reply llm.GameReply) (llm.GameReply, string) {
157+	if !o.isNewPartner() {
158+		return reply, reply.Desc
159+	}
160+	o.generateSheet(ctx, reply)
161+	if hasSpeech(reply) {