flashcard.go
3709 bytes
1package llm
2
3import (
4 "encoding/csv"
5 "fmt"
6 "strings"
7)
8
9// FlashcardRow is one vocabulary card parsed from the flashcard CSV output. The
10// kana field carries real kana on purpose: it is written to a local CSV file for
11// an external flashcard app and never reaches the TUI.
12type FlashcardRow struct {
13 Kana string
14 Romaji string
15 English string
16 Explanation string
17}
18
19// flashcardSystemTemplate turns a set of instructions into vocabulary flashcards.
20// It is persona-free and carries no game, player, NPC, quest, or scenario context.
21// Unlike the other prompts it asks for raw CSV (no FIELD|value contract), which
22// Go then parses and validates before persisting.
23const 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.
24
25First 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.
26
27Output raw CSV only. No markdown, no code fences, no commentary, nothing before or after. The first line must be exactly this header:
28kana,romaji,english,explanation
29
30Then one row per distinct word or phrase from your sentence, each with exactly four comma-separated fields:
31- kana: the word or phrase written in kana
32- romaji: that same word or phrase in Hepburn romaji
33- english: its English meaning, kept short
34- explanation: a more detailed breakdown - what each part means, how it is used, and any grammar note
35
36Rules:
37- One row per distinct item; never repeat the same word.
38- Keep every row on a single line.
39- If a field contains a comma, wrap that whole field in double quotes.`
40
41// BuildFlashcardMessages assembles the flashcard request from one set of
42// instructions: a static system message plus one user message carrying them. It
43// carries no game, history, or sheet context.
44func BuildFlashcardMessages(instructions string) []Message {
45 return []Message{
46 {Role: RoleSystem, Content: flashcardSystemTemplate},
47 {Role: RoleUser, Content: strings.TrimSpace(instructions)},
48 }
49}
50
51// ParseFlashcards parses raw flashcard CSV into valid rows. A record is valid
52// only when it has exactly four non-empty fields; malformed records are
53// discarded. The header row, when present, is skipped. An error is returned only
54// when the text cannot be read as CSV at all.
55func ParseFlashcards(raw string) ([]FlashcardRow, error) {
56 r := csv.NewReader(strings.NewReader(raw))
57 r.FieldsPerRecord = -1 // validate field count per record below
58 records, err := r.ReadAll()
59 if err != nil {
60 return nil, fmt.Errorf("llm: parse flashcards csv: %w", err)
61 }
62 var rows []FlashcardRow
63 for i, rec := range records {
64 if i == 0 && isFlashcardHeader(rec) {
65 continue
66 }
67 if row, ok := parseFlashcardRecord(rec); ok {
68 rows = append(rows, row)
69 }
70 }
71 return rows, nil
72}
73
74func isFlashcardHeader(rec []string) bool {
75 want := [4]string{"kana", "romaji", "english", "explanation"}
76 if len(rec) != 4 {
77 return false
78 }
79 for i, w := range want {
80 if strings.ToLower(strings.TrimSpace(rec[i])) != w {
81 return false
82 }
83 }
84 return true
85}
86
87func parseFlashcardRecord(rec []string) (FlashcardRow, bool) {
88 if len(rec) != 4 {
89 return FlashcardRow{}, false
90 }
91 row := FlashcardRow{
92 Kana: strings.TrimSpace(rec[0]),
93 Romaji: strings.TrimSpace(rec[1]),
94 English: strings.TrimSpace(rec[2]),
95 Explanation: strings.TrimSpace(rec[3]),
96 }
97 if row.Kana == "" || row.Romaji == "" || row.English == "" || row.Explanation == "" {
98 return FlashcardRow{}, false
99 }
100 return row, true
101}