786087270bcd761466dd481a05b3ed71c912118c

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

Message

Add F6 to flag the last NPC line as unknown into a flashcard deck

Pressing F6 sends the most recent character line (kana, romaji, and
English) to the LLM, which returns raw CSV of words worth learning. Go
validates each row has four non-empty fields, discards malformed ones,
and appends the rest to a persistent flashcards.csv next to jp_raw.log.
The TUI shows how many cards were added or an error line.

Diff

This diff is truncated to protect this page.

  1diff --git a/cmd/jp/main.go b/cmd/jp/main.go
  2index c04981dbf9387a68551c63f0a2fa28b6ed3a8fc6..a626d9a5f3a8768761ca78be65eb528b3c738b62 100644
  3--- a/cmd/jp/main.go
  4+++ b/cmd/jp/main.go
  5@@ -36,6 +36,7 @@ const (
  6 	compactionSlot = 2
  7 	sheetSlot      = 3
  8 	scratchSlot    = 4
  9+	flashcardsSlot = 5
 10 )
 11 const recordCommand = "arecord"
 12 
 13@@ -53,6 +54,7 @@ func main() {
 14 	compactionClient := newLLMClient(cfg, hc, compactionSlot)
 15 	sheetClient := newLLMClient(cfg, hc, sheetSlot)
 16 	scratchClient := newLLMClient(cfg, hc, scratchSlot)
 17+	flashcardsClient := newLLMClient(cfg, hc, flashcardsSlot)
 18 
 19 	if err := availability.CheckAudio(hc, cfg.AudioConfig.AudioBaseURL, []string{tts.ModelName, stt.ASRModelName}); err != nil {
 20 		fmt.Fprintf(os.Stderr, "jp: audio service unavailable: %v\n", err)
 21@@ -73,6 +75,7 @@ func main() {
 22 		{compactionClient, llm.CompactionPrompt()},
 23 		{sheetClient, llm.SheetSystemPrompt()},
 24 		{scratchClient, llm.ScratchSystemPrompt()},
 25+		{flashcardsClient, llm.FlashcardSystemPrompt()},
 26 	}
 27 	ctx, cancel := context.WithTimeout(context.Background(), warmupTimeout)
 28 	for _, w := range warmups {
 29@@ -86,7 +89,7 @@ func main() {
 30 	}
 31 
 32 	state := game.NewState(sc.Brief)
 33-	orch := buildOrchestrator(cfg, state, hc, gameClient, judgeClient, compactionClient, sheetClient, scratchClient)
 34+	orch := buildOrchestrator(cfg, state, hc, gameClient, judgeClient, compactionClient, sheetClient, scratchClient, flashcardsClient)
 35 
 36 	m := ui.NewModel(state, orch, int(recordCap.Seconds()))
 37 	if _, err := tea.NewProgram(m).Run(); err != nil {
 38@@ -94,12 +97,13 @@ func main() {
 39 	}
 40 }
 41 
 42-func buildOrchestrator(cfg *config.Config, state *game.State, hc *http.Client, gameClient, judgeClient, compactionClient, sheetClient, scratchClient *llm.Client) *game.Orchestrator {
 43+func buildOrchestrator(cfg *config.Config, state *game.State, hc *http.Client, gameClient, judgeClient, compactionClient, sheetClient, scratchClient, flashcardsClient *llm.Client) *game.Orchestrator {
 44 	gameModel := &adapters.GameModel{Client: gameClient}
 45 	compactor := &adapters.Compactor{Client: compactionClient}
 46 	judgeModel := &adapters.JudgeModel{Client: judgeClient}
 47 	sheetModel := &adapters.SheetModel{Client: sheetClient}
 48 	askModel := &adapters.ScratchModel{Client: scratchClient}
 49+	flashModel := &adapters.FlashcardModel{Client: flashcardsClient}
 50 
 51 	speechIn := &adapters.SpeechInput{
 52 		Recorder: stt.NewRecorder(recordCommand, recordCap),
 53@@ -110,7 +114,7 @@ func buildOrchestrator(cfg *config.Config, state *game.State, hc *http.Client, g
 54 		Player: tts.NewPlayer(),
 55 	}
 56 
 57-	return game.NewOrchestrator(state, gameModel, judgeModel, compactor, sheetModel, askModel, speechIn, speechOut)
 58+	return game.NewOrchestrator(state, gameModel, judgeModel, compactor, sheetModel, askModel, flashModel, speechIn, speechOut)
 59 }
 60 
 61 func newLLMClient(cfg *config.Config, hc *http.Client, slot int) *llm.Client {
 62diff --git a/internal/adapters/flashcards.go b/internal/adapters/flashcards.go
 63new file mode 100644
 64index 0000000000000000000000000000000000000000..51a7bd55c374a8954ec2105f24e1c252754a5359
 65--- /dev/null
 66+++ b/internal/adapters/flashcards.go
 67@@ -0,0 +1,75 @@
 68+package adapters
 69+
 70+import (
 71+	"context"
 72+	"encoding/csv"
 73+	"fmt"
 74+	"os"
 75+
 76+	"japanese/internal/llm"
 77+)
 78+
 79+// flashcardsPath is where generated vocabulary cards are persisted, next to the
 80+// raw model log. It is a plain append-only CSV for an external flashcard app.
 81+const flashcardsPath = "flashcards.csv"
 82+
 83+const flashcardHeader = "kana,romaji,english,explanation"
 84+
 85+// FlashcardModel adapts an llm.Client to game.FlashcardModel. It asks the model
 86+// to turn one NPC line into raw CSV, validates the rows in Go, and appends them
 87+// to the persistent local deck.
 88+type FlashcardModel struct {
 89+	Client *llm.Client
 90+}
 91+
 92+func (m *FlashcardModel) Flag(ctx context.Context, kana, romaji, english string) (int, error) {
 93+	raw, err := m.Client.Generate(ctx, llm.BuildFlashcardMessages(kana, romaji, english))
 94+	if err != nil {
 95+		return 0, err
 96+	}
 97+	dumpRaw("flashcards", "", raw)
 98+	rows, err := llm.ParseFlashcards(raw)
 99+	if err != nil {
100+		return 0, err
101+	}
102+	if len(rows) == 0 {
103+		return 0, nil
104+	}
105+	if err := appendFlashcards(flashcardsPath, rows); err != nil {
106+		return 0, err
107+	}
108+	return len(rows), nil
109+}
110+
111+// appendFlashcards appends valid rows to the deck file, creating it with the
112+// header when it does not exist yet. Existing rows are never rewritten.
113+func appendFlashcards(path string, rows []llm.FlashcardRow) error {
114+	f, err := os.OpenFile(path, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o644)
115+	if err != nil {
116+		return fmt.Errorf("open flashcards: %w", err)
117+	}
118+
119+	err = func() error {
120+		fi, err := f.Stat()
121+		if err != nil {
122+			return fmt.Errorf("stat flashcards: %w", err)
123+		}
124+		if fi.Size() == 0 {
125+			if _, err := f.WriteString(flashcardHeader + "\n"); err != nil {
126+				return fmt.Errorf("write flashcards header: %w", err)
127+			}
128+		}
129+		w := csv.NewWriter(f)
130+		for _, r := range rows {
131+			if err := w.Write([]string{r.Kana, r.Romaji, r.English, r.Explanation}); err != nil {
132+				return fmt.Errorf("write flashcard row: %w", err)
133+			}
134+		}
135+		w.Flush()
136+		return w.Error()
137+	}()
138+	if cerr := f.Close(); err == nil {
139+		err = cerr
140+	}
141+	return err
142+}
143diff --git a/internal/game/orchestrator.go b/internal/game/orchestrator.go
144index a575faffa64891ea1f1eb4a92f9fddfbe587f89b..8153b6bf42246ab4a8d9bba723d743f1702d7185 100644
145--- a/internal/game/orchestrator.go
146+++ b/internal/game/orchestrator.go
147@@ -51,6 +51,12 @@ type ScratchModel interface {
148 	Ask(ctx context.Context, question string) (llm.ScratchAnswer, error)
149 }
150 
151+// FlashcardModel turns one NPC line into vocabulary flashcards and appends them
152+// to a persistent local deck. It returns how many cards were added.
153+type FlashcardModel interface {
154+	Flag(ctx context.Context, kana, romaji, english string) (int, error)
155+}
156+
157 // newPartnerNudge is the user message that pulls a new partner's first line
158 // after their character sheet is registered.
159 const newPartnerNudge = "(The scene continues. The person the player is now talking to speaks first.)"
160@@ -61,6 +67,9 @@ var ErrEmptyTranscript = errors.New("game: empty transcript")
161 // ErrNoSpeech is returned by ReplayLast before any NPC has spoken yet.
162 var ErrNoSpeech = errors.New("game: nothing to replay yet")
163 
164+// ErrNoNPCLine is returned by FlagUnknown before any NPC line exists to flag.
165+var ErrNoNPCLine = errors.New("game: no character line to flag yet")
166+
167 // TurnResult reports what happened in one turn. Reply is always set unless Err is
168 // non-nil (no reply was produced, so the session did not advance). Judge and
169 // JudgeErr apply to spoken turns only. SpeakErr is a recoverable TTS failure.
170@@ -84,6 +93,7 @@ type Orchestrator struct {
171 	compactor Compactor
172 	sheet     SheetModel
173 	ask       ScratchModel
174+	flash     FlashcardModel
175 	speechIn  SpeechInput
176 	speechOut SpeechOutput
177 
178@@ -92,8 +102,8 @@ type Orchestrator struct {
179 	budget      int
180 }
181 
182-// NewOrchestrator wires an orchestrator over a state and the seven adapters.
183-func NewOrchestrator(state *State, g GameModel, j JudgeModel, c Compactor, sh SheetModel, ask ScratchModel, in SpeechInput, out SpeechOutput) *Orchestrator {
184+// NewOrchestrator wires an orchestrator over a state and the eight adapters.
185+func NewOrchestrator(state *State, g GameModel, j JudgeModel, c Compactor, sh SheetModel, ask ScratchModel, flash FlashcardModel, in SpeechInput, out SpeechOutput) *Orchestrator {
186 	return &Orchestrator{
187 		state:       state,
188 		game:        g,
189@@ -101,6 +111,7 @@ func NewOrchestrator(state *State, g GameModel, j JudgeModel, c Compactor, sh Sh
190 		compactor:   c,
191 		sheet:       sh,
192 		ask:         ask,
193+		flash:       flash,
194 		speechIn:    in,
195 		speechOut:   out,
196 		staticChars: utf8.RuneCountInString(llm.GameSystemPrompt(state.Brief())),
197@@ -122,6 +133,17 @@ func (o *Orchestrator) ReplayLast() error {
198 	return o.speechOut.Replay()
199 }
200 
201+// FlagUnknown turns the most recent NPC line into flashcards and appends them to
202+// the persistent deck. It returns how many cards were added, or ErrNoNPCLine if
203+// no NPC has spoken yet. It does not touch game state.
204+func (o *Orchestrator) FlagUnknown(ctx context.Context) (int, error) {
205+	e, ok := o.state.LatestSpeech()
206+	if !ok {
207+		return 0, ErrNoNPCLine
208+	}
209+	return o.flash.Flag(ctx, e.Kana, e.Romaji, e.English)
210+}
211+
212 // ActionTurn runs one typed action: record it, advance the game loop, handle any
213 // location change, play speech if the reply includes a line, and log the turn.
214 func (o *Orchestrator) ActionTurn(ctx context.Context, action string) TurnResult {
215@@ -335,7 +357,7 @@ func joinDescs(a, b string) string {
216 // displayFromTurn builds one display entry: the shown reply's speech triple,
217 // the turn's merged narration desc, and judge feedback when present.
218 func displayFromTurn(action string, shown llm.GameReply, desc string, j llm.JudgeResult, hasJudge bool) DisplayEntry {
219-	e := DisplayEntry{Action: action, Desc: desc, Romaji: shown.Romaji, English: shown.English, HasSpeech: hasSpeech(shown)}
220+	e := DisplayEntry{Action: action, Desc: desc, Kana: shown.Kana, Romaji: shown.Romaji, English: shown.English, HasSpeech: hasSpeech(shown)}
221 	if hasJudge {
222 		e.HasJudge = true
223 		e.PlayerRomaji = j.Romaji
224diff --git a/internal/game/state.go b/internal/game/state.go
225index 49950b4776caaea666591b6d468b01bee42b3986..4a8ec9135084fcedca7012b5483dccd13d059532 100644
226--- a/internal/game/state.go
227+++ b/internal/game/state.go
228@@ -39,6 +39,7 @@ type Segment struct {
229 type DisplayEntry struct {
230 	Action         string
231 	Desc           string
232+	Kana           string
233 	Romaji         string
234 	English        string
235 	HasSpeech      bool
236@@ -245,6 +246,16 @@ func (s *State) Display() []DisplayEntry {
237 	return out
238 }
239 
240+// LatestSpeech returns the most recent display entry that has NPC speech, plus
241+// whether one exists.
242+func (s *State) LatestSpeech() (DisplayEntry, bool) {
243+	i := latestSpeechIndex(s.display)
244+	if i < 0 {
245+		return DisplayEntry{}, false
246+	}
247+	return s.display[i], true
248+}
249+
250 // RevealRomaji marks romaji shown on the most recent speech entry. It returns
251 // whether anything changed.
252 func (s *State) RevealRomaji() bool {
253diff --git a/internal/llm/flashcard.go b/internal/llm/flashcard.go
254new file mode 100644
255index 0000000000000000000000000000000000000000..a0131bd6becdc727109ec172e871687049892b4e
256--- /dev/null
257+++ b/internal/llm/flashcard.go
258@@ -0,0 +1,108 @@
259+package llm
260+
261+import (
262+	"encoding/csv"
263+	"fmt"
264+	"strings"
265+)
266+
267+// FlashcardRow is one vocabulary card parsed from the flashcard CSV output. The
268+// kana field carries real kana on purpose: it is written to a local CSV file for
269+// an external flashcard app and never reaches the TUI.
270+type FlashcardRow struct {
271+	Kana        string
272+	Romaji      string
273+	English     string
274+	Explanation string
275+}
276+
277+// flashcardSystemTemplate turns one Japanese sentence into vocabulary flashcards.
278+// It is persona-free and carries no game, player, NPC, quest, or scenario context.
279+// Unlike the other prompts it asks for raw CSV (no FIELD|value contract), which
280+// Go then parses and validates before persisting.
281+const flashcardSystemTemplate = `You turn one Japanese sentence into vocabulary flashcards for a language learner's personal deck. You have no other context: this is not a game, and no scene, place, or person exists.
282+
283+You are given the sentence in kana, its romaji, and an English translation. Pick out the distinct words and phrases worth learning as flashcards - content words and useful phrases first.
284+
285+Output raw CSV only. No markdown, no code fences, no commentary, nothing before or after. The first line must be exactly this header:
286+kana,romaji,english,explanation
287+
288+Then one row per distinct word or phrase, each with exactly four comma-separated fields:
289+- kana: the word or phrase written in kana
290+- romaji: that same word or phrase in Hepburn romaji
291+- english: its English meaning, kept short
292+- explanation: a more detailed breakdown - what each part means, how it is used, and any grammar note
293+
294+Rules:
295+- One row per distinct item; never repeat the same word.
296+- Keep every row on a single line.
297+- If a field contains a comma, wrap that whole field in double quotes.`
298+
299+// FlashcardSystemPrompt returns the static flashcard system message.
300+func FlashcardSystemPrompt() string { return flashcardSystemTemplate }
301+
302+// BuildFlashcardMessages assembles the flashcard request from one NPC line: a
303+// static system message plus one user message carrying the kana, romaji, and
304+// English translation of the line.
305+func BuildFlashcardMessages(kana, romaji, english string) []Message {
306+	var b strings.Builder
307+	fmt.Fprintf(&b, "Sentence (kana): %s\n", kana)
308+	fmt.Fprintf(&b, "Sentence (romaji): %s\n", romaji)
309+	fmt.Fprintf(&b, "Translation: %s", english)
310+	return []Message{
311+		{Role: RoleSystem, Content: flashcardSystemTemplate},
312+		{Role: RoleUser, Content: b.String()},
313+	}
314+}
315+
316+// ParseFlashcards parses raw flashcard CSV into valid rows. A record is valid
317+// only when it has exactly four non-empty fields; malformed records are
318+// discarded. The header row, when present, is skipped. An error is returned only
319+// when the text cannot be read as CSV at all.
320+func ParseFlashcards(raw string) ([]FlashcardRow, error) {
321+	r := csv.NewReader(strings.NewReader(raw))
322+	r.FieldsPerRecord = -1 // validate field count per record below
323+	records, err := r.ReadAll()
324+	if err != nil {
325+		return nil, fmt.Errorf("llm: parse flashcards csv: %w", err)
326+	}
327+	var rows []FlashcardRow
328+	for i, rec := range records {
329+		if i == 0 && isFlashcardHeader(rec) {
330+			continue
331+		}
332+		if row, ok := parseFlashcardRecord(rec); ok {
333+			rows = append(rows, row)
334+		}
335+	}
336+	return rows, nil
337+}
338+
339+func isFlashcardHeader(rec []string) bool {
340+	want := [4]string{"kana", "romaji", "english", "explanation"}
341+	if len(rec) != 4 {
342+		return false
343+	}
344+	for i, w := range want {
345+		if strings.ToLower(strings.TrimSpace(rec[i])) != w {
346+			return false
347+		}
348+	}
349+	return true
350+}
351+
352+func parseFlashcardRecord(rec []string) (FlashcardRow, bool) {
353+	if len(rec) != 4 {
354+		return FlashcardRow{}, false
355+	}
356+	row := FlashcardRow{
357+		Kana:        strings.TrimSpace(rec[0]),
358diff --git a/internal/ui/app.go b/internal/ui/app.go
359index 5d31f28a64555b0b49695216663c1283f91f49a5..43ef133611c3494a4541a63b3b9127de2cf5a303 100644
360--- a/internal/ui/app.go
361+++ b/internal/ui/app.go
362@@ -43,6 +43,10 @@ const (
363 type beginResultMsg struct{ err error }
364 type finishResultMsg struct{ res game.TurnResult }
365 type replayResultMsg struct{ err error }
366+type flagResultMsg struct {
367+	count int
368+	err   error
369+}
370 type tickMsg struct{}
371 
372 // model is the Bubble Tea state. It owns only UI-local fields (input buffer,
373@@ -94,6 +98,8 @@ func (m *model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
374 		m.applyFinish(v.res)
375 	case replayResultMsg:
376 		m.applyReplay(v.err)
377+	case flagResultMsg:
378+		m.applyFlag(v.count, v.err)
379 	case tickMsg:
380 		if m.phase == phaseRecording {
381 			cmd = nextTick()
382@@ -129,6 +135,11 @@ func (m *model) handleKey(k tea.KeyMsg) tea.Cmd {
383 			m.phase = phaseProcessing
384 			return replayTurn(m.orch)
385 		}
386+	case tea.KeyF6:
387+		if m.phase == phaseIdle {
388+			m.phase = phaseProcessing
389+			return flagTurn(m.orch)
390+		}
391 	case tea.KeyEnter:
392 		return m.submit()
393 	default:
394@@ -212,6 +223,24 @@ func (m *model) applyReplay(err error) {
395 	}
396 }
397 
398+// applyFlag maps a completed flag-unknown onto UI-local state: the number of
399+// cards added, or a transient note when there was no line to flag or it failed.
400+func (m *model) applyFlag(count int, err error) {
401+	m.phase = phaseIdle
402+	switch {
403+	case errors.Is(err, game.ErrNoNPCLine):
404+		m.status = "no character line to flag yet"
405+	case err != nil:
406+		m.status = "flashcards failed: " + err.Error()
407+	case count == 0:
408+		m.status = "no cards added"
409+	case count == 1:
410+		m.status = "1 card added"
411+	default:
412+		m.status = fmt.Sprintf("%d cards added", count)
413+	}
414+}
415+
416 func turnStatus(res game.TurnResult) string {
417 	switch {
418 	case res.Err != nil:
419@@ -370,6 +399,13 @@ func replayTurn(o *game.Orchestrator) tea.Cmd {
420 	}
421 }
422 
423+func flagTurn(o *game.Orchestrator) tea.Cmd {
424+	return func() tea.Msg {
425+		count, err := o.FlagUnknown(context.Background())
426+		return flagResultMsg{count: count, err: err}
427+	}
428+}
429+
430 func nextTick() tea.Cmd {
431 	return tea.Tick(tickInterval, func(time.Time) tea.Msg { return tickMsg{} })
432 }
433diff --git a/internal/ui/render.go b/internal/ui/render.go
434index 0907c5f00f441eaf7e27980d3c59066e33a06047..9cc6aae16620cd4868645afa02d40d4dbf052907 100644
435--- a/internal/ui/render.go
436+++ b/internal/ui/render.go
437@@ -9,7 +9,7 @@ import (
438 )
439 
440 // controlsHint is the idle status line. It lists only keys wired in this build.
441-const controlsHint = "F2 talk · F3 romaji · F4 english · F5 replay · ? ask · Esc quit"
442+const controlsHint = "F2 talk · F3 romaji · F4 english · F5 replay · F6 flag · ? ask · Esc quit"
443 
444 // viewState is the plain, render-only snapshot the pure render functions take.
445 // It holds no pointers and no game types, so rendering stays trivially pure.