diff --git a/BUILD_PLAN.md b/BUILD_PLAN.md index aa56ffb..2ae7041 100644 --- a/BUILD_PLAN.md +++ b/BUILD_PLAN.md @@ -257,8 +257,25 @@ Phase 20 left this ready: `dict.db` on the VPS now holds all five languages, and ### Phase 22 — Learning loop + code-first layers Each item independent and small; order within is free (SUGGESTIONS §5–§6). -- [ ] **Growth journal** (Q3 settled): local aggregation over accepted suggestions; growth-only, self-comparison-only framing; feeds companion cheers -- [ ] **Plant accepted collocations** in the vocabulary garden as phrase cards (scheduler unchanged) +**First two built 2026-07-27** (user: "continue the build plan"; code only, no VPS work — not deployed, and there is no migration to undo, so it is a rebuild whenever the user wants it). +- [x] **Growth journal** (Q3 settled) ✅ (2026-07-27) — `GET /api/suggestions/growth`, a read-side view of a table Petal already keeps: no new capture, no model call, nothing leaves the box. Three signals, and the work was in deciding which ones are *honest* rather than in computing them. + - **Kept** — edits she took on board in the last 30 days, with the 30 before it offered flat beside it. That second number is the whole of the self-comparison rule: there is no target, no average and no other account anywhere in these queries. + - **Stuck** — accepted phrasing that now appears in **two or more** of her own documents. One document is not evidence: it is the edit itself, still sitting where it was applied. The second is her reaching for the phrase on her own, which is the only thing the line actually claims. Candidate phrases are filtered through `vocab.PhraseKey`, the *same* definition of "a learnable chunk" the garden plants, so the journal and the garden can never disagree about what counts. + - **Faded** — a pattern corrected ≥2× in the earlier window and not since. **Guarded by "has she written lately?"**: without that check, a month away from Petal is reported back to her as progress, which is the one way this feature could lie. Test named for the guard, not the query. + - **The dates had to come from her decision, not the model's proposal** — migration `0012_suggestion_resolved_at`. `created_at` is when a checkpoint *offered* an edit; a suggestion offered in April and accepted in June is June's growth. Existing rows backfill to `created_at`, which is exactly the approximation the journal would otherwise have had to make (and is very nearly right — edits are settled minutes after a checkpoint); pending rows keep NULL, because nothing has been decided. Tested against a database rewound to before the column, since that is the only shape the live box will ever present. + - **Surface**: a second tab *inside* the garden (🌷 Garden / 🌱 Growth) rather than new chrome — same idea seen twice, the garden as objects and the journal as change over time. A review session hides the tabs: mid-flashcard is no moment to be offered a different page. + - **Feeds the companion**, which was the point: on an accept the kitten prefers a line that is true of *her* ("you're using 'make a decision' on your own now! 🌱") over one that would fit anybody — half the time, so it stays a surprise, once per line per session, so personal praise never becomes wallpaper. The journal is fetched on the first accept and **never awaited**: the cheer goes out now, personal or not. + - Copy is bound by the same two rules as the SQL, and a test greps both packs for *error/mistake/wrong/streak/average/erro/errada/错误* — the framing is the feature, and it's the part a future edit would quietly undo. +- [x] **Plant accepted collocations** in the vocabulary garden as phrase cards ✅ (2026-07-27) — scheduler untouched, as predicted: `vocab.Plant` writes the same row `capture` does, so a three-word chunk climbs the SM-2-lite ladder exactly like a looked-up word, blossoms with `reps`, and cloze-blanks in review. The garden now holds both halves of learning — what she sought out, and what she was gently given. + - **Only collocations.** The other families fix *this* sentence (a comma, "their"→"there"); a collocation is the one that hands over something reusable, and reusable is the only thing worth reviewing in a week. + - **What isn't a chunk**: `PhraseKey` rejects single words (that's word choice, and lookup already gardens it), anything over 6 words or 60 runes (a rewritten sentence wearing a collocation's label makes a miserable flashcard), and digit/symbol-only text. The cap counts **runes** — a byte cap would drop Portuguese chunks for being accented. + - **The example is the *corrected* sentence.** The stored `content_text` is still the pre-accept draft (the client applies the replacement in the editor), so the sentence around `original` is extracted and swapped server-side. Otherwise the flashcard would quiz her on the phrasing she had just left behind. + - **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; the worst possible response is to overwrite its first context and reset a schedule it has been climbing. Test asserts the card keeps `interval_days = 7`. + - **Best-effort, always.** Planting runs after the status write and swallows its own errors: accepting an edit is what she asked for, and it must not fail — or feel slower — because a flashcard couldn't be made. A rewrite too long to plant still returns 204. + - Verified live on a throwaway DB (:8099, no dictionary, no LLM): accept → card `make a decision` with example *"I had to make a decision about the job."* bounded to its own sentence, then the journal reporting `kept:1`, `stuck:[{make a decision, docs:2}]` once the phrase appeared in a second document, and a seeded two-month-old pattern surfacing under `faded`. + - Tests: `internal/vocab/plant_test.go` (PhraseKey table incl. rune-vs-byte, plant-once, unplantable is a silent no-op), `internal/suggestions/plant_test.go` (corrected-sentence example, only-collocations, idempotent-and-never-resets, sentence-rewrite skipped without failing the accept), `internal/suggestions/growth_test.go` (both windows, stuck needs a second document, the wrote-recently guard, still-happening excluded, and a per-writer isolation test seeding bob), `internal/db/db_test.go` (the backfill). Frontend: `journalCheers.test.ts` (silent before the fetch lands, once per line, one fetch however often warmed, silent on failure, pack resolved at call time) plus journal assertions in `i18n.test.ts`. + - Verified: go build/vet, `go test ./internal/...` clean, tsc, vite build, vitest 131/131. + - ⚠️ **Not deployed and not seen in a browser.** Same standing gap as Phase 21: no pt-PT account exists, and this was a code-only session. The pt-PT journal copy is part of the pack a native speaker still has not reviewed. - [ ] **Daily writing invitation** from the companion (no streaks, declining is fine) - [ ] **False-friend list** per pair (curated data, WordCard heads-up + gentle flag) - [ ] **Embedded miscollocation list** (code-first under the collocation family; LLM adds the long tail when reachable) diff --git a/internal/db/db.go b/internal/db/db.go index e82fa52..9fa7583 100644 --- a/internal/db/db.go +++ b/internal/db/db.go @@ -459,6 +459,24 @@ CREATE TABLE personal_words ( created_at DATETIME DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (user_id, lang, word) ); +`, + }, + { + // The growth journal reads the suggestions table as a record of what the + // writer has been learning, and that reading only works if a row is dated + // by *her decision* rather than by the model's proposal. `created_at` is + // when a checkpoint offered the edit; a suggestion offered in April and + // accepted in June is June's growth, not April's. + // + // Existing rows are backfilled to created_at — which is exactly the + // approximation the journal would have had to make anyway, and is very + // nearly right in practice since edits are settled minutes after a + // checkpoint. Only pending rows keep a NULL: nothing has been decided. + name: "0012_suggestion_resolved_at", + stmt: ` +ALTER TABLE suggestions ADD COLUMN resolved_at DATETIME; +UPDATE suggestions SET resolved_at = created_at WHERE status != 'pending'; +CREATE INDEX idx_suggestions_resolved ON suggestions(status, resolved_at); `, }, } diff --git a/internal/db/db_test.go b/internal/db/db_test.go index 1eb12b8..8a9f6db 100644 --- a/internal/db/db_test.go +++ b/internal/db/db_test.go @@ -83,3 +83,70 @@ func TestOpenMigratesAndSeeds(t *testing.T) { t.Errorf("expected exactly 1 local user after reopen, got %d", users) } } + +// TestResolvedAtBackfill runs migration 0012 against a database that predates +// it, which is the only shape that matters: on the live box the suggestions +// table is years of settled edits with no resolved_at to their name. Backfilling +// to created_at is exactly the approximation the growth journal would otherwise +// have had to make, and a pending row must stay NULL — nothing has been decided. +func TestResolvedAtBackfill(t *testing.T) { + path := filepath.Join(t.TempDir(), "old.db") + d, err := Open(path) + if err != nil { + t.Fatalf("open: %v", err) + } + + // Rewind to the state before 0012: drop the column and forget the migration. + if _, err := d.Exec(`DROP INDEX idx_suggestions_resolved`); err != nil { + t.Fatalf("rewind index: %v", err) + } + if _, err := d.Exec(`ALTER TABLE suggestions DROP COLUMN resolved_at`); err != nil { + t.Fatalf("rewind schema: %v", err) + } + if _, err := d.Exec(`DELETE FROM schema_migrations WHERE name = '0012_suggestion_resolved_at'`); err != nil { + t.Fatalf("rewind migration record: %v", err) + } + if _, err := d.Exec(`INSERT INTO documents (id, user_id) VALUES ('d1', ?)`, LocalUserID); err != nil { + t.Fatalf("insert document: %v", err) + } + for _, s := range []struct{ id, status string }{ + {"s-old", "accepted"}, + {"s-open", "pending"}, + } { + if _, err := d.Exec( + `INSERT INTO suggestions (id, doc_id, from_pos, to_pos, original, replacement, explanation, type, status, created_at) + VALUES (?, 'd1', 0, 3, 'teh', 'the', 'x', 'grammar', ?, '2026-01-02 03:04:05')`, + s.id, s.status, + ); err != nil { + t.Fatalf("seed %s: %v", s.id, err) + } + } + d.Close() + + d2, err := Open(path) + if err != nil { + t.Fatalf("reopen (migrate): %v", err) + } + defer d2.Close() + + // Compared against created_at read back the same way: the driver renders a + // DATETIME column itself, so the assertion is "the same instant", not a + // particular text format. + var settled, created *string + if err := d2.QueryRow( + `SELECT resolved_at, created_at FROM suggestions WHERE id = 's-old'`, + ).Scan(&settled, &created); err != nil { + t.Fatalf("read settled row: %v", err) + } + if settled == nil || created == nil || *settled != *created { + t.Errorf("resolved_at = %v, want it backfilled from created_at (%v)", settled, created) + } + + var pending *string + if err := d2.QueryRow(`SELECT resolved_at FROM suggestions WHERE id = 's-open'`).Scan(&pending); err != nil { + t.Fatalf("read pending row: %v", err) + } + if pending != nil { + t.Errorf("pending row got resolved_at = %v, want NULL — nothing was decided", *pending) + } +} diff --git a/internal/suggestions/growth.go b/internal/suggestions/growth.go new file mode 100644 index 0000000..608da09 --- /dev/null +++ b/internal/suggestions/growth.go @@ -0,0 +1,230 @@ +package suggestions + +import ( + "net/http" + + "gitea.parodia.dev/drwily/petal/internal/auth" + "gitea.parodia.dev/drwily/petal/internal/httputil" + "gitea.parodia.dev/drwily/petal/internal/vocab" +) + +// The growth journal. +// +// The suggestions table already records everything this needs — it is purely a +// read-side view, with no new capture and no model call. Two framing rules +// decide what may appear here, and they are enforced in the SQL rather than left +// to the copy: +// +// 1. It reports growth, never an error tally. Nothing counts what she got +// wrong this month; the signals are things that *stopped* happening and +// phrasing that *stuck*. +// 2. It only ever compares the writer to her own past self. There is no +// target, no average, no other user anywhere in these queries. +// +// A quiet month is quiet: every signal below is omitted rather than softened +// when the data isn't there, because an invented milestone is worse than none. + +// Journal is one writer's growth over the recent windows. +type Journal struct { + // Kept / KeptBefore are edits she took on board in the last 30 days and in + // the 30 before that — her own past self, the only comparison offered. + Kept int `json:"kept"` + KeptBefore int `json:"kept_before"` + // Stuck: phrasing she was given that now turns up across her own writing. + Stuck []Chunk `json:"stuck"` + // Faded: things she used to need fixing and hasn't, recently. + Faded []Fade `json:"faded"` +} + +// Chunk is a phrase that has stuck: it appears in Docs of her documents now. +type Chunk struct { + Phrase string `json:"phrase"` + Docs int `json:"docs"` +} + +// Fade is a pattern that has stopped appearing. Times is how often it came up +// during the earlier window — context for "and not since", never a scoreboard. +type Fade struct { + Pattern string `json:"pattern"` + Times int `json:"times"` +} + +// Journal windows, in days. `recent` is the month being reported on; `history` +// reaches back far enough that a pattern's absence means something (one quiet +// fortnight doesn't). +const ( + recentDays = 30 + historyDays = 120 + maxSignals = 3 // per list: a journal is a couple of warm lines, not a report +) + +// growth serves GET /api/suggestions/growth. +func (h *Handler) growth(w http.ResponseWriter, r *http.Request) { + userID := auth.UserID(r.Context()) + j := Journal{Stuck: []Chunk{}, Faded: []Fade{}} + + err := h.DB.QueryRow( + `SELECT + sum(CASE WHEN s.resolved_at >= datetime('now', '-30 days') THEN 1 ELSE 0 END), + sum(CASE WHEN s.resolved_at < datetime('now', '-30 days') + AND s.resolved_at >= datetime('now', '-60 days') THEN 1 ELSE 0 END) + FROM suggestions s JOIN documents d ON d.id = s.doc_id + WHERE d.user_id = ? AND s.status = 'accepted' AND s.resolved_at IS NOT NULL`, + userID, + ).Scan(&nullInt{&j.Kept}, &nullInt{&j.KeptBefore}) + if err != nil { + httputil.ServerError(w, err) + return + } + + stuck, err := h.stuck(userID) + if err != nil { + httputil.ServerError(w, err) + return + } + j.Stuck = stuck + + faded, err := h.faded(userID) + if err != nil { + httputil.ServerError(w, err) + return + } + j.Faded = faded + + httputil.WriteJSON(w, http.StatusOK, j) +} + +// stuck finds accepted phrasing that now appears in more than one of her own +// documents. One document is just the edit itself, still sitting where it was +// applied; a second is her reaching for the phrase on her own, which is the +// whole claim the line makes. +func (h *Handler) stuck(userID string) ([]Chunk, error) { + rows, err := h.DB.Query( + `SELECT DISTINCT s.replacement + FROM suggestions s JOIN documents d ON d.id = s.doc_id + WHERE d.user_id = ? AND s.status = 'accepted' + AND s.resolved_at >= datetime('now', '-120 days') + AND trim(s.replacement) != '' + ORDER BY s.resolved_at DESC + LIMIT 40`, + userID, + ) + if err != nil { + return nil, err + } + defer rows.Close() + + // vocab.PhraseKey is the same definition of "a learnable chunk" the garden + // plants, so the journal and the garden can never disagree about what counts. + var phrases []string + for rows.Next() { + var replacement string + if err := rows.Scan(&replacement); err != nil { + return nil, err + } + if key := vocab.PhraseKey(replacement); key != "" { + phrases = append(phrases, key) + } + } + if err := rows.Err(); err != nil { + return nil, err + } + + out := []Chunk{} + for _, p := range phrases { + var docs int + if err := h.DB.QueryRow( + `SELECT count(*) FROM documents WHERE user_id = ? AND instr(lower(content_text), ?) > 0`, + userID, p, + ).Scan(&docs); err != nil { + return nil, err + } + if docs >= 2 { + out = append(out, Chunk{Phrase: p, Docs: docs}) + } + } + sortDesc(out, func(c Chunk) int { return c.Docs }) + return trim(out, maxSignals), nil +} + +// faded finds patterns she used to be corrected on during the earlier part of +// the history window and hasn't been since. +// +// The guard that makes this honest: it says nothing at all unless she has +// actually been writing lately. Without it, a month away from Petal would be +// reported back to her as progress, which is the one way this feature could lie. +func (h *Handler) faded(userID string) ([]Fade, error) { + var wroteRecently int + if err := h.DB.QueryRow( + `SELECT count(*) FROM suggestions s JOIN documents d ON d.id = s.doc_id + WHERE d.user_id = ? AND s.resolved_at >= datetime('now', '-30 days')`, + userID, + ).Scan(&wroteRecently); err != nil { + return nil, err + } + if wroteRecently == 0 { + return []Fade{}, nil + } + + rows, err := h.DB.Query( + `SELECT lower(trim(s.original)) AS pattern, count(*) AS times + FROM suggestions s JOIN documents d ON d.id = s.doc_id + WHERE d.user_id = ? AND s.status = 'accepted' + AND s.resolved_at < datetime('now', '-30 days') + AND s.resolved_at >= datetime('now', '-120 days') + AND trim(s.original) != '' + AND pattern NOT IN ( + SELECT lower(trim(s2.original)) + FROM suggestions s2 JOIN documents d2 ON d2.id = s2.doc_id + WHERE d2.user_id = ? AND s2.status = 'accepted' + AND s2.resolved_at >= datetime('now', '-30 days')) + GROUP BY pattern + HAVING times >= 2 + ORDER BY times DESC + LIMIT 3`, + userID, userID, + ) + if err != nil { + return nil, err + } + defer rows.Close() + + out := []Fade{} + for rows.Next() { + var f Fade + if err := rows.Scan(&f.Pattern, &f.Times); err != nil { + return nil, err + } + out = append(out, f) + } + return out, rows.Err() +} + +// nullInt scans a possibly-NULL aggregate into an int (SUM over no rows is +// NULL, which is a zero here, not an error). +type nullInt struct{ dst *int } + +func (n *nullInt) Scan(v any) error { + switch t := v.(type) { + case int64: + *n.dst = int(t) + case nil: + *n.dst = 0 + } + return nil +} + +func sortDesc[T any](s []T, key func(T) int) { + for i := 1; i < len(s); i++ { + for j := i; j > 0 && key(s[j]) > key(s[j-1]); j-- { + s[j], s[j-1] = s[j-1], s[j] + } + } +} + +func trim[T any](s []T, n int) []T { + if len(s) > n { + return s[:n] + } + return s +} diff --git a/internal/suggestions/growth_test.go b/internal/suggestions/growth_test.go new file mode 100644 index 0000000..b9b47b0 --- /dev/null +++ b/internal/suggestions/growth_test.go @@ -0,0 +1,156 @@ +package suggestions + +import ( + "encoding/json" + "net/http" + "strconv" + "testing" + + "gitea.parodia.dev/drwily/petal/internal/db" +) + +// resolved seeds one already-settled suggestion, dated `daysAgo` at the moment +// she decided it (the journal reads decisions, not proposals). +func resolved(t *testing.T, h *Handler, docID, status, original, replacement string, daysAgo int) { + t.Helper() + _, err := h.DB.Exec( + `INSERT INTO suggestions (doc_id, from_pos, to_pos, original, replacement, explanation, type, status, created_at, resolved_at) + VALUES (?, 0, 0, ?, ?, '', 'collocation', ?, datetime('now', ?), datetime('now', ?))`, + docID, original, replacement, status, + "-"+strconv.Itoa(daysAgo)+" days", "-"+strconv.Itoa(daysAgo)+" days", + ) + if err != nil { + t.Fatalf("seed resolved suggestion: %v", err) + } +} + +func seedDoc(t *testing.T, h *Handler, userID, text string) string { + t.Helper() + var id string + if err := h.DB.QueryRow( + `INSERT INTO documents (user_id, content_text) VALUES (?, ?) RETURNING id`, userID, text, + ).Scan(&id); err != nil { + t.Fatalf("seed doc: %v", err) + } + return id +} + +func readJournal(t *testing.T, srv http.Handler) Journal { + t.Helper() + rec := do(t, srv, http.MethodGet, "/suggestions/growth", "") + if rec.Code != http.StatusOK { + t.Fatalf("growth: got %d, want 200 (body %s)", rec.Code, rec.Body.String()) + } + var j Journal + if err := json.Unmarshal(rec.Body.Bytes(), &j); err != nil { + t.Fatalf("decode journal: %v", err) + } + return j +} + +// TestJournalIsEmptyForANewWriter: nothing to report reports nothing. Empty +// lists, not nulls, so the frontend never has to guess. +func TestJournalIsEmptyForANewWriter(t *testing.T) { + srv, _, _ := newTestServer(t, &stubClient{}) + j := readJournal(t, srv) + if j.Kept != 0 || j.KeptBefore != 0 || len(j.Stuck) != 0 || len(j.Faded) != 0 { + t.Fatalf("new writer got a journal: %+v", j) + } +} + +// TestKeptComparesHerToHerOwnPastSelf. +func TestKeptCountsTwoWindows(t *testing.T) { + srv, docID, h := newTestServer(t, &stubClient{}) + for i := 0; i < 3; i++ { + resolved(t, h, docID, "accepted", "do a decision", "make a decision", 5) + } + resolved(t, h, docID, "accepted", "big rain", "heavy rain", 40) + resolved(t, h, docID, "rejected", "no thanks", "no, thank you", 5) // decisions kept only + resolved(t, h, docID, "accepted", "long ago", "long since", 200) // outside both windows + + j := readJournal(t, srv) + if j.Kept != 3 { + t.Errorf("Kept = %d, want 3", j.Kept) + } + if j.KeptBefore != 1 { + t.Errorf("KeptBefore = %d, want 1", j.KeptBefore) + } +} + +// TestStuckNeedsASecondDocument: a phrase sitting in the one document it was +// applied to has not stuck — it's just the edit, where she left it. A second +// document is her reaching for it herself, which is the claim the line makes. +func TestStuckNeedsASecondDocument(t *testing.T) { + srv, docID, h := newTestServer(t, &stubClient{}) + if _, err := h.DB.Exec(`UPDATE documents SET content_text = ? WHERE id = ?`, + "I had to make a decision.", docID); err != nil { + t.Fatalf("set content: %v", err) + } + resolved(t, h, docID, "accepted", "do a decision", "make a decision", 10) + resolved(t, h, docID, "accepted", "do a photo", "take a photo", 10) + + if j := readJournal(t, srv); len(j.Stuck) != 0 { + t.Fatalf("one document counted as sticking: %+v", j.Stuck) + } + + // She uses it again, elsewhere, on her own. + seedDoc(t, h, db.LocalUserID, "Later I had to Make A Decision about the flat.") + j := readJournal(t, srv) + if len(j.Stuck) != 1 { + t.Fatalf("Stuck = %+v, want just the phrase she reused", j.Stuck) + } + if j.Stuck[0].Phrase != "make a decision" || j.Stuck[0].Docs != 2 { + t.Errorf("Stuck[0] = %+v, want {make a decision 2} (case-insensitive)", j.Stuck[0]) + } +} + +// TestFadedNeedsRecentWriting is the guard that keeps this feature honest: a +// month away from Petal must never be reported back as progress. +func TestFadedNeedsRecentWriting(t *testing.T) { + srv, docID, h := newTestServer(t, &stubClient{}) + resolved(t, h, docID, "accepted", "在 the morning", "in the morning", 60) + resolved(t, h, docID, "accepted", "在 the morning", "in the morning", 55) + + if j := readJournal(t, srv); len(j.Faded) != 0 { + t.Fatalf("silence reported as growth: %+v", j.Faded) + } + + // She has been writing again this month — now the absence means something. + resolved(t, h, docID, "accepted", "big rain", "heavy rain", 3) + j := readJournal(t, srv) + if len(j.Faded) != 1 || j.Faded[0].Pattern != "在 the morning" || j.Faded[0].Times != 2 { + t.Fatalf("Faded = %+v, want the pattern she stopped needing (twice, back then)", j.Faded) + } +} + +// TestFadedExcludesWhatStillHappens: a pattern corrected again this month has +// not faded, however often it came up before. +func TestFadedExcludesWhatStillHappens(t *testing.T) { + srv, docID, h := newTestServer(t, &stubClient{}) + resolved(t, h, docID, "accepted", "在 the morning", "in the morning", 60) + resolved(t, h, docID, "accepted", "在 the morning", "in the morning", 55) + resolved(t, h, docID, "accepted", "在 the morning", "in the morning", 2) + + if j := readJournal(t, srv); len(j.Faded) != 0 { + t.Fatalf("Faded = %+v, want empty — it still happens", j.Faded) + } +} + +// TestJournalIsPerWriter: another account's learning is never anyone else's +// journal, and the only comparison Petal draws is with her own past self. +func TestJournalIsPerWriter(t *testing.T) { + srv, _, h := newTestServer(t, &stubClient{}) + if _, err := h.DB.Exec(`INSERT INTO users (id, email) VALUES ('bob', 'bob@example.com')`); err != nil { + t.Fatalf("seed user: %v", err) + } + bobDoc := seedDoc(t, h, "bob", "Bob had to make a decision.") + seedDoc(t, h, "bob", "Bob will make a decision again.") + resolved(t, h, bobDoc, "accepted", "do a decision", "make a decision", 5) + resolved(t, h, bobDoc, "accepted", "big rain", "heavy rain", 60) + resolved(t, h, bobDoc, "accepted", "big rain", "heavy rain", 55) + + j := readJournal(t, srv) + if j.Kept != 0 || j.KeptBefore != 0 || len(j.Stuck) != 0 || len(j.Faded) != 0 { + t.Fatalf("bob's learning leaked into the local user's journal: %+v", j) + } +} diff --git a/internal/suggestions/handlers.go b/internal/suggestions/handlers.go index 22cc8ca..b3a194f 100644 --- a/internal/suggestions/handlers.go +++ b/internal/suggestions/handlers.go @@ -11,6 +11,7 @@ import ( "encoding/json" "errors" "io" + "log" "net/http" "strings" @@ -20,6 +21,7 @@ import ( "gitea.parodia.dev/drwily/petal/internal/db" "gitea.parodia.dev/drwily/petal/internal/httputil" "gitea.parodia.dev/drwily/petal/internal/llm" + "gitea.parodia.dev/drwily/petal/internal/vocab" ) // Handler holds the dependencies for the checkpoint + suggestion routes. The @@ -60,6 +62,10 @@ func (h *Handler) RegisterDocRoutes(r chi.Router) { // actions. func (h *Handler) Routes() chi.Router { r := chi.NewRouter() + // The growth journal reads the same table these actions write, so it lives + // here rather than growing its own mount. A literal segment, so it can never + // be shadowed by an id. + r.Get("/growth", h.growth) r.Post("/{id}/accept", h.accept) r.Post("/{id}/dismiss", h.dismiss) r.Post("/{id}/chat", h.chat) @@ -577,7 +583,7 @@ func (h *Handler) dismiss(w http.ResponseWriter, r *http.Request) { // no rows and surfaces as a 404. func (h *Handler) setStatus(w http.ResponseWriter, r *http.Request, status string) { res, err := h.DB.Exec( - `UPDATE suggestions SET status = ? + `UPDATE suggestions SET status = ?, resolved_at = datetime('now') WHERE id = ? AND status = ? AND doc_id IN (SELECT id FROM documents WHERE user_id = ?)`, status, chi.URLParam(r, "id"), db.SuggestionStatusPending, @@ -591,9 +597,72 @@ func (h *Handler) setStatus(w http.ResponseWriter, r *http.Request, status strin httputil.ErrorJSON(w, http.StatusNotFound, "pending suggestion not found") return } + if status == db.SuggestionStatusAccepted { + h.plant(chi.URLParam(r, "id"), auth.UserID(r.Context())) + } w.WriteHeader(http.StatusNoContent) } +// plant grows an accepted collocation into a vocabulary-garden phrase card. It +// runs after the status write and swallows its own errors: accepting an edit is +// the thing the writer asked for, and it must not fail — or even feel slower — +// because a flashcard couldn't be made. +// +// Only collocations are planted. The other families correct *this* sentence +// ("their" → "there", a comma, a clearer clause); a collocation is the one that +// hands over a reusable chunk, which is the only thing worth reviewing in a week. +func (h *Handler) plant(id, userID string) { + var s db.Suggestion + var contentText string + err := h.DB.QueryRow( + `SELECT s.type, s.original, s.replacement, s.explanation, s.doc_id, d.content_text + FROM suggestions s JOIN documents d ON d.id = s.doc_id + WHERE s.id = ? AND d.user_id = ?`, + id, userID, + ).Scan(&s.Type, &s.Original, &s.Replacement, &s.Explanation, &s.DocID, &contentText) + if err != nil { + if !errors.Is(err, sql.ErrNoRows) { + log.Printf("suggestions: could not read %s for planting: %v", id, err) + } + return + } + if s.Type != db.SuggestionTypeCollocation || strings.TrimSpace(s.Replacement) == "" { + return + } + // The stored text is still the pre-accept draft — the client applies the + // replacement in the editor. Correct the sentence here so the flashcard + // quizzes the phrasing she is keeping, not the one she just left behind. + docID := s.DocID + if _, err := vocab.Plant(h.DB, userID, vocab.Phrase{ + Text: s.Replacement, + Meaning: s.Explanation, + Example: correctedSentence(contentText, s.Original, s.Replacement), + DocID: &docID, + }); err != nil { + log.Printf("suggestions: could not plant %s: %v", id, err) + } +} + +// correctedSentence returns the sentence of contentText containing original, +// with original swapped for replacement. Returns "" when the original isn't +// found (the draft moved on) — a card with no example still reviews, just +// without the cloze, so there's nothing to fall back to and nothing to guess. +func correctedSentence(contentText, original, replacement string) string { + idx := strings.Index(contentText, original) + if original == "" || idx < 0 { + return "" + } + start := strings.LastIndexAny(contentText[:idx], ".!?\n") + end := strings.IndexAny(contentText[idx+len(original):], ".!?\n") + if end < 0 { + end = len(contentText) + } else { + end += idx + len(original) + 1 // keep the terminator + } + sentence := strings.TrimSpace(contentText[start+1 : end]) + return strings.Replace(sentence, original, replacement, 1) +} + // locate finds the plaintext offsets of original within contentText. Returns // (-1, -1) when not found; the frontend anchors by string regardless, so a miss // here is non-fatal. diff --git a/internal/suggestions/plant_test.go b/internal/suggestions/plant_test.go new file mode 100644 index 0000000..854de5d --- /dev/null +++ b/internal/suggestions/plant_test.go @@ -0,0 +1,186 @@ +package suggestions + +import ( + "net/http" + "testing" + + "gitea.parodia.dev/drwily/petal/internal/db" +) + +// seedSuggestion writes one pending suggestion against the seeded doc, after +// replacing the doc's text so the sentence around `original` is under the test's +// control. +func seedSuggestion(t *testing.T, h *Handler, docID, text, sType, original, replacement, explanation string) string { + t.Helper() + if _, err := h.DB.Exec(`UPDATE documents SET content_text = ? WHERE id = ?`, text, docID); err != nil { + t.Fatalf("set content: %v", err) + } + var id string + err := h.DB.QueryRow( + `INSERT INTO suggestions (doc_id, from_pos, to_pos, original, replacement, explanation, type, status) + VALUES (?, 0, 0, ?, ?, ?, ?, 'pending') RETURNING id`, + docID, original, replacement, explanation, sType, + ).Scan(&id) + if err != nil { + t.Fatalf("seed suggestion: %v", err) + } + return id +} + +type card struct { + word, definition, example string + interval int +} + +func gardenCards(t *testing.T, h *Handler) []card { + t.Helper() + rows, err := h.DB.Query( + `SELECT word, definition, example, interval_days FROM vocab_words WHERE user_id = ? ORDER BY word`, + db.LocalUserID, + ) + if err != nil { + t.Fatalf("read garden: %v", err) + } + defer rows.Close() + var out []card + for rows.Next() { + var c card + if err := rows.Scan(&c.word, &c.definition, &c.example, &c.interval); err != nil { + t.Fatalf("scan: %v", err) + } + out = append(out, c) + } + return out +} + +// TestAcceptedCollocationIsPlanted walks the whole hand-over: a collocation the +// writer accepts becomes a phrase card whose example is the *corrected* +// sentence, so the flashcard quizzes the phrasing she kept. +func TestAcceptedCollocationIsPlanted(t *testing.T) { + srv, docID, h := newTestServer(t, &stubClient{}) + id := seedSuggestion(t, h, docID, + "Yesterday was hard. I had to do a decision about the job. Then I slept.", + db.SuggestionTypeCollocation, "do a decision", "make a decision", + "English pairs “make” with “decision”.") + + if rec := do(t, srv, http.MethodPost, "/suggestions/"+id+"/accept", ""); rec.Code != http.StatusNoContent { + t.Fatalf("accept: got %d, want 204", rec.Code) + } + + cards := gardenCards(t, h) + if len(cards) != 1 { + t.Fatalf("garden has %d cards, want 1: %+v", len(cards), cards) + } + got := cards[0] + if got.word != "make a decision" { + t.Errorf("word = %q, want %q", got.word, "make a decision") + } + if got.example != "I had to make a decision about the job." { + t.Errorf("example = %q — want the corrected sentence, bounded to its own sentence", got.example) + } + if got.definition != "English pairs “make” with “decision”." { + t.Errorf("definition = %q, want the explanation", got.definition) + } + if got.interval != 1 { + t.Errorf("interval_days = %d, want 1 (due tomorrow, like a fresh capture)", got.interval) + } +} + +// TestOnlyCollocationsArePlanted: the other families correct this sentence and +// hand over nothing reusable. A dismissed collocation is not a lesson either. +func TestOnlyCollocationsArePlanted(t *testing.T) { + srv, docID, h := newTestServer(t, &stubClient{}) + + grammar := seedSuggestion(t, h, docID, "I has two apples.", + db.SuggestionTypeGrammar, "I has", "I have", "Subject–verb agreement.") + if rec := do(t, srv, http.MethodPost, "/suggestions/"+grammar+"/accept", ""); rec.Code != http.StatusNoContent { + t.Fatalf("accept grammar: got %d", rec.Code) + } + + dismissed := seedSuggestion(t, h, docID, "We must take a photo of it.", + db.SuggestionTypeCollocation, "do a photo", "take a photo", "Photos are taken.") + if rec := do(t, srv, http.MethodPost, "/suggestions/"+dismissed+"/dismiss", ""); rec.Code != http.StatusNoContent { + t.Fatalf("dismiss: got %d", rec.Code) + } + + if cards := gardenCards(t, h); len(cards) != 0 { + t.Fatalf("garden grew %d card(s) from a grammar fix and a dismissal: %+v", len(cards), cards) + } +} + +// TestPlantingIsIdempotentAndNeverResets: accepting the same chunk again is +// evidence it's still being learned — the worst possible response is to wipe the +// card's first context and the schedule it has been climbing. +func TestPlantingIsIdempotentAndNeverResets(t *testing.T) { + srv, docID, h := newTestServer(t, &stubClient{}) + first := seedSuggestion(t, h, docID, "I had to do a decision.", + db.SuggestionTypeCollocation, "do a decision", "make a decision", "First explanation.") + if rec := do(t, srv, http.MethodPost, "/suggestions/"+first+"/accept", ""); rec.Code != http.StatusNoContent { + t.Fatalf("accept: got %d", rec.Code) + } + // The card climbs a little. + if _, err := h.DB.Exec( + `UPDATE vocab_words SET reps = 3, interval_days = 7 WHERE user_id = ? AND word = 'make a decision'`, + db.LocalUserID, + ); err != nil { + t.Fatalf("advance card: %v", err) + } + + second := seedSuggestion(t, h, docID, "Later I must do a decision again.", + db.SuggestionTypeCollocation, "do a decision", "make a decision", "Second explanation.") + if rec := do(t, srv, http.MethodPost, "/suggestions/"+second+"/accept", ""); rec.Code != http.StatusNoContent { + t.Fatalf("accept again: got %d", rec.Code) + } + + cards := gardenCards(t, h) + if len(cards) != 1 { + t.Fatalf("garden has %d cards, want 1 (one chunk, one card)", len(cards)) + } + if cards[0].definition != "First explanation." { + t.Errorf("definition = %q — the existing card should win", cards[0].definition) + } + if cards[0].example != "I had to make a decision." { + t.Errorf("example = %q — the first context should survive", cards[0].example) + } + if cards[0].interval != 7 { + t.Errorf("interval_days = %d, want 7 — progress must not be reset", cards[0].interval) + } +} + +// TestSentenceRewriteIsNotAPhraseCard: a "collocation" long enough to be a +// rewritten sentence makes a miserable flashcard, so it is dropped rather than +// planted — and the accept still succeeds. +func TestSentenceRewriteIsNotAPhraseCard(t *testing.T) { + srv, docID, h := newTestServer(t, &stubClient{}) + long := "I would like to take this opportunity to thank you for everything" + id := seedSuggestion(t, h, docID, "I want thank you for everything.", + db.SuggestionTypeCollocation, "I want thank you for everything", long, "More natural.") + if rec := do(t, srv, http.MethodPost, "/suggestions/"+id+"/accept", ""); rec.Code != http.StatusNoContent { + t.Fatalf("accept: got %d, want 204 — a skipped card must never fail the accept", rec.Code) + } + if cards := gardenCards(t, h); len(cards) != 0 { + t.Fatalf("planted a sentence as a phrase card: %+v", cards) + } +} + +func TestCorrectedSentence(t *testing.T) { + const text = "One thing. I had to do a decision fast! Another thing." + cases := []struct { + name, original, replacement, want string + }{ + {"bounded to its sentence", "do a decision", "make a decision", "I had to make a decision fast!"}, + {"original no longer present", "do a choice", "make a choice", ""}, + {"empty original", "", "make a decision", ""}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + if got := correctedSentence(text, tc.original, tc.replacement); got != tc.want { + t.Errorf("correctedSentence = %q, want %q", got, tc.want) + } + }) + } + // A document with no terminator at all is one sentence, and still works. + if got := correctedSentence("i had to do a decision", "do a decision", "make a decision"); got != "i had to make a decision" { + t.Errorf("unterminated doc: got %q", got) + } +} diff --git a/internal/vocab/plant.go b/internal/vocab/plant.go new file mode 100644 index 0000000..c04041d --- /dev/null +++ b/internal/vocab/plant.go @@ -0,0 +1,100 @@ +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 +} + +// 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, 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, + ) + if err != nil { + return false, err + } + n, err := res.RowsAffected() + return n > 0, err +} diff --git a/internal/vocab/plant_test.go b/internal/vocab/plant_test.go new file mode 100644 index 0000000..b7679fc --- /dev/null +++ b/internal/vocab/plant_test.go @@ -0,0 +1,74 @@ +package vocab + +import ( + "path/filepath" + "testing" + + "gitea.parodia.dev/drwily/petal/internal/db" +) + +func TestPhraseKey(t *testing.T) { + cases := []struct { + in, want string + }{ + {"make a decision", "make a decision"}, + {"Make A Decision", "make a decision"}, // shares one namespace with lookups + {" make a\ndecision ", "make a decision"}, // the editor's whitespace + {"“make a decision.”", "make a decision"}, // punctuation from the sentence around it + {"can’t afford it", "can’t afford it"}, // inner marks are part of the chunk + {"decision", ""}, // word choice, not a chunk — lookup's job + {"", ""}, // + {"...", ""}, // + {"12 000", ""}, // digits aren't vocabulary + {"a b c d e f g", ""}, // a clause wearing a chunk's label + {"in one’s own good time again", "in one’s own good time again"}, // six words is still a chunk + } + for _, tc := range cases { + if got := PhraseKey(tc.in); got != tc.want { + t.Errorf("PhraseKey(%q) = %q, want %q", tc.in, got, tc.want) + } + } + // The length cap counts runes, not bytes — otherwise a Portuguese chunk well + // inside the limit would be dropped for being accented. + accented := "ãããããã ãããããã ãããããã ãããããã ãããããã" // 34 runes, 64 bytes + if got := PhraseKey(accented); got != accented { + t.Errorf("PhraseKey(%d runes / %d bytes) = %q, want it kept", len([]rune(accented)), len(accented), got) + } + long := "ãããããããããããã ãããããããããããã ãããããããããããã ãããããããããããã ãããããããããããã ãããããããããããã" + if got := PhraseKey(long); got != "" { + t.Errorf("PhraseKey(%d runes) = %q, want \"\"", len([]rune(long)), got) + } +} + +func TestPlantCreatesOnceAndReportsIt(t *testing.T) { + database, err := db.Open(filepath.Join(t.TempDir(), "test.db")) + if err != nil { + t.Fatalf("open db: %v", err) + } + t.Cleanup(func() { database.Close() }) + + p := Phrase{Text: "make a decision", Meaning: "why", Example: "I had to make a decision."} + created, err := Plant(database, db.LocalUserID, p) + if err != nil || !created { + t.Fatalf("first plant: created=%v err=%v", created, err) + } + created, err = Plant(database, db.LocalUserID, p) + if err != nil || created { + t.Fatalf("second plant: created=%v err=%v, want false", created, err) + } + + // A card that isn't plantable is a silent no-op, not an error: planting hangs + // off accepting an edit, and that accept must never fail for a flashcard. + created, err = Plant(database, db.LocalUserID, Phrase{Text: "decision"}) + if err != nil || created { + t.Fatalf("unplantable: created=%v err=%v", created, err) + } + + var n int + if err := database.QueryRow(`SELECT count(*) FROM vocab_words WHERE user_id = ?`, db.LocalUserID).Scan(&n); err != nil { + t.Fatalf("count: %v", err) + } + if n != 1 { + t.Fatalf("garden has %d cards, want 1", n) + } +} diff --git a/web/src/api/client.ts b/web/src/api/client.ts index c29a41d..07f4c9d 100644 --- a/web/src/api/client.ts +++ b/web/src/api/client.ts @@ -160,6 +160,19 @@ export interface Suggestion { created_at: string } +// The growth journal (GET /api/suggestions/growth). `kept`/`kept_before` are +// the last thirty days and the thirty before them — the only comparison Petal +// draws is with her own past self. `stuck` is phrasing she was given that now +// turns up across her own documents; `faded` is what she used to be corrected +// on and hasn't been lately. Both lists are empty when the data isn't there: +// nothing here is padded to fill a page. +export interface GrowthJournal { + kept: number + kept_before: number + stuck: { phrase: string; docs: number }[] + faded: { pattern: string; times: number }[] +} + // A deterministic, rule-based fix detected client-side (see Companion/prose.ts). // The frontend owns mechanics detection; the backend only persists these as the // 'mechanics' suggestion family. Spans are exact plaintext offsets. @@ -267,6 +280,10 @@ export const api = { // opening bubble (the explanation itself stays English in the card body). translateSuggestion: (id: string) => req<{ translation: string }>(`/suggestions/${id}/translate`, { method: 'POST' }), + // The growth journal: her own accepted edits read back as patterns. Purely a + // read-side view of a table Petal already keeps, computed locally with no + // model call, so it costs nothing and leaves nothing. + growth: () => req('/suggestions/growth'), // Version history. listVersions returns metadata only (no bodies); getVersion // loads one full snapshot for preview; snapshotDoc takes an explicit restore diff --git a/web/src/components/Companion/journalCheers.test.ts b/web/src/components/Companion/journalCheers.test.ts new file mode 100644 index 0000000..a4206c6 --- /dev/null +++ b/web/src/components/Companion/journalCheers.test.ts @@ -0,0 +1,76 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const growth = vi.fn() +vi.mock('../../api/client', () => ({ api: { growth: () => growth() } })) + +import { personalCheer, resetPersonalCheersForTests, warmPersonalCheers } from './journalCheers' +import { resetPackForTests, setPackLang } from '../../i18n' + +const journal = { + kept: 4, + kept_before: 2, + stuck: [{ phrase: 'make a decision', docs: 3 }], + faded: [{ pattern: '在 the morning', times: 2 }], +} + +// Let the warm-up promise settle. +const settle = () => new Promise((r) => setTimeout(r, 0)) + +describe('personal cheers', () => { + beforeEach(() => { + resetPersonalCheersForTests() + resetPackForTests() + growth.mockReset() + }) + + it('says nothing before the journal has arrived — the cheer never waits', () => { + growth.mockResolvedValue(journal) + warmPersonalCheers() + expect(personalCheer()).toBeNull() + }) + + it('serves each personal line once, then falls silent', async () => { + growth.mockResolvedValue(journal) + warmPersonalCheers() + await settle() + + const first = personalCheer() + const second = personalCheer() + expect(first).not.toBeNull() + expect(second).not.toBeNull() + expect(first!.en).not.toBe(second!.en) + // Both lines used: personal praise repeated is wallpaper, so the caller is + // handed back to the generic pool instead. + expect(personalCheer()).toBeNull() + }) + + it('fetches once however often it is warmed', async () => { + growth.mockResolvedValue(journal) + warmPersonalCheers() + warmPersonalCheers() + await settle() + warmPersonalCheers() + expect(growth).toHaveBeenCalledTimes(1) + }) + + it('is silent when the journal fails, rather than failing visibly', async () => { + growth.mockRejectedValue(new Error('offline')) + warmPersonalCheers() + await settle() + expect(personalCheer()).toBeNull() + }) + + it('speaks the pair the writer is in, resolved at call time', async () => { + growth.mockResolvedValue({ ...journal, faded: [] }) + warmPersonalCheers() + await settle() + + setPackLang('pt-PT') + const line = personalCheer() + expect(line).not.toBeNull() + expect(line!.native).toContain('make a decision') + expect(line!.native).not.toBe(line!.en) + // The English half is the same sentence in every pack. + expect(line!.en).toContain('make a decision') + }) +}) diff --git a/web/src/components/Companion/journalCheers.ts b/web/src/components/Companion/journalCheers.ts new file mode 100644 index 0000000..a45ce4b --- /dev/null +++ b/web/src/components/Companion/journalCheers.ts @@ -0,0 +1,62 @@ +// Personal material for the companion, drawn from the growth journal. +// +// The kitten's cheers are warm but generic — they'd be the same words for +// anybody. The journal already knows things that are true of *this* writer and +// nobody else ("you're using 'make a decision' on your own now"), and that is a +// far better thing to hear after accepting an edit. §5a's own example. +// +// Three rules keep it from wearing out: +// * A given line is served once per session. Personal praise repeated is +// wallpaper, and wallpaper is worse than the generic cheer it replaced. +// * The journal is fetched lazily, on the first accept, and never awaited — +// the first cheer of a session is generic, and that's fine. +// * Lines are built at call time from the pack, like every other companion +// line, so a bubble composed after /api/me is in the right language. + +import { api, type GrowthJournal } from '../../api/client' +import { pack, type Line } from '../../i18n' + +let journal: GrowthJournal | null = null +let inFlight = false +let served = new Set() + +// warmPersonalCheers starts the one fetch this module ever needs. Safe to call +// often; a failure is silent and simply leaves the companion with its generic +// pool, which is exactly the pre-journal behaviour. +export function warmPersonalCheers(): void { + if (journal || inFlight) return + inFlight = true + api + .growth() + .then((j) => { + journal = j + }) + .catch(() => { + /* no journal, no personal cheer — never a visible failure */ + }) + .finally(() => { + inFlight = false + }) +} + +// personalCheer returns an unserved line about her own writing, or null when +// there is none — the caller falls back to the generic pool. +export function personalCheer(): Line | null { + if (!journal) return null + const t = pack() + const candidates = [ + ...journal.stuck.map((s) => ({ key: `stuck:${s.phrase}`, line: () => t.journal.cheerStuck(s.phrase) })), + ...journal.faded.map((f) => ({ key: `faded:${f.pattern}`, line: () => t.journal.cheerFaded(f.pattern) })), + ].filter((c) => !served.has(c.key)) + if (candidates.length === 0) return null + const chosen = candidates[Math.floor(Math.random() * candidates.length)] + served.add(chosen.key) + return chosen.line() +} + +// Test seam, matching resetPackForTests: module state is per-session by design. +export function resetPersonalCheersForTests(): void { + journal = null + inFlight = false + served = new Set() +} diff --git a/web/src/components/Companion/useCompanion.ts b/web/src/components/Companion/useCompanion.ts index 8a8e63b..f19dcc0 100644 --- a/web/src/components/Companion/useCompanion.ts +++ b/web/src/components/Companion/useCompanion.ts @@ -14,6 +14,7 @@ import { type Line, } from './tips' import { analyzeProse } from './prose' +import { personalCheer, warmPersonalCheers } from './journalCheers' import { playPop, playSound, type SoundName } from '../../audio/sounds' import { isBedtime } from '../../lib/night' @@ -197,7 +198,13 @@ export function useCompanion({ wordCount, saveStatus, llmDown, editTick, acceptT firstAccept.current = false return } - say({ ...pick(encouragements()), tone: 'cheer' }, { celebrate: true }) + // Prefer something true of her own writing over a line that would fit + // anybody — but only sometimes, so the personal ones stay a small surprise + // rather than the new default. The journal is fetched on this first accept + // and never awaited: the cheer goes out now, personal or not. + warmPersonalCheers() + const personal = Math.random() < 0.5 ? personalCheer() : null + say({ ...(personal ?? pick(encouragements())), tone: 'cheer' }, { celebrate: true }) // eslint-disable-next-line react-hooks/exhaustive-deps }, [acceptTick]) diff --git a/web/src/components/Garden/GardenPanel.tsx b/web/src/components/Garden/GardenPanel.tsx index 05a53a1..f2ac745 100644 --- a/web/src/components/Garden/GardenPanel.tsx +++ b/web/src/components/Garden/GardenPanel.tsx @@ -3,6 +3,7 @@ import { api, type VocabGrade, type VocabWord } from '../../api/client' import { speak, speechSupported, stopSpeech } from '../../audio/speech' import { useFocusTrap } from '../../hooks/useFocusTrap' import { usePack, type Line } from '../../i18n' +import { JournalView } from './JournalView' // GardenPanel is the vocabulary garden: every word the writer has looked up, // grown into a blossom that opens further the more she remembers it, plus a @@ -51,6 +52,7 @@ export function GardenPanel({ onClose, onOpenDoc }: Props) { const [cursor, setCursor] = useState(0) const [revealed, setRevealed] = useState(false) const [expanded, setExpanded] = useState(null) + const [tab, setTab] = useState<'garden' | 'journal'>('garden') const panelRef = useFocusTrap() // Read-aloud is fire-and-forget, so a word she tapped could still be speaking @@ -155,7 +157,7 @@ export function GardenPanel({ onClose, onOpenDoc }: Props) {
{t.garden.titleWithFlower}
- {queue ? t.garden.reviewing : t.garden.subtitle} + {queue ? t.garden.reviewing : tab === 'journal' ? t.journal.subtitle : t.garden.subtitle}
+ ))} + + )} + {queue ? ( setQueue(null)} /> + ) : tab === 'journal' ? ( + ) : ( (null) + const [error, setError] = useState(false) + + useEffect(() => { + let live = true + api + .growth() + .then((j) => live && setJournal(j)) + .catch(() => live && setError(true)) + return () => { + live = false + } + }, []) + + if (error) { + return ( + +

+ Couldn’t read your journal just now. +

+
+ ) + } + if (!journal) { + return ( + +

+ Loading… +

+
+ ) + } + + const nothingYet = journal.kept === 0 && journal.stuck.length === 0 && journal.faded.length === 0 + if (nothingYet) { + return ( + +
+
🌱🐱💤
+

{t.journal.empty}

+
+
+ ) + } + + return ( + +
+ {journal.kept > 0 && ( +
+

{t.journal.kept(journal.kept)}

+ {journal.kept_before > 0 && ( +

+ {t.journal.keptBefore(journal.kept_before)} +

+ )} +
+ )} + + {journal.stuck.length > 0 && ( +
+
    + {journal.stuck.map((s) => ( +
  • + 🌸 + {t.journal.stuck(s.phrase, s.docs)} +
  • + ))} +
+
+ )} + + {journal.faded.length > 0 && ( +
+
    + {journal.faded.map((f) => ( +
  • + 🌿 + {t.journal.faded(f.pattern, f.times)} +
  • + ))} +
+
+ )} +
+
+ ) +} + +function Wrap({ children }: { children: React.ReactNode }) { + return
{children}
+} + +function Section({ head, children }: { head: string; children: React.ReactNode }) { + return ( +
+

+ {head} +

+ {children} +
+ ) +} diff --git a/web/src/i18n/i18n.test.ts b/web/src/i18n/i18n.test.ts index 5b4cb39..b628054 100644 --- a/web/src/i18n/i18n.test.ts +++ b/web/src/i18n/i18n.test.ts @@ -97,6 +97,9 @@ describe('the zh pack', () => { expect(zh.garden.growing(2)).toContain('2 blossoms growing') expect(zh.history.daysAgo(1)).toBe('1 day ago · 1 天前') expect(zh.history.daysAgo(3)).toBe('3 days ago · 3 天前') + expect(zh.journal.kept(1)).toContain('1 thing you took on board') + expect(zh.journal.kept(9)).toContain('9 things you took on board') + expect(zh.journal.stuck('make a decision', 3)).toContain('3 of your pieces') }) // A pack with a hole in it renders an empty label rather than failing, which @@ -194,6 +197,20 @@ describe('the pt-PT pack', () => { expect(ptPT.garden.reviewDue(4)).toContain('4 palavras ·') expect(ptPT.garden.growing(1)).toContain('1 flor no jardim') expect(ptPT.garden.growing(3)).toContain('3 flores no jardim') + expect(ptPT.journal.kept(1)).toContain('1 coisa que') + expect(ptPT.journal.kept(5)).toContain('5 coisas que') + }) + + // The growth journal is the one surface that talks about her progress, so it + // is the one most easily spoiled by a stray comparison. The rule is enforced + // in SQL on the backend; here it is enforced in the copy. + it('keeps the journal to growth and to her own past self', () => { + const text = JSON.stringify({ zh: zh.journal, pt: ptPT.journal }, (_k, v) => + typeof v === 'function' ? JSON.stringify(v(2, 3)) : v, + ) + for (const bad of ['error', 'mistake', 'wrong', 'streak', 'average', 'erro', 'errada', '错误']) { + expect(text.toLowerCase(), `the journal must not talk about "${bad}"`).not.toContain(bad) + } }) it('says the collision line the zh pair never needed', () => { diff --git a/web/src/i18n/packs/pt-PT.ts b/web/src/i18n/packs/pt-PT.ts index 705c18d..7f34a2b 100644 --- a/web/src/i18n/packs/pt-PT.ts +++ b/web/src/i18n/packs/pt-PT.ts @@ -258,6 +258,30 @@ export const ptPT: Pack = { gradeEasy: { native: 'Fácil', en: 'Easy' }, }, + journal: { + tabGarden: '🌷 Jardim · Garden', + tabJournal: '🌱 Progresso · Growth', + subtitle: 'Your own writing, month by month — only ever you and your past self', + empty: 'Escreve mais um pouco — esta página nasce do teu próprio trabalho. · Keep writing; this page grows out of your own work.', + keptHead: 'Este mês · This month', + kept: (n) => `${n} coisa${n === 1 ? '' : 's'} que aproveitaste · ${n} thing${n === 1 ? '' : 's'} you took on board`, + keptBefore: (n) => `${n} no mês anterior · ${n} the month before`, + stuckHead: 'Ficou contigo · Stayed with you', + stuck: (phrase, docs) => + `«${phrase}» — já a usas sozinha, em ${docs} textos teus · now in ${docs} of your pieces`, + fadedHead: 'Já não precisas de corrigir · You stopped needing this', + faded: (pattern, times) => + `«${pattern}» — ${times}× nessa altura, nenhuma este mês · ${times}× back then, none this month`, + cheerStuck: (phrase) => ({ + native: `Já escreves «${phrase}» sozinha! 🌱`, + en: `You’re using “${phrase}” on your own now! 🌱`, + }), + cheerFaded: (pattern) => ({ + native: `Há já algum tempo que «${pattern}» não precisa de correção 😌`, + en: `“${pattern}” hasn’t needed fixing in a while 😌`, + }), + }, + history: { title: 'Histórico · History', kinds: { diff --git a/web/src/i18n/packs/zh.ts b/web/src/i18n/packs/zh.ts index 2178dde..b93c45d 100644 --- a/web/src/i18n/packs/zh.ts +++ b/web/src/i18n/packs/zh.ts @@ -244,6 +244,29 @@ export const zh: Pack = { gradeEasy: { native: '太简单', en: 'Easy' }, }, + journal: { + tabGarden: '🌷 花园 · Garden', + tabJournal: '🌱 成长 · Growth', + subtitle: 'Your own writing, month by month — only ever you and your past self', + empty: '再写一阵子,这里就会长出东西来。· Keep writing — this page grows out of your own work.', + keptHead: '这个月 · This month', + kept: (n) => `你采纳了 ${n} 处建议 · ${n} thing${n === 1 ? '' : 's'} you took on board`, + keptBefore: (n) => `上个月是 ${n} 处 · ${n} the month before`, + stuckHead: '记住了 · Stayed with you', + stuck: (phrase, docs) => `“${phrase}” — 你后来又自己用了,出现在 ${docs} 篇里 · now in ${docs} of your pieces`, + fadedHead: '不再需要改了 · You stopped needing this', + faded: (pattern, times) => + `“${pattern}” — 以前改过 ${times} 次,这个月一次都没有 · ${times}× back then, none this month`, + cheerStuck: (phrase) => ({ + native: `“${phrase}” 你现在自己就会用了!🌱`, + en: `You’re using “${phrase}” on your own now! 🌱`, + }), + cheerFaded: (pattern) => ({ + native: `好久没见你写错 “${pattern}” 了 😌`, + en: `“${pattern}” hasn’t needed fixing in a while 😌`, + }), + }, + history: { title: '历史 · History', kinds: { diff --git a/web/src/i18n/types.ts b/web/src/i18n/types.ts index f0980b7..8ef52a4 100644 --- a/web/src/i18n/types.ts +++ b/web/src/i18n/types.ts @@ -199,6 +199,34 @@ export interface Pack { gradeEasy: Line } + // The growth journal, a second tab inside the garden: what she has been + // learning, read back out of her own accepted edits. + // + // Every line here is bound by two rules the backend enforces in SQL, and the + // copy must not undo them: it reports growth rather than tallying mistakes, + // and the only writer it ever compares her to is herself. A pack author has + // room to change the warmth and the word order; there is no room for a line + // that grades her, congratulates her on beating anyone, or invents a streak. + journal: { + tabGarden: string + tabJournal: string + subtitle: string + // Nothing to say yet — a quiet month stays quiet rather than being padded. + empty: string + keptHead: string + kept: (n: number) => string + // Last month's number, offered flat: no better, no worse, just her own past. + keptBefore: (n: number) => string + stuckHead: string + stuck: (phrase: string, docs: number) => string + fadedHead: string + faded: (pattern: string, times: number) => string + // The journal's material, handed to the companion. Genuinely personal + // praise beats a generic cheer, which is the whole point of §5a. + cheerStuck: (phrase: string) => Line + cheerFaded: (pattern: string) => Line + } + history: { title: string kinds: Record // manual | auto | pre_restore