c3ce85b5a15e765c448558d1014ac85e78727217
- Author
- TheEdgeOfRage <git@theedgeofrage.com>
- Committer
- TheEdgeOfRage <git@theedgeofrage.com>
- Date
Message
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.