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

View File

@@ -0,0 +1,170 @@
package vocab
import (
"bytes"
"encoding/json"
"net/http"
"net/http/httptest"
"path/filepath"
"testing"
"github.com/go-chi/chi/v5"
"gitea.parodia.dev/drwily/petal/internal/db"
)
func newTestServer(t *testing.T) (http.Handler, *db.DB) {
t.Helper()
database, err := db.Open(filepath.Join(t.TempDir(), "test.db"))
if err != nil {
t.Fatalf("open db: %v", err)
}
t.Cleanup(func() { database.Close() })
r := chi.NewRouter()
r.Mount("/vocab", New(database).Routes())
return r, database
}
func do(t *testing.T, srv http.Handler, method, path, body string) *httptest.ResponseRecorder {
t.Helper()
var r *http.Request
if body != "" {
r = httptest.NewRequest(method, path, bytes.NewBufferString(body))
} else {
r = httptest.NewRequest(method, path, nil)
}
rec := httptest.NewRecorder()
srv.ServeHTTP(rec, r)
return rec
}
// TestCaptureUpsertAndReview walks the garden lifecycle: capture a word (lands
// in the future, not yet due), re-capture refreshes context without resetting
// schedule, a forced-due word shows up in /due, a review reschedules it, and
// delete removes it.
func TestCaptureUpsertAndReview(t *testing.T) {
srv, database := newTestServer(t)
// Capture a new word → 201, due tomorrow (interval 1), not in /due yet.
rec := do(t, srv, http.MethodPost, "/vocab",
`{"word":"serendipity","gloss":"机缘巧合","phonetic":"/ˌserənˈdɪpəti/","example":"What a serendipity."}`)
if rec.Code != http.StatusCreated {
t.Fatalf("capture: code=%d body=%s", rec.Code, rec.Body)
}
var w Word
_ = json.Unmarshal(rec.Body.Bytes(), &w)
if w.ID == "" || w.Word != "serendipity" || w.Gloss != "机缘巧合" {
t.Fatalf("captured word wrong: %+v", w)
}
if w.IntervalDays != 1 || w.Reps != 0 {
t.Fatalf("new word should start at interval 1, reps 0: %+v", w)
}
// Not due yet (due tomorrow).
rec = do(t, srv, http.MethodGet, "/vocab/due", "")
var due []Word
_ = json.Unmarshal(rec.Body.Bytes(), &due)
if len(due) != 0 {
t.Fatalf("freshly-captured word should not be due yet, got %d", len(due))
}
// Re-capture with a new gloss → still one row (idempotent upsert), refreshed.
rec = do(t, srv, http.MethodPost, "/vocab",
`{"word":"serendipity","gloss":"意外的好运","phonetic":"/x/","example":""}`)
if rec.Code != http.StatusCreated {
t.Fatalf("re-capture: code=%d body=%s", rec.Code, rec.Body)
}
rec = do(t, srv, http.MethodGet, "/vocab", "")
var all []Word
_ = json.Unmarshal(rec.Body.Bytes(), &all)
if len(all) != 1 {
t.Fatalf("re-capture should not add a row, got %d", len(all))
}
if all[0].Gloss != "意外的好运" {
t.Fatalf("gloss should refresh on re-capture, got %q", all[0].Gloss)
}
if all[0].Example != "What a serendipity." {
t.Fatalf("empty example should not clobber the prior one, got %q", all[0].Example)
}
// Force it due now, then it appears in /due.
if _, err := database.Exec(`UPDATE vocab_words SET due_at = datetime('now','-1 hour')`); err != nil {
t.Fatalf("force due: %v", err)
}
rec = do(t, srv, http.MethodGet, "/vocab/due", "")
_ = json.Unmarshal(rec.Body.Bytes(), &due)
if len(due) != 1 {
t.Fatalf("forced-due word should be in /due, got %d", len(due))
}
// Review "good" → reschedules forward (interval 3 = second ladder rung, since
// reps was 0 and a capture set interval 1 but reps 0; first good → rung 1 = 1).
rec = do(t, srv, http.MethodPost, "/vocab/"+w.ID+"/review", `{"grade":"good"}`)
if rec.Code != http.StatusOK {
t.Fatalf("review: code=%d body=%s", rec.Code, rec.Body)
}
var reviewed Word
_ = json.Unmarshal(rec.Body.Bytes(), &reviewed)
if reviewed.Reps != 1 || reviewed.IntervalDays != 1 {
t.Fatalf("first good review should be reps 1, interval 1: %+v", reviewed)
}
if reviewed.LastReviewed == nil {
t.Fatalf("review should stamp last_reviewed")
}
// After a forward review it's no longer due.
rec = do(t, srv, http.MethodGet, "/vocab/due", "")
_ = json.Unmarshal(rec.Body.Bytes(), &due)
if len(due) != 0 {
t.Fatalf("reviewed word should leave /due, got %d", len(due))
}
// Bad grade → 400.
if rec = do(t, srv, http.MethodPost, "/vocab/"+w.ID+"/review", `{"grade":"nope"}`); rec.Code != http.StatusBadRequest {
t.Fatalf("bad grade: want 400, got %d", rec.Code)
}
// Delete → 204, garden empty.
if rec = do(t, srv, http.MethodDelete, "/vocab/"+w.ID, ""); rec.Code != http.StatusNoContent {
t.Fatalf("delete: code=%d", rec.Code)
}
if rec = do(t, srv, http.MethodDelete, "/vocab/"+w.ID, ""); rec.Code != http.StatusNotFound {
t.Fatalf("re-delete: want 404, got %d", rec.Code)
}
}
// TestCaptureValidation rejects an empty word.
func TestCaptureValidation(t *testing.T) {
srv, _ := newTestServer(t)
if rec := do(t, srv, http.MethodPost, "/vocab", `{"word":" "}`); rec.Code != http.StatusBadRequest {
t.Fatalf("empty word: want 400, got %d", rec.Code)
}
}
// TestDocLinkSurvivesDocDelete proves the ON DELETE SET NULL keeps a word in the
// garden when its source document is removed.
func TestDocLinkSurvivesDocDelete(t *testing.T) {
srv, database := newTestServer(t)
var docID string
if err := database.QueryRow(
`INSERT INTO documents (user_id, content_text) VALUES (?, 'hi') RETURNING id`, db.LocalUserID,
).Scan(&docID); err != nil {
t.Fatalf("seed doc: %v", err)
}
rec := do(t, srv, http.MethodPost, "/vocab", `{"word":"ephemeral","gloss":"短暂的","doc_id":"`+docID+`"}`)
if rec.Code != http.StatusCreated {
t.Fatalf("capture: code=%d body=%s", rec.Code, rec.Body)
}
if _, err := database.Exec(`DELETE FROM documents WHERE id = ?`, docID); err != nil {
t.Fatalf("delete doc: %v", err)
}
rec = do(t, srv, http.MethodGet, "/vocab", "")
var all []Word
_ = json.Unmarshal(rec.Body.Bytes(), &all)
if len(all) != 1 {
t.Fatalf("word should survive doc deletion, got %d", len(all))
}
if all[0].DocID != nil {
t.Fatalf("doc_id should be nulled after doc delete, got %v", *all[0].DocID)
}
}