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