28aa6c377c432db7a0bf690df1f96ba725f60fe0

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

Message

Improve rendering of formatted LLM responses

Diff

  1diff --git a/AGENTS.md b/AGENTS.md
  2index 7d33100c926b9d1d250b73fc83a6034cee198cdc..5212071c86234eb66653b33dbacd6a13ebe64171 100644
  3--- a/AGENTS.md
  4+++ b/AGENTS.md
  5@@ -81,3 +81,4 @@ almost fully allocated to the chat model.
  6 - Only the user may prepare live services for manual integration.
  7 - Docs: `README.md` covers running and configuration only. Operator reference
  8   for the external services lives in `docs/services.md`.
  9+- DO NOT cd into the directory you're already in when running the bash tool
 10diff --git a/README.md b/README.md
 11index af02aa089876566b7fb79eb8399b2255e4de2bdf..948f9e2ae0bc92745799599bf8e09f41536d04c9 100644
 12--- a/README.md
 13+++ b/README.md
 14@@ -40,14 +40,14 @@ launches a service to make a check pass.
 15 Every setting is a flag or an environment variable; flags win over env vars,
 16 which win over defaults. Defaults match a local setup.
 17 
 18-| Setting        | Flag                | Env var              | Default                                | Use                                                     |
 19-| -------------- | ------------------- | -------------------- | -------------------------------------- | ------------------------------------------------------- |
 20-| LLM router URL | `--url`             | `JP_LLM_BASE_URL`    | `https://llama.home.theedgeofrage.com` | llama.cpp router; `POST /v1/chat/completions`           |
 21-| Temperature    | `--temperature`     | `JP_TEMPERATURE`     | `1.0`                                  | model sampling temperature                              |
 22-| Max tokens     | `--max-tokens`      | `JP_MAX_TOKENS`      | `256`                                  | max output tokens per reply                             |
 23-| Thinking mode  | `--enable-thinking` | `JP_ENABLE_THINKING` | `false`                                | model thinking (`chat_template_kwargs.enable_thinking`) |
 24-| Audio base URL | `--audio-url`       | `JP_AUDIO_BASE_URL`  | `http://127.0.0.1:8080`                | audio.cpp server (TTS + ASR)                            |
 25-| Scenario brief | `--scenario`        | `JP_SCENARIO_PATH`   | `assets/scenarios/small_city.md`       | plain-text scenario brief file                          |
 26+| Setting        | Flag                    | Env var                  | Default                                | Use                                                     |
 27+| -------------- | ----------------------- | ------------------------ | -------------------------------------- | ------------------------------------------------------- |
 28+| LLM router URL | `--llm.url`             | `JP_LLM_BASE_URL`        | `https://llama.home.theedgeofrage.com` | llama.cpp router; `POST /v1/chat/completions`           |
 29+| Temperature    | `--llm.temperature`     | `JP_LLM_TEMPERATURE`     | `1.0`                                  | model sampling temperature                              |
 30+| Max tokens     | `--llm.max-tokens`      | `JP_LLM_MAX_TOKENS`      | `256`                                  | max output tokens per reply                             |
 31+| Thinking mode  | `--llm.enable-thinking` | `JP_LLM_ENABLE_THINKING` | `false`                                | model thinking (`chat_template_kwargs.enable_thinking`) |
 32+| Audio base URL | `--audio.url`           | `JP_AUDIO_BASE_URL`      | `http://127.0.0.1:8080`                | audio.cpp server (TTS + ASR)                            |
 33+| Scenario brief | `--scenario`            | `JP_SCENARIO_PATH`       | `assets/scenarios/small_city.md`       | plain-text scenario brief file                          |
 34 
 35 Run `jp -help` for the full flag list.
 36 
 37diff --git a/go.mod b/go.mod
 38index 6f93f566dbfda9618b760f6d322e316a7d67816c..ddbb7eb0163378b3fdf980b12747b28778fa2c7e 100644
 39--- a/go.mod
 40+++ b/go.mod
 41@@ -11,6 +11,7 @@ require (
 42 
 43 require (
 44 	github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
 45+	github.com/charmbracelet/bubbles v1.0.0 // indirect
 46 	github.com/charmbracelet/colorprofile v0.4.3 // indirect
 47 	github.com/charmbracelet/x/ansi v0.11.8 // indirect
 48 	github.com/charmbracelet/x/cellbuf v0.0.15 // indirect
 49diff --git a/go.sum b/go.sum
 50index 5dd27f197d3f1f48651d086f4801abe4d82fc324..c2dccee5f8a38d04788a04044f117b61c301b275 100644
 51--- a/go.sum
 52+++ b/go.sum
 53@@ -1,5 +1,7 @@
 54 github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=
 55 github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8=
 56+github.com/charmbracelet/bubbles v1.0.0 h1:12J8/ak/uCZEMQ6KU7pcfwceyjLlWsDLAxB5fXonfvc=
 57+github.com/charmbracelet/bubbles v1.0.0/go.mod h1:9d/Zd5GdnauMI5ivUIVisuEm3ave1XwXtD1ckyV6r3E=
 58 github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw=
 59 github.com/charmbracelet/bubbletea v1.3.10/go.mod h1:ORQfo0fk8U+po9VaNvnV95UPWA1BitP1E0N6xJPlHr4=
 60 github.com/charmbracelet/colorprofile v0.4.3 h1:QPa1IWkYI+AOB+fE+mg/5/4HRMZcaXex9t5KX76i20Q=
 61diff --git a/internal/adapters/llm.go b/internal/adapters/llm.go
 62index fd4d9f396490afc8dc2a34d3671b5242d89f2460..2d8132af494f6d807ac36c0f338d5a7eb004adf5 100644
 63--- a/internal/adapters/llm.go
 64+++ b/internal/adapters/llm.go
 65@@ -91,16 +91,16 @@ func (m *JudgeModel) Judge(ctx context.Context, transcript string) (llm.JudgeRes
 66 }
 67 
 68 // ScratchModel adapts an llm.Client to game.ScratchModel. It asks how to say a
 69-// thing in Japanese and returns the raw answer text; no world context is sent.
 70+// thing in Japanese and parses the three-field answer; no world context is sent.
 71 type ScratchModel struct {
 72 	Client *llm.Client
 73 }
 74 
 75-func (m *ScratchModel) Ask(ctx context.Context, question string) (string, error) {
 76+func (m *ScratchModel) Ask(ctx context.Context, question string) (llm.ScratchAnswer, error) {
 77 	raw, err := m.Client.Generate(ctx, llm.BuildScratchMessages(question))
 78 	if err != nil {
 79-		return "", err
 80+		return llm.ScratchAnswer{}, err
 81 	}
 82 	dumpRaw("scratch", question, raw)
 83-	return strings.TrimSpace(raw), nil
 84+	return llm.ParseScratch(raw)
 85 }
 86diff --git a/internal/config/config.go b/internal/config/config.go
 87index f558f32ec596fbda3c078d35e8f5f5447102b733..fffa9fc641428616f776a4047684c17461091c59 100644
 88--- a/internal/config/config.go
 89+++ b/internal/config/config.go
 90@@ -24,8 +24,8 @@ type AudioConfig struct {
 91 
 92 // Config is the shared option group. Parsed with github.com/jessevdk/go-flags.
 93 type Config struct {
 94-	LLMConfig   LLMConfig   `env-namespace:"JP_LLM"`
 95-	AudioConfig AudioConfig `env-namespace:"JP_AUDIO"`
 96+	LLMConfig   LLMConfig   `group:"llm" namespace:"llm" env-namespace:"JP_LLM"`
 97+	AudioConfig AudioConfig `group:"audio" namespace:"audio" env-namespace:"JP_AUDIO"`
 98 
 99 	Scenario string `long:"scenario" env:"JP_SCENARIO_PATH" default:"assets/scenarios/small_city.md" description:"path to the scenario brief file"`
100 }
101diff --git a/internal/game/orchestrator.go b/internal/game/orchestrator.go
102index 28dc7f5c420449c3a25c3f2c3933148a01c84c0f..f4c0f96d8bce8dca6342755f51c3c7997696b2ab 100644
103--- a/internal/game/orchestrator.go
104+++ b/internal/game/orchestrator.go
105@@ -46,7 +46,7 @@ type SheetModel interface {
106 // ScratchModel answers one scratch question about how to say something in
107 // Japanese. It is independent of the game model and sees no world context.
108 type ScratchModel interface {
109-	Ask(ctx context.Context, question string) (string, error)
110+	Ask(ctx context.Context, question string) (llm.ScratchAnswer, error)
111 }
112 
113 // newPartnerNudge is the user message that pulls a new partner's first line
114@@ -145,7 +145,13 @@ func (o *Orchestrator) AskTurn(ctx context.Context, action string) TurnResult {
115 		res.Err = err
116 		return res
117 	}
118-	o.state.RecordDisplay(DisplayEntry{Action: action, Desc: answer})
119+	o.state.RecordDisplay(DisplayEntry{
120+		Action:         action,
121+		HasAsk:         true,
122+		AskRomaji:      answer.Romaji,
123+		AskTranslation: answer.Translation,
124+		AskBreakdown:   answer.Breakdown,
125+	})
126 	return res
127 }
128 
129diff --git a/internal/game/state.go b/internal/game/state.go
130index b3b116441fdd2e5e5a0da215e3ae1138b1c39338..49950b4776caaea666591b6d468b01bee42b3986 100644
131--- a/internal/game/state.go
132+++ b/internal/game/state.go
133@@ -37,17 +37,21 @@ type Segment struct {
134 // are empty and HasSpeech false for pure narration turns. ShowRomaji and
135 // ShowEnglish are independent: either can be revealed in either order.
136 type DisplayEntry struct {
137-	Action       string
138-	Desc         string
139-	Romaji       string
140-	English      string
141-	HasSpeech    bool
142-	PlayerRomaji string
143-	Score        int
144-	Feedback     string
145-	HasJudge     bool
146-	ShowRomaji   bool
147-	ShowEnglish  bool
148+	Action         string
149+	Desc           string
150+	Romaji         string
151+	English        string
152+	HasSpeech      bool
153+	PlayerRomaji   string
154+	Score          int
155+	Feedback       string
156+	HasJudge       bool
157+	ShowRomaji     bool
158+	ShowEnglish    bool
159+	HasAsk         bool
160+	AskRomaji      string
161+	AskTranslation string
162+	AskBreakdown   string
163 }
164 
165 // sheetEntry is one stored character sheet, kept in first-met order so injected
166diff --git a/internal/llm/contract.go b/internal/llm/contract.go
167index f6bf6cc4617d20944c1ad9f9ab343a663a917fb3..1abe5d36465a721dcea63503ae393b8ee1f0c36c 100644
168--- a/internal/llm/contract.go
169+++ b/internal/llm/contract.go
170@@ -47,9 +47,10 @@ func kindString(k ContractErrorKind) string {
171 }
172 
173 var (
174-	judgeFieldNames = []string{"SCORE", "ROMAJI", "FEEDBACK"}
175-	gameFieldNames  = []string{"LOCATION", "TALK", "DESC", "ROMAJI", "KANA", "ENGLISH"}
176-	sheetFieldNames = []string{"ROLE", "TRAITS", "MOOD", "STYLE", "UNCLEAR", "SAMPLE"}
177+	judgeFieldNames   = []string{"SCORE", "ROMAJI", "FEEDBACK"}
178+	gameFieldNames    = []string{"LOCATION", "TALK", "DESC", "ROMAJI", "KANA", "ENGLISH"}
179+	sheetFieldNames   = []string{"ROLE", "TRAITS", "MOOD", "STYLE", "UNCLEAR", "SAMPLE"}
180+	scratchFieldNames = []string{"ROMAJI", "TRANSLATION", "BREAKDOWN"}
181 )
182 
183 // parseFields splits a raw model reply into FIELD|value lines. Surrounding
184@@ -169,6 +170,25 @@ func ParseGameReply(raw string) (GameReply, error) {
185 	}, nil
186 }
187 
188+type ScratchAnswer struct {
189+	Romaji      string
190+	Translation string
191+	Breakdown   string
192+}
193+
194+// ParseScratch parses the three-field scratch contract. All three fields are
195+// required and non-empty; a malformed reply is a recoverable *ContractError.
196+func ParseScratch(raw string) (ScratchAnswer, error) {
197+	vals, err := parseFields(raw, scratchFieldNames)
198+	if err != nil {
199+		return ScratchAnswer{}, err
200+	}
201+	if err := requireFields(vals, scratchFieldNames); err != nil {
202+		return ScratchAnswer{}, err
203+	}
204+	return ScratchAnswer{Romaji: vals["ROMAJI"], Translation: vals["TRANSLATION"], Breakdown: vals["BREAKDOWN"]}, nil
205+}
206+
207 // Sheet is one person's compact character profile, parsed from the sheet
208 // contract. All six fields are required and non-empty.
209 type Sheet struct {
210diff --git a/internal/ui/app.go b/internal/ui/app.go
211index 0d62879099ecf0161cccbe1795ee783abb418f5f..91f2d8ddc0122eb2cb38b88d99eb7f5753e5aba8 100644
212--- a/internal/ui/app.go
213+++ b/internal/ui/app.go
214@@ -23,7 +23,6 @@ var (
215 	actionStyle     = lipgloss.NewStyle().Foreground(lipgloss.Color("38"))  // cyan
216 	narrationStyle  = lipgloss.NewStyle()                                   // default
217 	playerSaidStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("42"))  // green
218-	judgeStyle      = lipgloss.NewStyle().Foreground(lipgloss.Color("220")) // yellow
219 	hintStyle       = lipgloss.NewStyle().Foreground(lipgloss.Color("245")) // gray
220 )
221 
222@@ -54,6 +53,7 @@ type model struct {
223 	input  string
224 	status string
225 	height int
226+	width  int
227 }
228 
229 func NewModel(state *game.State, orch *game.Orchestrator, recordCapSec int) *model {
230@@ -87,6 +87,9 @@ func (m *model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
231 		if v.Height > 0 {
232 			m.height = v.Height
233 		}
234+		if v.Width > 0 {
235+			m.width = v.Width
236+		}
237 	}
238 	return m, cmd
239 }
240@@ -189,7 +192,7 @@ func (m *model) View() string {
241 		if i > 0 {
242 			logLines = append(logLines, "")
243 		}
244-		logLines = append(logLines, entryLines(e)...)
245+		logLines = append(logLines, entryLines(e, m.width)...)
246 	}
247 	vs := viewState{
248 		logLines:   logLines,
249@@ -213,10 +216,33 @@ func (m *model) phaseWord() string {
250 	return "idle"
251 }
252 
253+// keyedBlock renders a titled block of key/value rows: a gray section rule,
254+// then each row as a gray key padded to the widest key and its value in one
255+// aligned column. width is the terminal width for the header rule; zero means
256+// no rule tail.
257+func keyedBlock(width int, title string, pairs ...[2]string) []string {
258+	maxKey := 0
259+	for _, p := range pairs {
260+		if len(p[0]) > maxKey {
261+			maxKey = len(p[0])
262+		}
263+	}
264+	header := "  ── " + title
265+	if pad := width - len(header); pad > 0 {
266+		header += strings.Repeat("─", pad)
267+	}
268+	lines := make([]string, 0, len(pairs)+1)
269+	lines = append(lines, hintStyle.Render(header))
270+	for _, p := range pairs {
271+		lines = append(lines, "  "+hintStyle.Render(fmt.Sprintf("%-*s", maxKey, p[0]))+"  "+p[1])
272+	}
273+	return lines
274+}
275+
276 // entryLines renders one display-log turn into log lines. Speech fields are
277 // revealed independently (romaji, then english); the judge line follows the turn
278 // that produced it.
279-func entryLines(e game.DisplayEntry) []string {
280+func entryLines(e game.DisplayEntry, width int) []string {
281 	var lines []string
282 	if e.Action != "" {
283 		lines = append(lines, actionStyle.Render("> "+sanitizeRomajiOnly(e.Action)))
284@@ -240,7 +266,15 @@ func entryLines(e game.DisplayEntry) []string {
285 		if e.PlayerRomaji != "" {
286 			lines = append(lines, playerSaidStyle.Render("  said: "+sanitizeRomajiOnly(e.PlayerRomaji)))
287 		}
288-		lines = append(lines, judgeStyle.Render(fmt.Sprintf("  judge %d/100 — %s", e.Score, sanitizeRomajiOnly(valueOrNone(e.Feedback)))))
289+		lines = append(lines, keyedBlock(width, "judge",
290+			[2]string{"Score", fmt.Sprintf("%d/100", e.Score)},
291+			[2]string{"Feedback", sanitizeRomajiOnly(valueOrNone(e.Feedback))})...)
292+	}
293+	if e.HasAsk {
294+		lines = append(lines, keyedBlock(width, "ask",
295+			[2]string{"Romaji", sanitizeRomajiOnly(e.AskRomaji)},
296+			[2]string{"Translation", sanitizeRomajiOnly(e.AskTranslation)},
297+			[2]string{"Breakdown", sanitizeRomajiOnly(e.AskBreakdown)})...)
298 	}
299 	return lines
300 }