Files
petal/internal/vocab/plant.go
T
prosolis 466055020f 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
2026-07-28 23:45:26 -07:00

106 lines
4.0 KiB
Go

package vocab
import (
"database/sql"
"strings"
"unicode"
)
// Planting: the garden's second source.
//
// Capture (handlers.go) records words the writer *sought out*. Planting records
// phrasing she was gently *given* — an accepted collocation like "make a
// decision" is a learnable chunk exactly like a looked-up word, and the SM-2-lite
// scheduler doesn't care that it's three words rather than one. Together the two
// halves make the garden a record of both sides of learning.
//
// Everything here is best-effort by design: planting hangs off accepting a
// suggestion, and that accept must succeed whether or not a card comes of it.
// Execer is the slice of *sql.DB (or *sql.Tx) that planting needs.
type Execer interface {
Exec(query string, args ...any) (sql.Result, error)
}
// Phrase is one chunk to plant.
type Phrase struct {
Text string // the corrected phrasing, e.g. "make a decision"
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
// rewritten sentence wearing a collocation's label, and a sentence makes a
// miserable flashcard. Both bounds are deliberately tight — the cost of
// skipping a real chunk is one missing card, the cost of planting a sentence is
// a garden the writer stops trusting.
const (
maxPhraseRunes = 60
maxPhraseWords = 6
minPhraseWords = 2
)
// PhraseKey normalizes a replacement into a garden key, or returns "" when the
// text isn't a plantable chunk.
//
// Lowercasing matches capture's normalization, so a phrase and a looked-up word
// share one UNIQUE(user_id, word) namespace rather than colliding sideways.
// Single words are rejected on purpose: a one-word fix is word choice, and word
// choice already reaches the garden through lookup — planting it here would give
// it a card with no gloss and no phonetic, which reviews badly.
func PhraseKey(text string) string {
// Collapse all whitespace (a replacement can carry a newline from the
// editor) so the key is stable and the word count is honest.
s := strings.Join(strings.Fields(strings.ToLower(text)), " ")
// Trim the punctuation a phrase picks up from the sentence around it, but
// leave inner marks alone: "can't afford" and "in one's own time" are chunks.
s = strings.Trim(s, `.,;:!?…"'“”‘’()[]`)
s = strings.TrimSpace(s)
if s == "" || len([]rune(s)) > maxPhraseRunes {
return ""
}
n := len(strings.Fields(s))
if n < minPhraseWords || n > maxPhraseWords {
return ""
}
// A chunk of pure digits or symbols ("12 000", "-- --") isn't vocabulary.
if !strings.ContainsFunc(s, unicode.IsLetter) {
return ""
}
return s
}
// Plant adds a phrase card to the garden, due tomorrow like any fresh capture.
// It reports whether a new card was created.
//
// ON CONFLICT DO NOTHING, unlike capture's refresh-the-context upsert: accepting
// the same collocation again months later is evidence the chunk is still being
// learned, and the last thing that should do is overwrite the card's first
// context or disturb a schedule it has been climbing. An existing card wins.
func Plant(ex Execer, userID string, p Phrase) (bool, error) {
key := PhraseKey(p.Text)
if key == "" {
return false, nil
}
res, err := ex.Exec(
`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
}
n, err := res.RowsAffected()
return n > 0, err
}