Parent directory

flashcards.go

2348 bytes
 1package adapters
 2
 3import (
 4	"context"
 5	"encoding/csv"
 6	"fmt"
 7	"os"
 8	"sync"
 9
10	"git.theedgeofrage.com/TheEdgeOfRage/kaiwari/internal/llm"
11)
12
13// flashcardsPath is where generated vocabulary cards are persisted, next to the
14// raw model log. It is a plain append-only CSV for an external flashcard app.
15const flashcardsPath = "flashcards.csv"
16
17const flashcardHeader = "kana,romaji,english,explanation"
18
19// FlashcardModel adapts an llm.Client to game.FlashcardModel. It asks the model
20// to generate a sentence from instructions and turn it into raw CSV, validates
21// the rows in Go, and appends them to the persistent local deck.
22type FlashcardModel struct {
23	Client *llm.Client
24}
25
26func (m *FlashcardModel) Generate(ctx context.Context, instructions string) (int, error) {
27	ctx, cancel := context.WithTimeout(ctx, sideTaskTimeout)
28	defer cancel()
29	raw, err := m.Client.Generate(ctx, llm.BuildFlashcardMessages(instructions))
30	if err != nil {
31		return 0, err
32	}
33	dumpRaw("flashcards", "", raw)
34	rows, err := llm.ParseFlashcards(raw)
35	if err != nil {
36		return 0, err
37	}
38	if len(rows) == 0 {
39		return 0, nil
40	}
41	if err := appendFlashcards(flashcardsPath, rows); err != nil {
42		return 0, err
43	}
44	return len(rows), nil
45}
46
47// flashcardsMu serializes deck appends so concurrent sessions cannot
48// double-write the header or interleave rows.
49var flashcardsMu sync.Mutex
50
51// appendFlashcards appends valid rows to the deck file, creating it with the
52// header when it does not exist yet. Existing rows are never rewritten.
53func appendFlashcards(path string, rows []llm.FlashcardRow) error {
54	flashcardsMu.Lock()
55	defer flashcardsMu.Unlock()
56
57	f, err := os.OpenFile(path, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o644)
58	if err != nil {
59		return fmt.Errorf("open flashcards: %w", err)
60	}
61
62	err = func() error {
63		fi, err := f.Stat()
64		if err != nil {
65			return fmt.Errorf("stat flashcards: %w", err)
66		}
67		if fi.Size() == 0 {
68			if _, err := f.WriteString(flashcardHeader + "\n"); err != nil {
69				return fmt.Errorf("write flashcards header: %w", err)
70			}
71		}
72		w := csv.NewWriter(f)
73		for _, r := range rows {
74			if err := w.Write([]string{r.Kana, r.Romaji, r.English, r.Explanation}); err != nil {
75				return fmt.Errorf("write flashcard row: %w", err)
76			}
77		}
78		w.Flush()
79		return w.Error()
80	}()
81	if cerr := f.Close(); err == nil {
82		err = cerr
83	}
84	return err
85}