1ae5b4677a2e847a654cd4ce5d6a0e4f125a0c39

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

Message

Replace F6 flag flow with "! instructions" flashcards

Flashcards are now driven by a typed "! <instructions>" action, mirroring the
"? ask" prefix. The persona-free prompt takes the instructions, generates one
Japanese sentence that matches them, and emits raw CSV (kana,romaji,english,
explanation) of the distinct words in that sentence. Go validates each row has
four non-empty fields and appends the valid ones to flashcards.csv; the TUI
shows how many cards were added or an error line.

Removes the old F6 flow end to end: the keybinding, its hint, the FlagUnknown
orchestrator entry point, ErrNoNPCLine, State.LatestSpeech, and the DisplayEntry
Kana field that only that path read. The reusable CSV parsing/validation and
append logic stay; the prompt and message builder now take instructions instead
of an NPC line.

Diff

This diff is truncated to protect this page.

  1diff --git a/internal/adapters/flashcards.go b/internal/adapters/flashcards.go
  2index 51a7bd55c374a8954ec2105f24e1c252754a5359..e82a85d22a66835e2350881f3acbfac0e66ee46a 100644
  3--- a/internal/adapters/flashcards.go
  4+++ b/internal/adapters/flashcards.go
  5@@ -16,14 +16,14 @@ const flashcardsPath = "flashcards.csv"
  6 const flashcardHeader = "kana,romaji,english,explanation"
  7 
  8 // FlashcardModel adapts an llm.Client to game.FlashcardModel. It asks the model
  9-// to turn one NPC line into raw CSV, validates the rows in Go, and appends them
 10-// to the persistent local deck.
 11+// to generate a sentence from instructions and turn it into raw CSV, validates
 12+// the rows in Go, and appends them to the persistent local deck.
 13 type FlashcardModel struct {
 14 	Client *llm.Client
 15 }
 16 
 17-func (m *FlashcardModel) Flag(ctx context.Context, kana, romaji, english string) (int, error) {
 18-	raw, err := m.Client.Generate(ctx, llm.BuildFlashcardMessages(kana, romaji, english))
 19+func (m *FlashcardModel) Generate(ctx context.Context, instructions string) (int, error) {
 20+	raw, err := m.Client.Generate(ctx, llm.BuildFlashcardMessages(instructions))
 21 	if err != nil {
 22 		return 0, err
 23 	}
 24diff --git a/internal/game/orchestrator.go b/internal/game/orchestrator.go
 25index 8153b6bf42246ab4a8d9bba723d743f1702d7185..405752c36f799a6063546806629eb01be42c0b43 100644
 26--- a/internal/game/orchestrator.go
 27+++ b/internal/game/orchestrator.go
 28@@ -51,10 +51,11 @@ type ScratchModel interface {
 29 	Ask(ctx context.Context, question string) (llm.ScratchAnswer, error)
 30 }
 31 
 32-// FlashcardModel turns one NPC line into vocabulary flashcards and appends them
 33-// to a persistent local deck. It returns how many cards were added.
 34+// FlashcardModel generates a Japanese sentence from instructions, turns it into
 35+// vocabulary flashcards, and appends them to a persistent local deck. It returns
 36+// how many cards were added.
 37 type FlashcardModel interface {
 38-	Flag(ctx context.Context, kana, romaji, english string) (int, error)
 39+	Generate(ctx context.Context, instructions string) (int, error)
 40 }
 41 
 42 // newPartnerNudge is the user message that pulls a new partner's first line
 43@@ -67,9 +68,6 @@ var ErrEmptyTranscript = errors.New("game: empty transcript")
 44 // ErrNoSpeech is returned by ReplayLast before any NPC has spoken yet.
 45 var ErrNoSpeech = errors.New("game: nothing to replay yet")
 46 
 47-// ErrNoNPCLine is returned by FlagUnknown before any NPC line exists to flag.
 48-var ErrNoNPCLine = errors.New("game: no character line to flag yet")
 49-
 50 // TurnResult reports what happened in one turn. Reply is always set unless Err is
 51 // non-nil (no reply was produced, so the session did not advance). Judge and
 52 // JudgeErr apply to spoken turns only. SpeakErr is a recoverable TTS failure.
 53@@ -133,15 +131,13 @@ func (o *Orchestrator) ReplayLast() error {
 54 	return o.speechOut.Replay()
 55 }
 56 
 57-// FlagUnknown turns the most recent NPC line into flashcards and appends them to
 58-// the persistent deck. It returns how many cards were added, or ErrNoNPCLine if
 59-// no NPC has spoken yet. It does not touch game state.
 60-func (o *Orchestrator) FlagUnknown(ctx context.Context) (int, error) {
 61-	e, ok := o.state.LatestSpeech()
 62-	if !ok {
 63-		return 0, ErrNoNPCLine
 64-	}
 65-	return o.flash.Flag(ctx, e.Kana, e.Romaji, e.English)
 66+// GenerateFlashcards runs one "! instructions" action: ask the flashcard model
 67+// to generate a sentence from the instructions and turn it into vocabulary cards,
 68+// then append them to the persistent deck. It returns how many cards were added.
 69+// It does not touch game state.
 70+func (o *Orchestrator) GenerateFlashcards(ctx context.Context, action string) (int, error) {
 71+	instructions := strings.TrimSpace(strings.TrimPrefix(action, "!"))
 72+	return o.flash.Generate(ctx, instructions)
 73 }
 74 
 75 // ActionTurn runs one typed action: record it, advance the game loop, handle any
 76@@ -357,7 +353,7 @@ func joinDescs(a, b string) string {
 77 // displayFromTurn builds one display entry: the shown reply's speech triple,
 78 // the turn's merged narration desc, and judge feedback when present.
 79 func displayFromTurn(action string, shown llm.GameReply, desc string, j llm.JudgeResult, hasJudge bool) DisplayEntry {
 80-	e := DisplayEntry{Action: action, Desc: desc, Kana: shown.Kana, Romaji: shown.Romaji, English: shown.English, HasSpeech: hasSpeech(shown)}
 81+	e := DisplayEntry{Action: action, Desc: desc, Romaji: shown.Romaji, English: shown.English, HasSpeech: hasSpeech(shown)}
 82 	if hasJudge {
 83 		e.HasJudge = true
 84 		e.PlayerRomaji = j.Romaji
 85diff --git a/internal/game/state.go b/internal/game/state.go
 86index 4a8ec9135084fcedca7012b5483dccd13d059532..49950b4776caaea666591b6d468b01bee42b3986 100644
 87--- a/internal/game/state.go
 88+++ b/internal/game/state.go
 89@@ -39,7 +39,6 @@ type Segment struct {
 90 type DisplayEntry struct {
 91 	Action         string
 92 	Desc           string
 93-	Kana           string
 94 	Romaji         string
 95 	English        string
 96 	HasSpeech      bool
 97@@ -246,16 +245,6 @@ func (s *State) Display() []DisplayEntry {
 98 	return out
 99 }
100 
101-// LatestSpeech returns the most recent display entry that has NPC speech, plus
102-// whether one exists.
103-func (s *State) LatestSpeech() (DisplayEntry, bool) {
104-	i := latestSpeechIndex(s.display)
105-	if i < 0 {
106-		return DisplayEntry{}, false
107-	}
108-	return s.display[i], true
109-}
110-
111 // RevealRomaji marks romaji shown on the most recent speech entry. It returns
112 // whether anything changed.
113 func (s *State) RevealRomaji() bool {
114diff --git a/internal/llm/flashcard.go b/internal/llm/flashcard.go
115index a0131bd6becdc727109ec172e871687049892b4e..abaf23e74edd604896aa2f65d6198d932117f3df 100644
116--- a/internal/llm/flashcard.go
117+++ b/internal/llm/flashcard.go
118@@ -16,18 +16,18 @@ type FlashcardRow struct {
119 	Explanation string
120 }
121 
122-// flashcardSystemTemplate turns one Japanese sentence into vocabulary flashcards.
123+// flashcardSystemTemplate turns a set of instructions into vocabulary flashcards.
124 // It is persona-free and carries no game, player, NPC, quest, or scenario context.
125 // Unlike the other prompts it asks for raw CSV (no FIELD|value contract), which
126 // Go then parses and validates before persisting.
127-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.
128+const flashcardSystemTemplate = `You turn a set of instructions 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.
129 
130-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.
131+First read the instructions and generate one Japanese sentence that matches them. Then pick out the distinct words and phrases in that sentence worth learning as flashcards - content words and useful phrases first.
132 
133 Output raw CSV only. No markdown, no code fences, no commentary, nothing before or after. The first line must be exactly this header:
134 kana,romaji,english,explanation
135 
136-Then one row per distinct word or phrase, each with exactly four comma-separated fields:
137+Then one row per distinct word or phrase from your sentence, each with exactly four comma-separated fields:
138 - kana: the word or phrase written in kana
139 - romaji: that same word or phrase in Hepburn romaji
140 - english: its English meaning, kept short
141@@ -41,17 +41,13 @@ Rules:
142 // FlashcardSystemPrompt returns the static flashcard system message.
143 func FlashcardSystemPrompt() string { return flashcardSystemTemplate }
144 
145-// BuildFlashcardMessages assembles the flashcard request from one NPC line: a
146-// static system message plus one user message carrying the kana, romaji, and
147-// English translation of the line.
148-func BuildFlashcardMessages(kana, romaji, english string) []Message {
149-	var b strings.Builder
150-	fmt.Fprintf(&b, "Sentence (kana): %s\n", kana)
151-	fmt.Fprintf(&b, "Sentence (romaji): %s\n", romaji)
152-	fmt.Fprintf(&b, "Translation: %s", english)
153+// BuildFlashcardMessages assembles the flashcard request from one set of
154+// instructions: a static system message plus one user message carrying them. It
155+// carries no game, history, or sheet context.
156+func BuildFlashcardMessages(instructions string) []Message {
157 	return []Message{
158 		{Role: RoleSystem, Content: flashcardSystemTemplate},
159-		{Role: RoleUser, Content: b.String()},
160+		{Role: RoleUser, Content: strings.TrimSpace(instructions)},
161 	}
162 }
163 
164diff --git a/internal/ui/app.go b/internal/ui/app.go
165index 43ef133611c3494a4541a63b3b9127de2cf5a303..c4e54340ecd56b8283db27df8728de59f1e8758e 100644
166--- a/internal/ui/app.go
167+++ b/internal/ui/app.go
168@@ -43,7 +43,7 @@ const (
169 type beginResultMsg struct{ err error }
170 type finishResultMsg struct{ res game.TurnResult }
171 type replayResultMsg struct{ err error }
172-type flagResultMsg struct {
173+type flashcardResultMsg struct {
174 	count int
175 	err   error
176 }
177@@ -98,8 +98,8 @@ func (m *model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
178 		m.applyFinish(v.res)
179 	case replayResultMsg:
180 		m.applyReplay(v.err)
181-	case flagResultMsg:
182-		m.applyFlag(v.count, v.err)
183+	case flashcardResultMsg:
184+		m.applyFlashcards(v.count, v.err)
185 	case tickMsg:
186 		if m.phase == phaseRecording {
187 			cmd = nextTick()
188@@ -135,11 +135,6 @@ func (m *model) handleKey(k tea.KeyMsg) tea.Cmd {
189 			m.phase = phaseProcessing
190 			return replayTurn(m.orch)
191 		}
192-	case tea.KeyF6:
193-		if m.phase == phaseIdle {
194-			m.phase = phaseProcessing
195-			return flagTurn(m.orch)
196-		}
197 	case tea.KeyEnter:
198 		return m.submit()
199 	default:
200@@ -152,7 +147,8 @@ func (m *model) handleKey(k tea.KeyMsg) tea.Cmd {
201 	return nil
202 }
203 
204-// submit sends the input buffer as an action or scratch question.
205+// submit sends the input buffer as an action, a "?" scratch question, or "!"
206+// flashcard instructions.
207 func (m *model) submit() tea.Cmd {
208 	if m.phase != phaseIdle {
209 		return nil
210@@ -161,18 +157,31 @@ func (m *model) submit() tea.Cmd {
211 	if action == "" {
212 		return nil
213 	}
214-	if strings.HasPrefix(action, "?") {
215-		question := strings.TrimSpace(strings.TrimPrefix(action, "?"))
216-		if question == "" {
217-			return nil // bare "?": keep the buffer
218-		}
219+	if p := specialPrefix(action); p != "" && strings.TrimSpace(strings.TrimPrefix(action, p)) == "" {
220+		return nil // bare "?" or "!": keep the buffer
221 	}
222 	m.input.SetValue("")
223 	m.phase = phaseProcessing
224-	if strings.HasPrefix(action, "?") {
225+	switch {
226+	case strings.HasPrefix(action, "?"):
227 		return askTurn(m.orch, action)
228+	case strings.HasPrefix(action, "!"):
229+		return flashcardTurn(m.orch, action)
230+	default:
231+		return actionTurn(m.orch, action)
232 	}
233-	return actionTurn(m.orch, action)
234+}
235+
236+// specialPrefix returns the leading "?" or "!" when the action is a scratch
237+// question or flashcard instructions; empty for a plain action.
238+func specialPrefix(action string) string {
239+	switch {
240+	case strings.HasPrefix(action, "?"):
241+		return "?"
242+	case strings.HasPrefix(action, "!"):
243+		return "!"
244+	}
245+	return ""
246 }
247 
248 // talkToggle starts or stops push-to-talk.
249@@ -223,13 +232,11 @@ func (m *model) applyReplay(err error) {
250 	}
251 }
252 
253-// applyFlag maps a completed flag-unknown onto UI-local state: the number of
254-// cards added, or a transient note when there was no line to flag or it failed.
255-func (m *model) applyFlag(count int, err error) {
256+// applyFlashcards maps a completed flashcard generation onto UI-local state: the
257+// number of cards added, or a transient note when it failed.
258+func (m *model) applyFlashcards(count int, err error) {
259 	m.phase = phaseIdle
260 	switch {
261-	case errors.Is(err, game.ErrNoNPCLine):
262-		m.status = "no character line to flag yet"
263 	case err != nil:
264 		m.status = "flashcards failed: " + err.Error()
265 	case count == 0:
266@@ -399,10 +406,10 @@ func replayTurn(o *game.Orchestrator) tea.Cmd {
267 	}
268diff --git a/internal/ui/render.go b/internal/ui/render.go
269index 9cc6aae16620cd4868645afa02d40d4dbf052907..ee9f84fd638b66babbdf68d9079714faf7967096 100644
270--- a/internal/ui/render.go
271+++ b/internal/ui/render.go
272@@ -9,7 +9,7 @@ import (
273 )
274 
275 // controlsHint is the idle status line. It lists only keys wired in this build.
276-const controlsHint = "F2 talk · F3 romaji · F4 english · F5 replay · F6 flag · ? ask · Esc quit"
277+const controlsHint = "F2 talk · F3 romaji · F4 english · F5 replay · ? ask · ! cards · Esc quit"
278 
279 // viewState is the plain, render-only snapshot the pure render functions take.
280 // It holds no pointers and no game types, so rendering stays trivially pure.