c3ce85b5a15e765c448558d1014ac85e78727217

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

Message

Add scratch questions: type ?question to ask how to say something in Japanese

Diff

This diff is truncated to protect this page.

  1diff --git a/README.md b/README.md
  2index bc7c006d3bff3b0737abd8179fea0d739c6fb182..387ef519fe72e249a69cac917b30c7afca8d349c 100644
  3--- a/README.md
  4+++ b/README.md
  5@@ -51,7 +51,7 @@ which win over defaults. Defaults match a local setup.
  6 | Max tokens       | `--max-tokens`       | `MAX_TOKENS`          | `256`                                     | max output tokens per reply                     |
  7 | Thinking mode    | `--enable-thinking`  | `ENABLE_THINKING`     | `false`                                   | Qwen3 thinking (`chat_template_kwargs.enable_thinking`) |
  8 | LLM game slot    | `--llm-game-slot`    | `JP_LLM_GAME_SLOT`    | `0`                                       | llama-server slot for game-loop requests (`-1` = server decides) |
  9-| LLM scratch slot | `--llm-scratch-slot` | `JP_LLM_SCRATCH_SLOT` | `1`                                       | llama-server slot for judge, compaction, and sheet requests (`-1` = server decides) |
 10+| LLM scratch slot | `--llm-scratch-slot` | `JP_LLM_SCRATCH_SLOT` | `1`                                       | llama-server slot for judge, compaction, sheet, and ask requests (`-1` = server decides) |
 11 | Audio base URL   | `--audio-url`        | `JP_AUDIO_BASE_URL`   | `http://127.0.0.1:8080`                  | audio.cpp server (TTS + ASR)                    |
 12 | Recorder command | `--record-command`   | —                     | `arecord`                                 | 16 kHz mono S16_LE WAV capture (see note above) |
 13 | Scenario brief   | `--scenario`         | `JP_SCENARIO_PATH`    | `assets/scenarios/small_city.md`          | plain-text scenario brief file                  |
 14@@ -61,6 +61,8 @@ Run `jp -help` for the full flag list.
 15 ## Controls
 16 
 17 - Type an action in English and press `enter` to send it
 18+- Ask how to say something in Japanese: type `?your question` and press
 19+  `enter`. The answer is a learning aid; it does not affect the world
 20 - Push-to-talk: `F2` (press to start recording, press again to stop and send
 21   the turn)
 22 - Reveal NPC romaji: `F3`
 23diff --git a/cmd/jp/main.go b/cmd/jp/main.go
 24index ae729cd8c6c9c756a3233d02b655a6374009426e..72308cd7fd897afea3293e57cf529a9c235f569d 100644
 25--- a/cmd/jp/main.go
 26+++ b/cmd/jp/main.go
 27@@ -53,7 +53,7 @@ func main() {
 28 		ctx, cancel := context.WithTimeout(context.Background(), warmupTimeout)
 29 		err := gameClient.Warmup(ctx, []string{llm.GameSystemPrompt(sc.Brief)})
 30 		if err == nil {
 31-			err = scratchClient.Warmup(ctx, []string{llm.JudgeSystemPrompt(), llm.CompactionPrompt(), llm.SheetSystemPrompt()})
 32+			err = scratchClient.Warmup(ctx, []string{llm.JudgeSystemPrompt(), llm.CompactionPrompt(), llm.SheetSystemPrompt(), llm.ScratchSystemPrompt()})
 33 		}
 34 		cancel()
 35 		if err != nil {
 36@@ -75,6 +75,7 @@ func buildOrchestrator(cfg *config.Config, state *game.State, hc *http.Client, g
 37 	compactor := &adapters.Compactor{Client: scratchClient}
 38 	judgeModel := &adapters.JudgeModel{Client: scratchClient}
 39 	sheetModel := &adapters.SheetModel{Client: scratchClient}
 40+	askModel := &adapters.ScratchModel{Client: scratchClient}
 41 
 42 	speechIn := &adapters.SpeechInput{
 43 		Recorder: stt.NewRecorder(cfg.RecordCommand, recordCap),
 44@@ -85,7 +86,7 @@ func buildOrchestrator(cfg *config.Config, state *game.State, hc *http.Client, g
 45 		Player: tts.NewPlayer(),
 46 	}
 47 
 48-	return game.NewOrchestrator(state, gameModel, judgeModel, compactor, sheetModel, speechIn, speechOut)
 49+	return game.NewOrchestrator(state, gameModel, judgeModel, compactor, sheetModel, askModel, speechIn, speechOut)
 50 }
 51 
 52 func fatalf(format string, args ...any) {
 53diff --git a/internal/adapters/llm.go b/internal/adapters/llm.go
 54index d80a5398fabed120959c4b16ea3ac628f41ca8b2..fd4d9f396490afc8dc2a34d3671b5242d89f2460 100644
 55--- a/internal/adapters/llm.go
 56+++ b/internal/adapters/llm.go
 57@@ -89,3 +89,18 @@ func (m *JudgeModel) Judge(ctx context.Context, transcript string) (llm.JudgeRes
 58 	dumpRaw("judge", transcript, raw)
 59 	return llm.ParseJudge(raw)
 60 }
 61+
 62+// ScratchModel adapts an llm.Client to game.ScratchModel. It asks how to say a
 63+// thing in Japanese and returns the raw answer text; no world context is sent.
 64+type ScratchModel struct {
 65+	Client *llm.Client
 66+}
 67+
 68+func (m *ScratchModel) Ask(ctx context.Context, question string) (string, error) {
 69+	raw, err := m.Client.Generate(ctx, llm.BuildScratchMessages(question))
 70+	if err != nil {
 71+		return "", err
 72+	}
 73+	dumpRaw("scratch", question, raw)
 74+	return strings.TrimSpace(raw), nil
 75+}
 76diff --git a/internal/config/config.go b/internal/config/config.go
 77index c984aaebc6ee723cc06bae5e7a0666760cd55517..0f128a53a4a2eb837a384afeba6cdfe4eb78a5c1 100644
 78--- a/internal/config/config.go
 79+++ b/internal/config/config.go
 80@@ -18,7 +18,7 @@ type LLMConfig struct {
 81 	MaxTokens      int     `long:"max-tokens" env:"MAX_TOKENS" default:"256"`
 82 	EnableThinking bool    `long:"enable-thinking" env:"ENABLE_THINKING"`
 83 	GameSlot       int     `long:"llm-game-slot" env:"JP_LLM_GAME_SLOT" default:"0" description:"llama-server slot for game-loop requests (-1 = server decides)"`
 84-	ScratchSlot    int     `long:"llm-scratch-slot" env:"JP_LLM_SCRATCH_SLOT" default:"1" description:"llama-server slot for judge and compaction requests (-1 = server decides)"`
 85+	ScratchSlot    int     `long:"llm-scratch-slot" env:"JP_LLM_SCRATCH_SLOT" default:"1" description:"llama-server slot for judge, compaction, sheet, and ask requests (-1 = server decides)"`
 86 }
 87 
 88 // Config is the shared option group. Parsed with github.com/jessevdk/go-flags.
 89diff --git a/internal/game/orchestrator.go b/internal/game/orchestrator.go
 90index 256fc511244ec4c231915c4616eb1d7a69f70f0a..28dc7f5c420449c3a25c3f2c3933148a01c84c0f 100644
 91--- a/internal/game/orchestrator.go
 92+++ b/internal/game/orchestrator.go
 93@@ -43,6 +43,12 @@ type SheetModel interface {
 94 	GenerateSheet(ctx context.Context, brief, location, talk, intro, firstLine string) (llm.Sheet, error)
 95 }
 96 
 97+// ScratchModel answers one scratch question about how to say something in
 98+// Japanese. It is independent of the game model and sees no world context.
 99+type ScratchModel interface {
100+	Ask(ctx context.Context, question string) (string, error)
101+}
102+
103 // newPartnerNudge is the user message that pulls a new partner's first line
104 // after their character sheet is registered.
105 const newPartnerNudge = "(The scene continues. The person the player is now talking to speaks first.)"
106@@ -72,6 +78,7 @@ type Orchestrator struct {
107 	judge     JudgeModel
108 	compactor Compactor
109 	sheet     SheetModel
110+	ask       ScratchModel
111 	speechIn  SpeechInput
112 	speechOut SpeechOutput
113 
114@@ -79,14 +86,15 @@ type Orchestrator struct {
115 	budget      int
116 }
117 
118-// NewOrchestrator wires an orchestrator over a state and the six adapters.
119-func NewOrchestrator(state *State, g GameModel, j JudgeModel, c Compactor, sh SheetModel, in SpeechInput, out SpeechOutput) *Orchestrator {
120+// NewOrchestrator wires an orchestrator over a state and the seven adapters.
121+func NewOrchestrator(state *State, g GameModel, j JudgeModel, c Compactor, sh SheetModel, ask ScratchModel, in SpeechInput, out SpeechOutput) *Orchestrator {
122 	return &Orchestrator{
123 		state:       state,
124 		game:        g,
125 		judge:       j,
126 		compactor:   c,
127 		sheet:       sh,
128+		ask:         ask,
129 		speechIn:    in,
130 		speechOut:   out,
131 		staticChars: utf8.RuneCountInString(llm.GameSystemPrompt(state.Brief())),
132@@ -126,6 +134,21 @@ func (o *Orchestrator) ActionTurn(ctx context.Context, action string) TurnResult
133 	return res
134 }
135 
136+// AskTurn runs one scratch question: ask the scratch model how to say the thing
137+// and record one display entry. It never touches world state, plays speech, or
138+// runs the judge.
139+func (o *Orchestrator) AskTurn(ctx context.Context, action string) TurnResult {
140+	var res TurnResult
141+	question := strings.TrimSpace(strings.TrimPrefix(action, "?"))
142+	answer, err := o.ask.Ask(ctx, question)
143+	if err != nil {
144+		res.Err = err
145+		return res
146+	}
147+	o.state.RecordDisplay(DisplayEntry{Action: action, Desc: answer})
148+	return res
149+}
150+
151 // FinishSpeak runs one spoken turn: stop and transcribe the recording, then run
152 // the judge and the game advance in parallel, play speech if present, and log.
153 func (o *Orchestrator) FinishSpeak(ctx context.Context) TurnResult {
154diff --git a/internal/llm/prompt.go b/internal/llm/prompt.go
155index 22d2fa5502ad331548e647f636b1d97898c02459..de0781fc3b43183fc1c85a21c570ecbda9ecd410 100644
156--- a/internal/llm/prompt.go
157+++ b/internal/llm/prompt.go
158@@ -140,3 +140,29 @@ func BuildSheetMessages(brief, location, talk, intro, firstLine string) []Messag
159 		{Role: RoleUser, Content: strings.TrimRight(b.String(), "\n")},
160 	}
161 }
162+
163+// scratchSystemTemplate is the static system message for a scratch question. It
164+// answers one "how do I say X" question with romaji plus a breakdown and sees
165+// no game context.
166diff --git a/internal/ui/app.go b/internal/ui/app.go
167index 0e764f384301ad2095793d97d550ecb943088f98..0d62879099ecf0161cccbe1795ee783abb418f5f 100644
168--- a/internal/ui/app.go
169+++ b/internal/ui/app.go
170@@ -3,6 +3,7 @@ package ui
171 import (
172 	"context"
173 	"fmt"
174+	"strings"
175 	"time"
176 
177 	"github.com/charmbracelet/bubbletea"
178@@ -106,9 +107,18 @@ func (m *model) handleKey(k tea.KeyMsg) tea.Cmd {
179 		}
180 	case tea.KeyEnter:
181 		if m.phase == phaseIdle && m.input != "" {
182+			if strings.HasPrefix(m.input, "?") {
183+				question := strings.TrimSpace(strings.TrimPrefix(m.input, "?"))
184+				if question == "" {
185+					return nil // bare "?": keep the buffer
186+				}
187+			}
188 			action := m.input
189 			m.input = ""
190 			m.phase = phaseProcessing
191+			if strings.HasPrefix(action, "?") {
192+				return askTurn(m.orch, action)
193+			}
194 			return actionTurn(m.orch, action)
195 		}
196 	case tea.KeyBackspace:
197@@ -258,6 +268,12 @@ func actionTurn(o *game.Orchestrator, action string) tea.Cmd {
198 	}
199 }
200 
201+func askTurn(o *game.Orchestrator, question string) tea.Cmd {
202+	return func() tea.Msg {
203+		return finishResultMsg{res: o.AskTurn(context.Background(), question)}
204+	}
205+}
206+
207 func nextTick() tea.Cmd {
208 	return tea.Tick(tickInterval, func(time.Time) tea.Msg { return tickMsg{} })
209 }
210diff --git a/internal/ui/render.go b/internal/ui/render.go
211index 4862ac3d5109d7d2ac4367955bf91adb3772e6ee..48b9ad31c7e8da98654f480147ef0fb4e574764b 100644
212--- a/internal/ui/render.go
213+++ b/internal/ui/render.go
214@@ -9,7 +9,7 @@ import (
215 )
216 
217 // controlsHint is the idle status line. It lists only keys wired in this build.
218-const controlsHint = "F2 talk · F3 romaji · F4 english · Esc quit"
219+const controlsHint = "F2 talk · F3 romaji · F4 english · ? ask · Esc quit"
220 
221 // viewState is the plain, render-only snapshot the pure render functions take.
222 // It holds no pointers and no game types, so rendering stays trivially pure.