533d702bae7394f98072f9283316cb29b3f6b750

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

Message

Simplify persona loading

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)