The garden learns which language a card is in, and read-aloud stops guessing

Phase 28 (c), the last of the phase. A word met inside a Portuguese
document is a Portuguese card: migration 0018 mirrors documents.doc_lang
onto vocab_words, set server-side from the ownership lookup capture was
already making. Every card still reviews — filtering the queue to the
half she is learning would drop the words she actually met.

Read-aloud was the larger surprise. detectLang routed Han/kana to
Chinese and everything else to en-US, so the zh pair was accidentally
right and every Latin pair wrong. doc_lang now reaches the client
read-only on the document JSON, and docLang(text, verdict) answers for a
passage taken out of it — with the script test still winning, because
quoted Chinese must never be spelled out one "Chinese letter" at a time.

Claude-Session: https://claude.ai/code/session_01GJHNvirh7Hzhc9RL3HAvz7
This commit is contained in:
prosolis
2026-07-28 23:45:26 -07:00
parent 29eb2fe1fc
commit 466055020f
15 changed files with 352 additions and 57 deletions
+32 -16
View File
@@ -19,13 +19,18 @@ import (
// 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"`
Definition string `json:"definition"` // English fallback meaning when there's no Chinese gloss
Phonetic string `json:"phonetic"`
Example string `json:"example"`
DocID *string `json:"doc_id"`
ID string `json:"id"`
Word string `json:"word"`
Gloss string `json:"gloss"`
Definition string `json:"definition"` // English fallback meaning when there's no Chinese gloss
Phonetic string `json:"phonetic"`
Example string `json:"example"`
DocID *string `json:"doc_id"`
// Lang is '' | 'en' | 'pair' — the language of the document the word was met
// in (see migration 0018). '' reads as English, like everywhere else this
// vocabulary appears. The client needs it to pick a read-aloud voice: "comum"
// is unguessable from its letters, so the card has to carry the answer.
Lang string `json:"lang"`
DueAt time.Time `json:"due_at"`
IntervalDays int `json:"interval_days"`
Ease float64 `json:"ease"`
@@ -54,7 +59,7 @@ func (h *Handler) Routes() chi.Router {
return r
}
const vocabColumns = `id, word, gloss, definition, phonetic, example, doc_id,
const vocabColumns = `id, word, gloss, definition, phonetic, example, doc_id, lang,
due_at, interval_days, ease, reps, lapses, last_reviewed, created_at`
func scanWord(s interface {
@@ -62,7 +67,7 @@ func scanWord(s interface {
}) (Word, error) {
var w Word
err := s.Scan(
&w.ID, &w.Word, &w.Gloss, &w.Definition, &w.Phonetic, &w.Example, &w.DocID,
&w.ID, &w.Word, &w.Gloss, &w.Definition, &w.Phonetic, &w.Example, &w.DocID, &w.Lang,
&w.DueAt, &w.IntervalDays, &w.Ease, &w.Reps, &w.Lapses, &w.LastReviewed, &w.CreatedAt,
)
return w, err
@@ -165,15 +170,22 @@ func (h *Handler) capture(w http.ResponseWriter, r *http.Request) {
// would hit the foreign key and leak a raw "FOREIGN KEY constraint" 500
// instead of a clean 400 (and, once auth lands, would let a word be attached
// to another user's document).
//
// The same row-scoped lookup answers what language the card is in: a word is
// met inside a document, so the document's verdict is the word's language.
// Asking the document rather than trusting a `lang` in the request body is
// the same choice `runPass` makes — the client never gets to name a language
// the server can already read. A word with no document is '', which reads as
// English.
lang := ""
if req.DocID != nil {
if strings.TrimSpace(*req.DocID) == "" {
req.DocID = nil
} else {
var ok int
err := h.DB.QueryRow(
`SELECT 1 FROM documents WHERE id = ? AND user_id = ?`,
`SELECT doc_lang FROM documents WHERE id = ? AND user_id = ?`,
*req.DocID, userID,
).Scan(&ok)
).Scan(&lang)
if errors.Is(err, sql.ErrNoRows) {
httputil.ErrorJSON(w, http.StatusBadRequest, "unknown doc_id")
return
@@ -189,15 +201,19 @@ func (h *Handler) capture(w http.ResponseWriter, r *http.Request) {
// 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, definition, phonetic, example, doc_id, due_at, interval_days)
VALUES (?, ?, ?, ?, ?, ?, ?, datetime('now', '+1 day'), 1)
`INSERT INTO vocab_words (user_id, word, gloss, definition, phonetic, example, doc_id, lang, due_at, interval_days)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, datetime('now', '+1 day'), 1)
ON CONFLICT(user_id, word) DO UPDATE SET
gloss = excluded.gloss,
definition = excluded.definition,
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)`,
userID, word, req.Gloss, req.Definition, req.Phonetic, req.Example, req.DocID,
doc_id = COALESCE(excluded.doc_id, vocab_words.doc_id),
-- lang travels with doc_id, and for the same reason: it is the new
-- context or it is nothing. A lookup made outside any document must
-- not relabel a card that was captured inside one.
lang = CASE WHEN excluded.doc_id IS NOT NULL THEN excluded.lang ELSE vocab_words.lang END`,
userID, word, req.Gloss, req.Definition, req.Phonetic, req.Example, req.DocID, lang,
)
if err != nil {
httputil.ServerError(w, err)