Phase 12 + 13: collocation coach + vocabulary garden

Phase 12 — collocation coach: a third suggestion family for gentle
"natives usually say…" hints on non-native word pairings, reusing the
existing runPass/pendingScope/rail machinery.
- llm/collocation.go (RunCollocation, 25s floor, reuses ParseCheckpoint)
  + collocationSystemPrompt/CollocationMessages (warm, Mandarin gloss,
  defers grammar/spelling to the grammar family)
- migration 0005 rebuilds the suggestions table to extend the type CHECK
  (SQLite can't ALTER a CHECK)
- collocationScope + CollocationLimit + POST /{id}/collocation
- fix: grammarScope was `type != 'voice'` and would wipe the new
  collocation flags; now `type NOT IN ('voice','collocation')`
- frontend: --color-blossom, "Make it sound natural 🌸" pill,
  collocating/runCollocation in useCheckpoint, StatusBar dot

Phase 13 — vocabulary garden: capture looked-up words and surface them
for gentle spaced repetition.
- new internal/vocab package: migration 0006 (vocab_words, SM-2-lite
  columns, doc_id ON DELETE SET NULL, UNIQUE(user_id,word)),
  scheduler.go (Leitner ladder 1/3/7/16/35 then geometric; gentle
  "again", no streak-shaming), handlers (capture-upsert/list/due/
  review/delete, owner-scoped, SQLite-side datetime math)
- auto-capture on word lookup (dictionary-known words only, captures
  the surrounding sentence + doc_id) + 🤍/💚 toggle on WordCard
- GardenPanel: blossom grid (bloom by reps), flashcard review (sentence
  blanked, flip, again/good/easy, recognition↔production), sleepy-kitten
  footer; opened from a global 🌷 header button

Tests: TestCollocationPassCoexists, vocab scheduler + handlers, db CHECK
extended. go build/vet/test + tsc + vite + vitest (51/51) clean;
migration verified against a copy of the live DB; live backend smoke
walked the full vocab lifecycle + the warm-502 collocation path.

Claude-Session: https://claude.ai/code/session_016Yr6jELuRc7hyzYLccQKZd
This commit is contained in:
prosolis
2026-06-26 15:50:25 -07:00
parent 20442d1356
commit 8aa437ec82
23 changed files with 1614 additions and 58 deletions

245
internal/vocab/handlers.go Normal file
View File

@@ -0,0 +1,245 @@
package vocab
import (
"database/sql"
"encoding/json"
"errors"
"net/http"
"strconv"
"strings"
"time"
"github.com/go-chi/chi/v5"
"gitea.parodia.dev/drwily/petal/internal/db"
)
// Word is one entry in the vocabulary garden: the looked-up word with its gloss,
// phonetic, and the sentence it was met in, plus its spaced-repetition state.
type Word struct {
ID string `json:"id"`
Word string `json:"word"`
Gloss string `json:"gloss"`
Phonetic string `json:"phonetic"`
Example string `json:"example"`
DocID *string `json:"doc_id"`
DueAt time.Time `json:"due_at"`
IntervalDays int `json:"interval_days"`
Ease float64 `json:"ease"`
Reps int `json:"reps"`
Lapses int `json:"lapses"`
LastReviewed *time.Time `json:"last_reviewed"`
CreatedAt time.Time `json:"created_at"`
}
// Handler owns the vocabulary-garden routes. Everything is scoped to the local
// user while auth is deferred.
type Handler struct {
DB *db.DB
}
func New(database *db.DB) *Handler { return &Handler{DB: database} }
// Routes mounts the garden endpoints under /api/vocab.
func (h *Handler) Routes() chi.Router {
r := chi.NewRouter()
r.Get("/", h.list) // the whole garden
r.Get("/due", h.due) // only the cards due for review now
r.Post("/", h.capture) // record/upsert a looked-up word
r.Post("/{id}/review", h.review)
r.Delete("/{id}", h.remove)
return r
}
const vocabColumns = `id, word, gloss, phonetic, example, doc_id,
due_at, interval_days, ease, reps, lapses, last_reviewed, created_at`
func scanWord(s interface {
Scan(dest ...any) error
}) (Word, error) {
var w Word
err := s.Scan(
&w.ID, &w.Word, &w.Gloss, &w.Phonetic, &w.Example, &w.DocID,
&w.DueAt, &w.IntervalDays, &w.Ease, &w.Reps, &w.Lapses, &w.LastReviewed, &w.CreatedAt,
)
return w, err
}
// list returns the full garden, newest blossoms first.
func (h *Handler) list(w http.ResponseWriter, _ *http.Request) {
h.queryList(w, `SELECT `+vocabColumns+` FROM vocab_words
WHERE user_id = ? ORDER BY created_at DESC`, db.LocalUserID)
}
// due returns only the cards whose review time has arrived, soonest first.
func (h *Handler) due(w http.ResponseWriter, _ *http.Request) {
h.queryList(w, `SELECT `+vocabColumns+` FROM vocab_words
WHERE user_id = ? AND due_at <= datetime('now') ORDER BY due_at ASC`, db.LocalUserID)
}
func (h *Handler) queryList(w http.ResponseWriter, query string, args ...any) {
rows, err := h.DB.Query(query, args...)
if err != nil {
serverError(w, err)
return
}
defer rows.Close()
out := []Word{}
for rows.Next() {
word, err := scanWord(rows)
if err != nil {
serverError(w, err)
return
}
out = append(out, word)
}
writeJSON(w, http.StatusOK, out)
}
type captureRequest struct {
Word string `json:"word"`
Gloss string `json:"gloss"`
Phonetic string `json:"phonetic"`
Example string `json:"example"`
DocID *string `json:"doc_id"`
}
// capture records a looked-up word. It's an idempotent upsert keyed on the word:
// a new word lands due tomorrow (interval 1 day); an existing word keeps its
// schedule untouched but refreshes its gloss/phonetic/example/doc_id so the most
// recent context wins. Looking words up IS the data source — no extra effort.
func (h *Handler) capture(w http.ResponseWriter, r *http.Request) {
var req captureRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
errorJSON(w, http.StatusBadRequest, "invalid body")
return
}
word := strings.TrimSpace(req.Word)
if word == "" {
errorJSON(w, http.StatusBadRequest, "word is required")
return
}
// New rows start due tomorrow; ON CONFLICT refreshes context but leaves the
// schedule (due_at/reps/interval/ease) alone so re-looking-up a word never
// resets its progress.
_, err := h.DB.Exec(
`INSERT INTO vocab_words (user_id, word, gloss, phonetic, example, doc_id, due_at, interval_days)
VALUES (?, ?, ?, ?, ?, ?, datetime('now', '+1 day'), 1)
ON CONFLICT(user_id, word) DO UPDATE SET
gloss = excluded.gloss,
phonetic = excluded.phonetic,
example = CASE WHEN excluded.example != '' THEN excluded.example ELSE vocab_words.example END,
doc_id = COALESCE(excluded.doc_id, vocab_words.doc_id)`,
db.LocalUserID, word, req.Gloss, req.Phonetic, req.Example, req.DocID,
)
if err != nil {
serverError(w, err)
return
}
out, err := h.fetch(word)
if err != nil {
serverError(w, err)
return
}
writeJSON(w, http.StatusCreated, out)
}
// fetch loads one word row by its (user, word) key.
func (h *Handler) fetch(word string) (Word, error) {
return scanWord(h.DB.QueryRow(
`SELECT `+vocabColumns+` FROM vocab_words WHERE user_id = ? AND word = ?`,
db.LocalUserID, word,
))
}
type reviewRequest struct {
Grade Grade `json:"grade"`
}
// review grades one card and reschedules it. The grade drives the SM-2-lite
// scheduler; the new interval is applied as `due_at = now + interval days`.
func (h *Handler) review(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, "id")
var req reviewRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
errorJSON(w, http.StatusBadRequest, "invalid body")
return
}
if req.Grade != GradeAgain && req.Grade != GradeGood && req.Grade != GradeEasy {
errorJSON(w, http.StatusBadRequest, "grade must be again, good, or easy")
return
}
var cur State
err := h.DB.QueryRow(
`SELECT reps, interval_days, ease, lapses FROM vocab_words WHERE id = ? AND user_id = ?`,
id, db.LocalUserID,
).Scan(&cur.Reps, &cur.Interval, &cur.Ease, &cur.Lapses)
if errors.Is(err, sql.ErrNoRows) {
errorJSON(w, http.StatusNotFound, "word not found")
return
}
if err != nil {
serverError(w, err)
return
}
nxt := cur.next(req.Grade)
// `datetime('now', '+N days')` keeps the stored value in SQLite's canonical
// text format, matching CURRENT_TIMESTAMP and the due query's comparison.
offset := "+" + strconv.Itoa(nxt.Interval) + " days"
if _, err := h.DB.Exec(
`UPDATE vocab_words SET
reps = ?, interval_days = ?, ease = ?, lapses = ?,
last_reviewed = datetime('now'), due_at = datetime('now', ?)
WHERE id = ? AND user_id = ?`,
nxt.Reps, nxt.Interval, nxt.Ease, nxt.Lapses, offset, id, db.LocalUserID,
); err != nil {
serverError(w, err)
return
}
out, err := scanWord(h.DB.QueryRow(
`SELECT `+vocabColumns+` FROM vocab_words WHERE id = ? AND user_id = ?`, id, db.LocalUserID,
))
if err != nil {
serverError(w, err)
return
}
writeJSON(w, http.StatusOK, out)
}
// remove deletes a word from the garden (e.g. the writer already knows it).
func (h *Handler) remove(w http.ResponseWriter, r *http.Request) {
res, err := h.DB.Exec(
`DELETE FROM vocab_words WHERE id = ? AND user_id = ?`,
chi.URLParam(r, "id"), db.LocalUserID,
)
if err != nil {
serverError(w, err)
return
}
if n, _ := res.RowsAffected(); n == 0 {
errorJSON(w, http.StatusNotFound, "word not found")
return
}
w.WriteHeader(http.StatusNoContent)
}
// --- response helpers -------------------------------------------------------
func writeJSON(w http.ResponseWriter, status int, v any) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
_ = json.NewEncoder(w).Encode(v)
}
func errorJSON(w http.ResponseWriter, status int, msg string) {
writeJSON(w, status, map[string]string{"error": msg})
}
func serverError(w http.ResponseWriter, err error) {
errorJSON(w, http.StatusInternalServerError, err.Error())
}