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

@@ -260,6 +260,71 @@ END;
INSERT INTO documents_fts (doc_id, title, content_text)
SELECT id, title, content_text FROM documents;
`,
},
{
// Collocation coach (Phase 12). Adds a third suggestion family,
// 'collocation', for gentle "natives usually say…" hints on
// non-native word pairings. The `type` column carries a CHECK
// constraint and SQLite cannot ALTER one in place, so we rebuild the
// suggestions table with the extended CHECK, copy every row across,
// and recreate its index. Nothing references suggestions, so dropping
// the old table is safe; the new table keeps the same ON DELETE
// CASCADE to documents.
name: "0005_collocation_suggestion_type",
stmt: `
CREATE TABLE suggestions_new (
id TEXT PRIMARY KEY DEFAULT (lower(hex(randomblob(16)))),
doc_id TEXT NOT NULL REFERENCES documents(id) ON DELETE CASCADE,
from_pos INTEGER NOT NULL,
to_pos INTEGER NOT NULL,
original TEXT NOT NULL,
replacement TEXT NOT NULL,
explanation TEXT NOT NULL,
type TEXT NOT NULL CHECK(type IN ('grammar','phrasing','idiom','clarity','voice','collocation')),
status TEXT NOT NULL DEFAULT 'pending' CHECK(status IN ('pending','accepted','rejected')),
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
INSERT INTO suggestions_new (id, doc_id, from_pos, to_pos, original, replacement, explanation, type, status, created_at)
SELECT id, doc_id, from_pos, to_pos, original, replacement, explanation, type, status, created_at FROM suggestions;
DROP TABLE suggestions;
ALTER TABLE suggestions_new RENAME TO suggestions;
CREATE INDEX idx_suggestions_doc_id ON suggestions(doc_id);
`,
},
{
// Vocabulary garden (Phase 13). Every word the writer looks up is
// captured here and put on a gentle spaced-repetition schedule, turning
// passive lookups into real vocabulary. `example` holds the sentence the
// word appeared in (captured at lookup) for context during review;
// `doc_id` links back to where she met the word (nulled if that doc is
// deleted — the word stays in the garden). The SM-2-lite scheduling
// columns (due_at/interval_days/ease/reps/lapses/last_reviewed) drive a
// Leitner-style ladder (see internal/vocab/scheduler.go). UNIQUE on
// (user_id, word) makes capture an idempotent upsert.
name: "0006_vocab_garden",
stmt: `
CREATE TABLE vocab_words (
id TEXT PRIMARY KEY DEFAULT (lower(hex(randomblob(16)))),
user_id TEXT NOT NULL REFERENCES users(id),
word TEXT NOT NULL,
gloss TEXT NOT NULL DEFAULT '',
phonetic TEXT NOT NULL DEFAULT '',
example TEXT NOT NULL DEFAULT '',
doc_id TEXT REFERENCES documents(id) ON DELETE SET NULL,
due_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
interval_days INTEGER NOT NULL DEFAULT 0,
ease REAL NOT NULL DEFAULT 2.5,
reps INTEGER NOT NULL DEFAULT 0,
lapses INTEGER NOT NULL DEFAULT 0,
last_reviewed DATETIME,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
UNIQUE(user_id, word)
);
CREATE INDEX idx_vocab_due ON vocab_words(user_id, due_at);
`,
},
}

View File

@@ -39,6 +39,14 @@ func TestOpenMigratesAndSeeds(t *testing.T) {
t.Fatalf("insert voice suggestion: %v", err)
}
// The collocation type (added by migration 0005's table rebuild) is permitted.
if _, err := d.Exec(
`INSERT INTO suggestions (doc_id, from_pos, to_pos, original, replacement, explanation, type)
VALUES ('d1', 0, 9, 'do a decision', 'make a decision', 'natives usually say…', 'collocation')`,
); err != nil {
t.Fatalf("insert collocation suggestion: %v", err)
}
// An invalid type is rejected.
if _, err := d.Exec(
`INSERT INTO suggestions (doc_id, from_pos, to_pos, original, replacement, explanation, type)

View File

@@ -88,18 +88,19 @@ type Suggestion struct {
Original string `json:"original"`
Replacement string `json:"replacement"`
Explanation string `json:"explanation"`
Type string `json:"type"` // grammar | phrasing | idiom | clarity | voice
Type string `json:"type"` // grammar | phrasing | idiom | clarity | voice | collocation
Status string `json:"status"` // pending | accepted | rejected
CreatedAt time.Time `json:"created_at"`
}
// Suggestion type and status values, mirrored from the schema CHECK constraints.
const (
SuggestionTypeGrammar = "grammar"
SuggestionTypePhrasing = "phrasing"
SuggestionTypeIdiom = "idiom"
SuggestionTypeClarity = "clarity"
SuggestionTypeVoice = "voice"
SuggestionTypeGrammar = "grammar"
SuggestionTypePhrasing = "phrasing"
SuggestionTypeIdiom = "idiom"
SuggestionTypeClarity = "clarity"
SuggestionTypeVoice = "voice"
SuggestionTypeCollocation = "collocation"
SuggestionStatusPending = "pending"
SuggestionStatusAccepted = "accepted"

View File

@@ -0,0 +1,35 @@
package llm
import (
"context"
"time"
)
// CollocationInterval is the minimum gap between collocation passes for one
// document. Like the voice pass it runs on an explicit user action ("Make it
// sound natural") rather than a typing cadence, so this floor only guards the
// inference endpoint against the button being mashed.
const CollocationInterval = 25 * time.Second
// RunCollocation sends the WHOLE document for a collocation pass — gentle
// "natives usually say…" hints on non-native word pairings — and parses the JSON
// result with the checkpoint's tolerant parser. It is deliberately NOT
// TruncateDoc'd: like the voice pass it reads the whole document so the hints
// reflect the full piece. Each flag carries a native replacement to apply.
//
// The tone argument is accepted for a uniform pass signature and passed through
// to the prompt so a hint can prefer a register-appropriate pairing.
func RunCollocation(ctx context.Context, client LLMClient, contentText, tone string) ([]RawSuggestion, error) {
raw, err := client.Complete(ctx, CompletionRequest{
Messages: CollocationMessages(contentText, tone),
MaxTokens: 2048,
Temperature: 0.3,
RepetitionPenalty: 1.15,
TopP: 0.9,
Stop: []string{"```", "\n\n\n\n"},
})
if err != nil {
return nil, err
}
return ParseCheckpoint(raw)
}

View File

@@ -92,6 +92,54 @@ func VoiceMessages(contentText string) []Message {
}
}
// collocationSystemPrompt drives the collocation coach — the single most
// valuable polish for an ESL writer. It flags word PAIRINGS that are not wrong,
// just non-native ("do a decision" → "make a decision", "strong rain" → "heavy
// rain"), and explicitly DEFERS real grammar/spelling errors to the grammar
// checkpoint so the two families don't overlap. Every explanation is framed as a
// warm "natives usually say…" note with a short Mandarin gloss — never
// "error/wrong" — because these are stylistic, not mistakes. It is a distinct
// pass from the grammar checkpoint (do not bundle them). `replacement` carries
// the natural pairing the writer can accept in one tap.
const collocationSystemPrompt = `You are a warm, encouraging writing assistant helping someone who speaks English as a second language. ` +
`You are reviewing a COMPLETE document for COLLOCATIONS only — the natural word pairings native speakers use.
A collocation is a pair or short group of words that native speakers habitually say together. ESL writers ` +
`often choose words that are grammatically correct but sound non-native: "do a decision" instead of "make a decision", ` +
`"strong rain" instead of "heavy rain", "say a joke" instead of "tell a joke". These are NOT grammar mistakes — they ` +
`are just not what a native speaker would naturally say.
Identify up to 5 such non-native word pairings. For each, give the natural pairing a native speaker would use. ` +
`Be gentle and specific. Do NOT flag grammar errors, spelling mistakes, or unclear sentences — those are handled ` +
`elsewhere. Only flag word pairings that are correct but sound non-native.%s
Phrase every explanation warmly as "Natives usually say…" and include a brief Simplified Chinese gloss in parentheses. ` +
`Never use the words "error", "wrong", or "mistake" — these are friendly polish, not corrections.
Respond ONLY with valid JSON. No preamble, no markdown fences. Format:
{
"suggestions": [
{
"original": "exact word pairing from the document",
"replacement": "the natural native pairing",
"explanation": "friendly note, e.g. 'Natives usually say \"make a decision\" rather than \"do a decision\" (native usage / 地道说法).'",
"type": "collocation"
}
]
}
If every pairing already sounds natural, return: {"suggestions": []}`
// CollocationMessages builds the message array for a collocation pass over the
// WHOLE document (no truncation), gently steered toward the document's tone so a
// hint can prefer a register-appropriate pairing.
func CollocationMessages(contentText, tone string) []Message {
return []Message{
{Role: "system", Content: fmt.Sprintf(collocationSystemPrompt, toneGuidance(tone))},
{Role: "user", Content: contentText},
}
}
// askPetalSystemTemplate is the Ask Petal tutor prompt. The suggestion context
// is interpolated in; the user's own messages are appended after this system
// turn by the caller.

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

View File

@@ -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

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())
}

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)
}
}

View File

@@ -0,0 +1,97 @@
// Package vocab implements the vocabulary garden: it captures words the writer
// looks up and schedules them for gentle spaced-repetition review. The scheduler
// is a deliberately forgiving SM-2-lite / Leitner hybrid — no streaks to break,
// no harsh resets beyond a single step back — because this is a confidence
// builder, not a drill sergeant.
package vocab
import "math"
// Grade is the writer's self-assessment after seeing a flashcard's answer.
type Grade string
const (
GradeAgain Grade = "again" // didn't recall it — show again soon
GradeGood Grade = "good" // recalled it — advance one rung
GradeEasy Grade = "easy" // knew it instantly — advance a little further
)
// ladder is the Leitner interval ladder in days for the first several successful
// reviews: 1 → 3 → 7 → 16 → 35. Beyond the ladder, intervals grow by the card's
// ease factor so well-known words drift far into the future.
var ladder = []int{1, 3, 7, 16, 35}
const (
minEase = 1.3
startEase = 2.5
easyBonus = 1.3 // multiplier applied on top of a "good" step for "easy"
easeAgainDelta = -0.20 // ease nudged down on a lapse (still floored at minEase)
easeEasyDelta = 0.15 // ease nudged up when a card feels easy
)
// State is the spaced-repetition state of one card. It mirrors the scheduling
// columns on vocab_words so a review is "load State → next(grade) → persist".
type State struct {
Reps int
Interval int // days until the next review
Ease float64
Lapses int
}
// next returns the card's state after a review with the given grade. It never
// mutates the receiver. "again" steps the card back to a 1-day interval and
// counts a lapse (but only nudges ease down, never wipes progress harshly);
// "good" advances one rung of the ladder; "easy" advances a rung and a bit more.
func (s State) next(grade Grade) State {
ease := s.Ease
if ease == 0 {
ease = startEase
}
switch grade {
case GradeAgain:
return State{
Reps: 0,
Interval: 1,
Ease: clampEase(ease + easeAgainDelta),
Lapses: s.Lapses + 1,
}
case GradeEasy:
ease = clampEase(ease + easeEasyDelta)
reps := s.Reps + 1
return State{
Reps: reps,
Interval: int(math.Round(float64(goodInterval(reps, s.Interval, ease)) * easyBonus)),
Ease: ease,
Lapses: s.Lapses,
}
default: // GradeGood
reps := s.Reps + 1
return State{
Reps: reps,
Interval: goodInterval(reps, s.Interval, ease),
Ease: ease,
Lapses: s.Lapses,
}
}
}
// goodInterval returns the day-interval for a successful review: the explicit
// ladder while it lasts, then geometric growth by ease once past it.
func goodInterval(reps, prevInterval int, ease float64) int {
if reps >= 1 && reps <= len(ladder) {
return ladder[reps-1]
}
prev := prevInterval
if prev < ladder[len(ladder)-1] {
prev = ladder[len(ladder)-1]
}
return int(math.Round(float64(prev) * ease))
}
func clampEase(e float64) float64 {
if e < minEase {
return minEase
}
return e
}

View File

@@ -0,0 +1,66 @@
package vocab
import "testing"
// TestLadderProgression walks a card up the Leitner ladder on repeated "good"
// reviews: 1 → 3 → 7 → 16 → 35 days, then geometric growth by ease.
func TestLadderProgression(t *testing.T) {
s := State{Ease: startEase}
want := []int{1, 3, 7, 16, 35}
for i, w := range want {
s = s.next(GradeGood)
if s.Interval != w {
t.Fatalf("rung %d: interval=%d, want %d", i, s.Interval, w)
}
if s.Reps != i+1 {
t.Fatalf("rung %d: reps=%d, want %d", i, s.Reps, i+1)
}
}
// Past the ladder it grows by ease (35 * 2.5 = 87.5 → 88).
s = s.next(GradeGood)
if s.Interval != 88 {
t.Fatalf("post-ladder interval=%d, want 88", s.Interval)
}
}
// TestAgainStepsBackGently proves a lapse resets to a 1-day interval and counts
// a lapse, but only nudges ease down (never below the floor) — no harsh wipe.
func TestAgainStepsBackGently(t *testing.T) {
s := State{Reps: 4, Interval: 35, Ease: startEase}
s = s.next(GradeAgain)
if s.Interval != 1 {
t.Fatalf("again interval=%d, want 1", s.Interval)
}
if s.Reps != 0 {
t.Fatalf("again reps=%d, want 0", s.Reps)
}
if s.Lapses != 1 {
t.Fatalf("again lapses=%d, want 1", s.Lapses)
}
if s.Ease != startEase+easeAgainDelta {
t.Fatalf("again ease=%v, want %v", s.Ease, startEase+easeAgainDelta)
}
// Ease never drops below the floor no matter how many lapses.
low := State{Ease: minEase}
for i := 0; i < 5; i++ {
low = low.next(GradeAgain)
}
if low.Ease < minEase {
t.Fatalf("ease fell below floor: %v", low.Ease)
}
}
// TestEasyAdvancesFurther proves "easy" both bumps ease and lands a longer
// interval than a plain "good" at the same rung.
func TestEasyAdvancesFurther(t *testing.T) {
base := State{Reps: 2, Interval: 7, Ease: startEase}
good := base.next(GradeGood)
easy := base.next(GradeEasy)
if easy.Interval <= good.Interval {
t.Fatalf("easy interval %d should exceed good interval %d", easy.Interval, good.Interval)
}
if easy.Ease <= startEase {
t.Fatalf("easy should raise ease, got %v", easy.Ease)
}
}