Parent directory

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}