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:
@@ -227,6 +227,65 @@ func TestVoicePassCoexists(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestCollocationPassCoexists proves the collocation coach is a third
|
||||
// independent family: it never wipes grammar or voice pending flags, a grammar
|
||||
// checkpoint never wipes its flags, and each endpoint returns the unified set.
|
||||
func TestCollocationPassCoexists(t *testing.T) {
|
||||
client := &switchClient{}
|
||||
srv, docID, h := newTestServer(t, client)
|
||||
// Zero the floors so the test can re-run passes without waiting them out.
|
||||
h.Limit = llm.NewRateLimiter(0)
|
||||
h.VoiceLimit = llm.NewRateLimiter(0)
|
||||
h.CollocationLimit = llm.NewRateLimiter(0)
|
||||
|
||||
// Grammar + voice first, so all three families are exercised.
|
||||
client.response = `{"suggestions":[{"original":"I has","replacement":"I have","explanation":"agreement","type":"grammar"}]}`
|
||||
if rec := do(t, srv, http.MethodPost, "/docs/"+docID+"/check", ""); rec.Code != http.StatusOK {
|
||||
t.Fatalf("check: code=%d body=%s", rec.Code, rec.Body)
|
||||
}
|
||||
client.response = `{"suggestions":[{"original":"two apple","replacement":null,"explanation":"sounds formal","type":"voice"}]}`
|
||||
if rec := do(t, srv, http.MethodPost, "/docs/"+docID+"/voice", ""); rec.Code != http.StatusOK {
|
||||
t.Fatalf("voice: code=%d body=%s", rec.Code, rec.Body)
|
||||
}
|
||||
|
||||
// Collocation pass → a third flag (with a replacement). It must keep the
|
||||
// grammar and voice flags; the response is the unified set of all three.
|
||||
client.response = `{"suggestions":[{"original":"two apple","replacement":"two apples","explanation":"Natives usually say…","type":"collocation"}]}`
|
||||
rec := do(t, srv, http.MethodPost, "/docs/"+docID+"/collocation", "")
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("collocation: code=%d body=%s", rec.Code, rec.Body)
|
||||
}
|
||||
var got []db.Suggestion
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil {
|
||||
t.Fatalf("decode: %v", err)
|
||||
}
|
||||
byType := map[string]bool{}
|
||||
for _, s := range got {
|
||||
byType[s.Type] = true
|
||||
}
|
||||
if !byType[db.SuggestionTypeGrammar] || !byType[db.SuggestionTypeVoice] || !byType[db.SuggestionTypeCollocation] {
|
||||
t.Fatalf("collocation response should carry all three families, got %+v", got)
|
||||
}
|
||||
|
||||
// A grammar checkpoint must NOT wipe the voice or collocation flags.
|
||||
client.response = `{"suggestions":[]}`
|
||||
rec = do(t, srv, http.MethodPost, "/docs/"+docID+"/check", "")
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("second check: code=%d body=%s", rec.Code, rec.Body)
|
||||
}
|
||||
_ = json.Unmarshal(rec.Body.Bytes(), &got)
|
||||
byType = map[string]bool{}
|
||||
for _, s := range got {
|
||||
byType[s.Type] = true
|
||||
}
|
||||
if byType[db.SuggestionTypeGrammar] {
|
||||
t.Fatalf("grammar flag should have cleared, got %+v", got)
|
||||
}
|
||||
if !byType[db.SuggestionTypeVoice] || !byType[db.SuggestionTypeCollocation] {
|
||||
t.Fatalf("grammar checkpoint wiped a sibling family, got %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
// switchClient returns a response that can be swapped between calls.
|
||||
type switchClient struct {
|
||||
response string
|
||||
|
||||
Reference in New Issue
Block a user