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

@@ -23,19 +23,22 @@ import (
// grammar checkpoint and the voice pass each get their own per-document rate
// limiter — they are independent passes with different cadences.
type Handler struct {
DB *db.DB
Client llm.LLMClient
Limit *llm.RateLimiter // grammar checkpoint floor
VoiceLimit *llm.RateLimiter // voice-consistency floor
DB *db.DB
Client llm.LLMClient
Limit *llm.RateLimiter // grammar checkpoint floor
VoiceLimit *llm.RateLimiter // voice-consistency floor
CollocationLimit *llm.RateLimiter // collocation-coach floor
}
// New constructs a Handler with per-document checkpoint and voice rate limiters.
// New constructs a Handler with per-document checkpoint, voice, and collocation
// rate limiters.
func New(database *db.DB, client llm.LLMClient) *Handler {
return &Handler{
DB: database,
Client: client,
Limit: llm.NewRateLimiter(llm.CheckpointInterval),
VoiceLimit: llm.NewRateLimiter(llm.VoiceInterval),
DB: database,
Client: client,
Limit: llm.NewRateLimiter(llm.CheckpointInterval),
VoiceLimit: llm.NewRateLimiter(llm.VoiceInterval),
CollocationLimit: llm.NewRateLimiter(llm.CollocationInterval),
}
}
@@ -44,6 +47,7 @@ func New(database *db.DB, client llm.LLMClient) *Handler {
func (h *Handler) RegisterDocRoutes(r chi.Router) {
r.Post("/{id}/check", h.check)
r.Post("/{id}/voice", h.voice)
r.Post("/{id}/collocation", h.collocation)
r.Post("/{id}/rewrite", h.rewrite)
r.Get("/{id}/suggestions", h.listForDoc)
}
@@ -70,6 +74,13 @@ func (h *Handler) voice(w http.ResponseWriter, r *http.Request) {
h.runPass(w, r, h.VoiceLimit, llm.RunVoice, voiceScope)
}
// collocation runs the collocation coach over the whole document, flagging
// non-native word pairings. Explicit-action pass; replaces only the pending
// collocation flags.
func (h *Handler) collocation(w http.ResponseWriter, r *http.Request) {
h.runPass(w, r, h.CollocationLimit, llm.RunCollocation, collocationScope)
}
// pass is the signature shared by the grammar checkpoint and the voice pass:
// given the document text and the document's tone it returns the model's raw
// suggestions. The voice pass ignores tone (see llm.RunVoice).
@@ -151,10 +162,14 @@ type pendingScope struct {
}
var (
// grammarScope owns the grammar/phrasing/idiom/clarity flags (everything but voice).
grammarScope = pendingScope{deleteWhere: "type != 'voice'", forceType: ""}
// grammarScope owns the grammar/phrasing/idiom/clarity flags everything but
// the explicit-action families (voice, collocation), which run on their own
// cadence and must survive a grammar checkpoint.
grammarScope = pendingScope{deleteWhere: "type NOT IN ('voice','collocation')", forceType: ""}
// voiceScope owns the voice flags only.
voiceScope = pendingScope{deleteWhere: "type = 'voice'", forceType: db.SuggestionTypeVoice}
// collocationScope owns the collocation flags only.
collocationScope = pendingScope{deleteWhere: "type = 'collocation'", forceType: db.SuggestionTypeCollocation}
)
// replacePending swaps a document's pending suggestions within one family for a
@@ -320,7 +335,7 @@ func locate(contentText, original string) (int, int) {
// defaulting unknown values to grammar so a stray label never trips the CHECK.
func normalizeType(t string) string {
switch strings.ToLower(strings.TrimSpace(t)) {
case db.SuggestionTypeGrammar, db.SuggestionTypePhrasing, db.SuggestionTypeIdiom, db.SuggestionTypeClarity:
case db.SuggestionTypeGrammar, db.SuggestionTypePhrasing, db.SuggestionTypeIdiom, db.SuggestionTypeClarity, db.SuggestionTypeCollocation:
return strings.ToLower(strings.TrimSpace(t))
default:
return db.SuggestionTypeGrammar