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