533d702bae7394f98072f9283316cb29b3f6b750
- Author
- TheEdgeOfRage <git@theedgeofrage.com>
- Committer
- TheEdgeOfRage <git@theedgeofrage.com>
- Date
Message
Diff
This diff is truncated to protect this page.
1diff --git a/AGENTS.md b/AGENTS.md
2index e82f399a3220637eeadf451edf971dcc1c86fc83..4d85806c5590a5e3592a6215460887b6ca65cad4 100644
3--- a/AGENTS.md
4+++ b/AGENTS.md
5@@ -40,8 +40,7 @@ almost fully allocated to the chat model.
6 - Render romaji only; never show kana or kanji in the TUI.
7 - NPCs behave as normal people, not language teachers. Grading stays separate
8 from NPC dialogue; judge output never enters the NPC prompt.
9-- Persona data contains no game, player, NPC, quest, or scenario context
10- (enforced by `persona.Validate`).
11+- Persona data contains no game, player, NPC, quest, or scenario context.
12 - Keep the Bubble Tea event loop non-blocking.
13
14 ## Development
15diff --git a/assets/personas/ramen-shop.json b/assets/personas/ramen-shop.json
16index 9908b09bdeeae406392817cf760c8d43340b7d1d..db10e2ebc2d0840b0b3fc0d07997f86bf4e992af 100644
17--- a/assets/personas/ramen-shop.json
18+++ b/assets/personas/ramen-shop.json
19@@ -1,11 +1,5 @@
20 {
21 "name": "Kenji",
22 "location": "ramen-shop",
23- "personality": "tired, blunt, focused on the pot; does not stop mid-task for small talk",
24- "speech_style": "short, plain sentences; answers what is asked and moves on",
25- "situation": "You are a cook at a small ramen counter in the evening. A customer approaches the counter to order.",
26- "ambiguity": {
27- "mode": "carry-on",
28- "note": "Carries on with the order after understandable minor mistakes; repeats back a dish or size only when it is genuinely unclear."
29- }
30diff --git a/assets/personas/station.json b/assets/personas/station.json
31index af392074fd727cb1e0242903893f6799451f920e..8d3bbbedebcc7228bbf8a40a2c346dcc76376c05 100644
32--- a/assets/personas/station.json
33+++ b/assets/personas/station.json
34@@ -1,11 +1,5 @@
35 {
36 "name": "Sato",
37 "location": "station",
38- "personality": "precise, careful, methodical; checks details before acting",
39- "speech_style": "clear, orderly sentences; states the destination and fare plainly",
40- "situation": "You are a clerk at a train station ticket window. A passenger asks about tickets.",
41- "ambiguity": {
42- "mode": "confirm",
43- "note": "Confirms the destination and number of tickets before proceeding when they are not fully clear."
44- }
45diff --git a/assets/personas/tourist-info.json b/assets/personas/tourist-info.json
46index d1bf868a8fe0306820c50b4feb4baec44dc50df7..6d96cbbe9596068e060107efe9d5d75154ccb3dc 100644
47--- a/assets/personas/tourist-info.json
48+++ b/assets/personas/tourist-info.json
49@@ -1,11 +1,5 @@
50 {
51 "name": "Miyu",
52 "location": "tourist-info",
53- "personality": "friendly, patient, helpful; likes to make visitors feel welcome",
54- "speech_style": "warm, conversational sentences; offers simple directions and options",
55- "situation": "You are at a tourist information desk in the city centre. A visitor asks where to go.",
56- "ambiguity": {
57- "mode": "confirm",
58- "note": "Asks one short clarifying question only when the place or direction is genuinely unclear; otherwise gives an answer."
59- }
60diff --git a/cmd/jp/main.go b/cmd/jp/main.go
61index 549b26135d85b7c5fb858eaace87982866728b3d..de0bec38b5a39a3892754b44df40cf67c8664ea0 100644
62--- a/cmd/jp/main.go
63+++ b/cmd/jp/main.go
64@@ -56,8 +56,10 @@ func main() {
65 if err != nil {
66 fatalf("load personas: %v", err)
67 }
68-
69- views := buildPersonaViews(personas)
70+ views := make(map[string]string, len(personas))
71+ for _, p := range personas {
72+ views[p.Location] = p.Description
73+ }
74 if llmUp {
75 ctx, cancel := context.WithTimeout(context.Background(), warmupTimeout)
76 err := llmClient.Warmup(ctx, warmupSystems(views))
77@@ -87,7 +89,7 @@ func serviceURLs(cfg *config.Config) map[string]string {
78 }
79 }
80
81-func buildOrchestrator(cfg *config.Config, state *game.State, views map[string]game.PersonaView, playDone chan struct{}, hc *http.Client, llmClient *llm.Client) *game.Orchestrator {
82+func buildOrchestrator(cfg *config.Config, state *game.State, views map[string]string, playDone chan struct{}, hc *http.Client, llmClient *llm.Client) *game.Orchestrator {
83 npcModel := &adapters.NPCModel{Client: llmClient, Policy: llm.DefaultHistoryPolicy()}
84 judgeModel := &adapters.JudgeModel{Client: llmClient}
85
86@@ -111,27 +113,15 @@ func buildOrchestrator(cfg *config.Config, state *game.State, views map[string]g
87
88 // warmupSystems lists every system prompt the game will send: one per location
89 // plus the judge. Warming them pins each prefix in the server's KV cache.
90-func warmupSystems(views map[string]game.PersonaView) []string {
91+func warmupSystems(views map[string]string) []string {
92 out := make([]string, 0, len(views)+1)
93 for _, v := range views {
94- out = append(out, llm.NPCSystemPrompt(v.Description, v.Situation))
95+ out = append(out, llm.NPCSystemPrompt(v))
96 }
97 out = append(out, llm.JudgeSystemPrompt())
98 return out
99 }
100
101-func buildPersonaViews(personas []persona.Persona) map[string]game.PersonaView {
102- views := make(map[string]game.PersonaView, len(personas))
103- for _, p := range personas {
104- views[p.Location] = game.PersonaView{Description: composeDescription(p), Situation: p.Situation}
105- }
106- return views
107-}
108-
109-func composeDescription(p persona.Persona) string {
110- return fmt.Sprintf("%s. %s Speech style: %s.", p.Name, p.Personality, p.SpeechStyle)
111-}
112-
113 func fatalf(format string, args ...any) {
114 fmt.Fprintf(os.Stderr, "jp: "+format+"\n", args...)
115 os.Exit(1)
116diff --git a/internal/adapters/llm.go b/internal/adapters/llm.go
117index 20f1220ba395987ef8d96d8ba874ecc792333f88..8123b23cf458efd2ae06e42704c81c597f52657b 100644
118--- a/internal/adapters/llm.go
119+++ b/internal/adapters/llm.go
120@@ -28,8 +28,8 @@ func dumpRaw(label, transcript, raw string) {
121 fmt.Fprintf(f, "--- %s %s ---\ntranscript: %s\n%s\n\n", label, time.Now().Format(time.RFC3339), transcript, raw)
122 }
123
124-// NPCModel adapts an llm.Client to game.NPCModel. It builds the persona and
125-// situation prompt with bounded history and parses the strict three-field reply.
126+// NPCModel adapts an llm.Client to game.NPCModel. It builds the persona prompt
127+// with bounded history and parses the strict three-field reply.
128 type NPCModel struct {
129 Client *llm.Client
130 Policy llm.HistoryPolicy
131@@ -40,7 +40,7 @@ func (m *NPCModel) Reply(ctx context.Context, req game.NPCRequest) (llm.NPCReply
132 if policy.MaxTurns == 0 && policy.PromptCharBudget == 0 {
133 policy = llm.DefaultHistoryPolicy()
134 }
135- msgs := llm.BuildNPCMessages(req.Persona, req.Situation, req.Transcript, req.History, policy)
136+ msgs := llm.BuildNPCMessages(req.Persona, req.Transcript, req.History, policy)
137 raw, err := m.Client.Generate(ctx, msgs)
138 if err != nil {
139 return llm.NPCReply{}, err
140@@ -50,17 +50,17 @@ func (m *NPCModel) Reply(ctx context.Context, req game.NPCRequest) (llm.NPCReply
141 }
142
143 // JudgeModel adapts an llm.Client to game.JudgeModel. It issues the separate,
144-// persona-free judge request and parses the two-field result.
145+// persona-free judge request and parses the strict three-field result.
146 type JudgeModel struct {
147 Client *llm.Client
148 }
149
150-func (m *JudgeModel) Judge(ctx context.Context, req game.JudgeRequest) (llm.JudgeResult, error) {
151- msgs := llm.BuildJudgeMessages(req.Situation, req.Transcript)
152+func (m *JudgeModel) Judge(ctx context.Context, transcript string) (llm.JudgeResult, error) {
153+ msgs := llm.BuildJudgeMessages(transcript)
154 raw, err := m.Client.Generate(ctx, msgs)
155 if err != nil {
156 return llm.JudgeResult{}, err
157 }
158- dumpRaw("judge", req.Transcript, raw)
159+ dumpRaw("judge", transcript, raw)
160 return llm.ParseJudge(raw)
161 }
162diff --git a/internal/game/orchestrator.go b/internal/game/orchestrator.go
163index 4a4b5231a2a140f4057eaf36c1b1a160410f8150..96d649e375cd773995e8fb72795a403c839d65b2 100644
164--- a/internal/game/orchestrator.go
165+++ b/internal/game/orchestrator.go
166@@ -19,7 +19,6 @@ type SpeechInput interface {
167 // never appears in it.
168 type NPCRequest struct {
169 Persona string // stable persona description
170- Situation string // local situation text
171 Transcript string // raw transcript (Japanese)
172 History []llm.Turn // prior turns; the adapter bounds this
173 }
174@@ -29,15 +28,9 @@ type NPCModel interface {
175 Reply(ctx context.Context, req NPCRequest) (llm.NPCReply, error)
176 }
177
178-// JudgeRequest carries what the judge needs to score one spoken line.
179-type JudgeRequest struct {
180- Situation string
181- Transcript string // raw transcript
182-}
183-
184-// JudgeModel scores a spoken line. It is independent of the NPC model.
185+// JudgeModel scores one spoken line. It is independent of the NPC model.
186 type JudgeModel interface {
187- Judge(ctx context.Context, req JudgeRequest) (llm.JudgeResult, error)
188+ Judge(ctx context.Context, transcript string) (llm.JudgeResult, error)
189 }
190
191 // SpeechOutput synthesizes and plays an NPC reply's audio from its kana.
192@@ -45,13 +38,6 @@ type SpeechOutput interface {
193 Speak(ctx context.Context, kana string) error
194 }
195
196-// PersonaView is the stable persona data the orchestrator sends to the models
197-// for one location: a composed description and the local situation text.
198-type PersonaView struct {
199- Description string
200- Situation string
201-}
202-
203 // ErrNotInLocation is returned when a turn is finished outside any location.
204 var ErrNotInLocation = errors.New("game: not in a location")
205
206@@ -72,8 +58,8 @@ type TurnResult struct {
207 // explicit await on the caller's context, so it is safe to run off a UI event
208 // loop (for example from a Bubble Tea command goroutine).
209 type Orchestrator struct {
210- state *State
211- personas map[string]PersonaView
212+ state *State
213+ personas map[string]string // location -> stable persona description
214 speechIn SpeechInput
215 npc NPCModel
216 judge JudgeModel
217@@ -81,7 +67,7 @@ type Orchestrator struct {
218 }
219
220 // NewOrchestrator wires an orchestrator over a state and the four adapters.
221-func NewOrchestrator(state *State, personas map[string]PersonaView, in SpeechInput, npc NPCModel, judge JudgeModel, out SpeechOutput) *Orchestrator {
222+func NewOrchestrator(state *State, personas map[string]string, in SpeechInput, npc NPCModel, judge JudgeModel, out SpeechOutput) *Orchestrator {
223 return &Orchestrator{
224 state: state,
225 personas: personas,
226@@ -117,7 +103,7 @@ func (o *Orchestrator) Finish(ctx context.Context) TurnResult {
227 return res
228 }
229
230- view := o.personas[loc]
231+ desc := o.personas[loc]
232 prior := toLLMTurns(o.state.History(loc))
233
234 var (
235@@ -130,13 +116,12 @@ func (o *Orchestrator) Finish(ctx context.Context) TurnResult {
236 wg.Add(2)
237 go func() {
238 defer wg.Done()
239- judgeRes, judgeErr = o.judge.Judge(ctx, JudgeRequest{Situation: view.Situation, Transcript: raw})
240+ judgeRes, judgeErr = o.judge.Judge(ctx, raw)
241 }()
242 go func() {
243 defer wg.Done()
244 npcRes, npcErr = o.npc.Reply(ctx, NPCRequest{
245- Persona: view.Description,
246- Situation: view.Situation,
247+ Persona: desc,
248 Transcript: raw,
249 History: prior,
250 })
251diff --git a/internal/llm/prompt.go b/internal/llm/prompt.go
252index 9fb65176de2db58a95f0f1e9864ffc9ddbd4fbbf..2f8aba322ee4fd3ede456a6502609d291db78b97 100644
253--- a/internal/llm/prompt.go
254+++ b/internal/llm/prompt.go
255@@ -5,14 +5,12 @@ import (
256 "strings"
257 )
258
259-// npcSystemTemplate describes a real situation only and fixes the three-field
260-// reply contract. It never names a game, quest, or scenario, never refers to
261-// grading, and tells the model to speak as a normal person rather than a
262-// teacher. The persona and situation are inserted as authored data.
263+// npcSystemTemplate wraps the authored persona description and fixes the
264+// three-field reply contract. It never names a game, quest, or scenario,
265+// never refers to grading, and tells the model to speak as a normal person
266+// rather than a teacher.
267 const npcSystemTemplate = `You are %s.
268
269-Situation: %s
270-
271 Speak naturally, as yourself, in Japanese. Keep your reply short and conversational. Do not teach language, correct mistakes, or mention any evaluation.
272
273 Your reply must be exactly three lines of plain text, in this order. No extra lines, no markdown, no code fences. Every line starts with its field key, then a pipe character |, then the value. Never omit, rename, or reorder the keys.
274@@ -27,10 +25,10 @@ KANA|ねえさん、注文は?
275 ENGLISH|Miss, what is your order?`
276
277 // judgeSystemTemplate is persona-free and fixes the three-field judge contract.
278-// It scores fluency, naturalness, and fit to the situation, accepting any
279-// natural phrasing, and transcribes the given line into Hepburn romaji. It never
280-// names a game, quest, or scenario.
281diff --git a/internal/persona/persona.go b/internal/persona/persona.go
282index 61f2e423d072f526d176c708f7aba8fed0826489..f295e339eeebddb1de29f10e1ea026271ff8784e 100644
283--- a/internal/persona/persona.go
284+++ b/internal/persona/persona.go
285@@ -1,110 +1,31 @@
286-// Package persona holds the authored, reviewed persona data that drives NPC
287-// speech in a real-world situation. A persona is stable data: who the person
288-// is, how they speak, the local situation they are in, and how they handle
289-// unclear input based on their personality. Persona content never mentions a
290-// game, player, NPC, quest, or scenario.
291+// Package persona holds the authored persona data that drives NPC speech. A
292+// persona is stable data bound to a single location; its description carries
293+// all instructions for playing the person.
294 package persona
295
296 import (
297- "bytes"
298 "encoding/json"
299 "fmt"
300 "os"
301 "path/filepath"
302- "regexp"
303 "sort"
304 )
305
306-// AmbiguityMode is an authored, personality-based way an NPC handles input it
307-// does not fully understand.
308-type AmbiguityMode string
309-
310-const (
311- // CarryOn means the NPC carries on with the conversation after
312- // understandable minor mistakes and only repeats back a detail when it is
313- // genuinely unclear.
314- CarryOn AmbiguityMode = "carry-on"
315- // Confirm means the NPC confirms specifics before proceeding when they are
316- // not fully clear, as a detail-oriented person naturally would.
317- Confirm AmbiguityMode = "confirm"
318-)
319-
320-var ambiguityModes = map[AmbiguityMode]bool{
321- CarryOn: true,
322- Confirm: true,
323-}
324-
325-// Ambiguity captures how this person handles unclear input, shaped by their
326-// personality rather than any teaching role.
327-type Ambiguity struct {
328- Mode AmbiguityMode `json:"mode"`
329- Note string `json:"note"`
330-}
331-
332-// Persona is one reviewed NPC character bound to a single location.
333+// Persona is one authored NPC character bound to a single location. The
334+// description contains everything needed to play the person: who they are,
335+// how they speak, the local situation, and how they handle unclear input.
336 type Persona struct {
337- Name string `json:"name"`
338- Location string `json:"location"`
339- Personality string `json:"personality"`
340- SpeechStyle string `json:"speech_style"`
341- Situation string `json:"situation"`
342- Ambiguity Ambiguity `json:"ambiguity"`
343+ Name string `json:"name"`
344+ Location string `json:"location"`
345+ Description string `json:"description"`
346 }
347
348-var gameContextWord = regexp.MustCompile(`(?i)\b(game|player|npc|quest|scenario)\b`)
349-
350-// Validate checks a persona's required fields and the romaji-only / no-game-
351-// context rules. It returns a clear error naming the offending field.
352-func Validate(p Persona) error {
353- if p.Name == "" {
354- return fmt.Errorf("persona: name must not be empty")
355- }
356- if hasJapaneseScript(p.Name) {
357- return fmt.Errorf("persona: name %q must be romaji only, no kana or kanji", p.Name)
358- }
359- if p.Location == "" {
360- return fmt.Errorf("persona: location must not be empty")
361- }
362- if p.Personality == "" {
363- return fmt.Errorf("persona: personality must not be empty")
364- }
365- if p.SpeechStyle == "" {
366- return fmt.Errorf("persona: speech_style must not be empty")
367- }
368- if p.Situation == "" {
369- return fmt.Errorf("persona: situation must not be empty")
370- }
371- if !ambiguityModes[p.Ambiguity.Mode] {
372- return fmt.Errorf("persona: ambiguity.mode %q is not allowed", p.Ambiguity.Mode)
373- }
374- if p.Ambiguity.Note == "" {
375- return fmt.Errorf("persona: ambiguity.note must not be empty")
376- }
377- for field, value := range map[string]string{
378- "personality": p.Personality,
379- "speech_style": p.SpeechStyle,
380- "situation": p.Situation,
381- "ambiguity.note": p.Ambiguity.Note,
382- } {
383- if gameContextWord.MatchString(value) {
384- return fmt.Errorf("persona: %s mentions game context", field)