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:
+32
-16
@@ -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)
|
||||
|
||||
@@ -253,3 +253,89 @@ func TestDocLinkSurvivesDocDelete(t *testing.T) {
|
||||
t.Fatalf("doc_id should be nulled after doc delete, got %v", *all[0].DocID)
|
||||
}
|
||||
}
|
||||
|
||||
// TestCaptureTakesLanguageFromItsDocument is the garden's half of Phase 28: a
|
||||
// word met inside a document written in her own language is a card in that
|
||||
// language, and the server reads that off the document rather than being told.
|
||||
//
|
||||
// The three cases are the three the client can actually produce: a lookup inside
|
||||
// a flipped document, a lookup inside an English one, and a lookup with no
|
||||
// document at all (the search box) — the last of which is '', which reads as
|
||||
// English everywhere this value is used.
|
||||
func TestCaptureTakesLanguageFromItsDocument(t *testing.T) {
|
||||
srv, database := newTestServer(t)
|
||||
|
||||
seed := func(lang string) string {
|
||||
t.Helper()
|
||||
var id string
|
||||
if err := database.QueryRow(
|
||||
`INSERT INTO documents (user_id, content_text, doc_lang) VALUES (?, 'hi', ?) RETURNING id`,
|
||||
db.LocalUserID, lang,
|
||||
).Scan(&id); err != nil {
|
||||
t.Fatalf("seed doc: %v", err)
|
||||
}
|
||||
return id
|
||||
}
|
||||
pairDoc, enDoc := seed("pair"), seed("en")
|
||||
|
||||
capture := func(word, body string) Word {
|
||||
t.Helper()
|
||||
rec := do(t, srv, http.MethodPost, "/vocab", body)
|
||||
if rec.Code != http.StatusCreated {
|
||||
t.Fatalf("capture %s: code=%d body=%s", word, rec.Code, rec.Body)
|
||||
}
|
||||
var w Word
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &w); err != nil {
|
||||
t.Fatalf("decode %s: %v", word, err)
|
||||
}
|
||||
return w
|
||||
}
|
||||
|
||||
if got := capture("comum", `{"word":"comum","doc_id":"`+pairDoc+`"}`); got.Lang != "pair" {
|
||||
t.Fatalf("word from a flipped document: lang=%q, want pair", got.Lang)
|
||||
}
|
||||
if got := capture("reception", `{"word":"reception","doc_id":"`+enDoc+`"}`); got.Lang != "en" {
|
||||
t.Fatalf("word from an English document: lang=%q, want en", got.Lang)
|
||||
}
|
||||
if got := capture("orphan", `{"word":"orphan"}`); got.Lang != "" {
|
||||
t.Fatalf("word with no document: lang=%q, want empty", got.Lang)
|
||||
}
|
||||
|
||||
// A client that names a language is ignored: the document is the authority,
|
||||
// the same way runPass never lets the request pick its own target.
|
||||
if got := capture("comum", `{"word":"comum","lang":"en","doc_id":"`+pairDoc+`"}`); got.Lang != "pair" {
|
||||
t.Fatalf("client-supplied lang should not win: lang=%q, want pair", got.Lang)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRecaptureOutsideADocumentKeepsItsLanguage pins the one asymmetry in the
|
||||
// upsert. Re-looking-up a word refreshes its context, but a lookup made with no
|
||||
// document carries no verdict — and relabelling a Portuguese card English
|
||||
// because she checked the word again from the search box would silently move it
|
||||
// to the wrong voice. lang travels with doc_id, or it doesn't travel.
|
||||
func TestRecaptureOutsideADocumentKeepsItsLanguage(t *testing.T) {
|
||||
srv, database := newTestServer(t)
|
||||
var docID string
|
||||
if err := database.QueryRow(
|
||||
`INSERT INTO documents (user_id, content_text, doc_lang) VALUES (?, 'olá', 'pair') RETURNING id`,
|
||||
db.LocalUserID,
|
||||
).Scan(&docID); err != nil {
|
||||
t.Fatalf("seed doc: %v", err)
|
||||
}
|
||||
if rec := do(t, srv, http.MethodPost, "/vocab",
|
||||
`{"word":"saudade","gloss":"","doc_id":"`+docID+`"}`); rec.Code != http.StatusCreated {
|
||||
t.Fatalf("capture: code=%d body=%s", rec.Code, rec.Body)
|
||||
}
|
||||
rec := do(t, srv, http.MethodPost, "/vocab", `{"word":"saudade","gloss":"longing"}`)
|
||||
if rec.Code != http.StatusCreated {
|
||||
t.Fatalf("recapture: code=%d body=%s", rec.Code, rec.Body)
|
||||
}
|
||||
var w Word
|
||||
_ = json.Unmarshal(rec.Body.Bytes(), &w)
|
||||
if w.Lang != "pair" {
|
||||
t.Fatalf("recapture outside a document: lang=%q, want pair held", w.Lang)
|
||||
}
|
||||
if w.Gloss != "longing" {
|
||||
t.Fatalf("recapture should still refresh the gloss, got %q", w.Gloss)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -28,6 +28,10 @@ type Phrase struct {
|
||||
Meaning string // why it's better — the suggestion's explanation
|
||||
Example string // the sentence she met it in, already corrected
|
||||
DocID *string // where, so "where did I see this?" stays one tap
|
||||
// Lang is the document's verdict ('' | 'en' | 'pair'), because the chunk is
|
||||
// lifted out of her own prose and is therefore in whatever language that
|
||||
// prose is. See migration 0018.
|
||||
Lang string
|
||||
}
|
||||
|
||||
// Phrase-card caps. A collocation is a short chunk; anything longer is a
|
||||
@@ -84,13 +88,14 @@ func Plant(ex Execer, userID string, p Phrase) (bool, error) {
|
||||
return false, nil
|
||||
}
|
||||
res, err := ex.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 NOTHING`,
|
||||
userID, key,
|
||||
clamp(strings.TrimSpace(p.Meaning), maxDefinitionLen),
|
||||
clamp(strings.TrimSpace(p.Example), maxExampleLen),
|
||||
p.DocID,
|
||||
p.Lang,
|
||||
)
|
||||
if err != nil {
|
||||
return false, err
|
||||
|
||||
Reference in New Issue
Block a user