diff --git a/BUILD_PLAN.md b/BUILD_PLAN.md index 2ae7041..0045f78 100644 --- a/BUILD_PLAN.md +++ b/BUILD_PLAN.md @@ -255,9 +255,10 @@ Phase 20 left this ready: `dict.db` on the VPS now holds all five languages, and - **A bug the test found, not the code review**: `extendedAlphabet` was a value computed when the checker was built while `correct`/`suggest` read live. Her dictionary arrives *after* English, so the underlines would have been right while every lookup was still resolving "cora". It is a getter now. - Verified: go build/vet/test, tsc, vite build, vitest 116/116 clean. The shipped asset loaded in a real nspell (842 ms, 139 MB, pt-PT variants correct both ways). Live smoke on a throwaway DB (:8091): both dictionary files served (577 B aff, 2,661,813 B gz), the gz inflating to 1,039,058 forms with `receção` present, and the zh word lookup unchanged. **Not verified against real data**: this laptop has no `dict.db`, so the reverse-lookup path is exercised by the fixture only — the first real pt-PT collision lookup happens on the VPS. -### Phase 22 — Learning loop + code-first layers +### Phase 22 — Learning loop + code-first layers ✅ (2026-07-27) — the last phase of the plan Each item independent and small; order within is free (SUGGESTIONS §5–§6). **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). +**Remaining four built 2026-07-27** (user: "let's finish the last phase of the build plan"). With them the left-hand column of the SUGGESTIONS §6 table is complete: **spell, define, gloss, pronounce, catch the common mistakes, review vocabulary, prove authorship — every daily-writing need now works on a box with the tunnel down.** The model adds depth and conversation when it is reachable and holds nothing hostage when it isn't. Carries one migration (`0013_suggestion_source`), so unlike the earlier code-only sessions this is a deploy rather than a rebuild. - [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. @@ -276,10 +277,24 @@ Each item independent and small; order within is free (SUGGESTIONS §5–§6). - 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) -- [ ] **Grammar lite** rule-pack as a fourth suggestion family: instant, offline, precision-over-recall (near-certain or silent); per-pair L1-interference rules; sourcing per SUGGESTIONS Q6 (hand-curate vs mine LanguageTool's corpus — decide at build time) +- [x] **Daily writing invitation** from the companion ✅ (2026-07-27) — offered to a *blank page* about a minute into a session, at most once a day. Petal always has a document open, so "a session that starts with no doc open" became "the page in front of her is still empty", which is the state the invitation was actually for. + - **The stored value is a date, and that is the entire mechanism.** No count, no run of days, nothing that degrades with absence: coming back after a month reads exactly like coming back tomorrow. That is the one property this feature could lose silently, so the rule lives in its own file (`invitation.ts`) rather than inside the heartbeat, and the test names it — *treats a month away the same as a day away*. + - **Both answers spend the day's invitation.** Being asked again after "not today" would make no into a negotiation. Declining costs a sleepy `好吧,我继续睡 😴` and nothing else; letting the bubble time out is a third way of saying no. + - **Accepting titles the blank page with the prompt**, so the question she agreed to answer is still in front of her once the bubble has gone. + - Copy is bound the way the journal's is: a test greps both packs for *streak / in a row / every day / missed / 连续 / 打卡 / todos os dias* — the framing is the feature. +- [x] **False-friend list** per pair ✅ (2026-07-27) — ~19 curated en↔pt entries in the pt-PT pack; **zh has none, and that is the honest answer**, not an unwritten one: the trap needs a shared script to spring. + - **Never a correction.** Two surfaces, both heads-up only: a lavender block at the top of the WordCard (above the definition — it is the thing she would not think to check), and at most one companion note per pass. No `fix`, so it never becomes a card. *Actually* may well be the word she meant; the flag says what the English one means and stops. A test greps the entries for *wrong / mistake / errado* — this is the mistake that makes a learner feel foolish, and the tone is the whole point. +- [x] **Embedded miscollocation list** ✅ (2026-07-27) — the do/make, say/tell, heavy-rain families as ten curated patterns, and **they file as `collocation`, not as a new family**. Same rail, same warm phrasing, and — the reason it matters — an accepted chunk plants in the vocabulary garden exactly as the coach's would. The writer never learns which engine spoke. + - **That forced a schema change**: `type` had been doubling as the answer to "which engine found this" (`mechanics` meant offline). The moment an offline rule proposes a collocation that breaks — so migration `0013_suggestion_source` adds `source` (llm | local) and every pass now scopes its DELETE by engine. Without it the coach silently wiped every offline chunk on the page, and the offline pass left the coach's rows to accumulate. Both directions are tested; existing rows backfill by type, and a pre-0013 collocation row is correctly claimed as the coach's, since the offline list did not exist yet. + - **The span tiebreak moved with it**: an exact offline card beats an overlapping LLM one by *source*, not by type — an offline miscollocation is as exact as an offline comma. + - Replacements agree with the tense she wrote in (`did a mistake` → `made a mistake`), and a rule never proposes a phrase identical to what she already wrote. +- [x] **Grammar lite** rule-pack ✅ (2026-07-27) — the deterministic `mechanics` family already *was* the fourth family (Phase 8), so this was the rule pack it had been waiting for rather than new plumbing: preposition pairs, doubled comparatives, `people is`, and per-pair L1 interference. All client-side, instant, no debounce, no rate limit, alive on a VPN-down box. + - **Sourcing decision (SUGGESTIONS Q6): hand-curated, not mined.** LanguageTool's corpus is broad because it aims at recall; this pack aims at the opposite. Every entry here is a pairing that is wrong in essentially *all* contexts, and the ones that are only usually wrong were left out on purpose — `married with` is a mistake until "married with children", `arrive to` wants at or in depending on the noun, `different than` is ordinary American English. Each rule is tested in both directions, and the guard cases are the correct English sitting next to the mistake. + - **L1 rules are gated by pair, and the gating is what lets them be confident**: a near-certainty for a Portuguese speaker is only a guess for anybody else. pt/fr/es get *ter 30 anos* → "I am 30 years old" (subject and tense carried into the correction), "I am agree", "since three years" → "for three years". zh gets 很喜欢 → "very like", 开灯 → "open the light", and 虽然…但是 → "although … but". + - **The zh rules the plan named and this pack does not implement**: dropped articles and he/she slips. Neither is detectable from text alone — "She said he was late" is a perfect sentence whichever pronoun was meant — and flagging them would mean correcting correct writing, which is the one thing a rule pack running on every keystroke must not do. Said in a comment where the rules are, not only here. + - Verified live on a throwaway DB (:8099, no dictionary, **no LLM configured at all**): an offline `did a mistake` → card → accept → garden card *made a mistake* with the example bounded to its own corrected sentence, and the journal reporting `kept:1`. + - Tests: `grammarLite.test.ts` (30, every rule in both directions), `invitation.test.ts` (7), `offline_test.go` (the six engine-split cases), `db_test.go` (the 0013 backfill), plus false-friend shape/tone guards in `i18n.test.ts`. + - ⚠️ **Not deployed and not seen in a browser.** Same standing gap as Phases 21–22: the pt-PT copy added here is part of the pack a native speaker still has not reviewed. ### Later / explicitly not now - Learner-facing Chinese writing (the zh pair's second direction) — own phase with its own spec (SUGGESTIONS §4); only after Phases 19–21 prove the pair model @@ -295,6 +310,7 @@ Each item independent and small; order within is free (SUGGESTIONS §5–§6). - [x] **Phase 14 — companion warmth + bedtime nag + night mode**: more encouraging phrases, a gentle "go to bed" nudge after 11pm, and a calm dark theme + falling stars at night. ✅ (see Phase 14 above) ## Session log +- 2026-07-27: **Phase 22 finished — the build plan's last four items, and the LLM stops holding anything hostage** (user: "let's finish the last phase of the build plan"; code only, no VPS work). The four remaining items shared one theme, and it only became visible while building them: **§6's left-hand column is now complete.** Spell, define, gloss, pronounce, catch the common mistakes, review vocabulary, prove authorship — every daily-writing need works with the tunnel down. **The plan asked for "grammar lite as a fourth suggestion family", and the fourth family already existed**: Phase 8's deterministic `mechanics` pass was the plumbing, so this was the rule pack it had been waiting for rather than new machinery — preposition pairs, doubled comparatives, `people is`, plus per-pair L1 interference. **Q6 answered by hand-curating rather than mining LanguageTool**: that corpus is broad because it aims at recall, and this pack aims at the exact opposite, so every entry is a pairing wrong in essentially *all* contexts and the ones only *usually* wrong were left out on purpose — `married with` is a mistake until "married with children", `arrive to` wants at or in depending on the noun, `different than` is ordinary American English. Each rule is pinned in both directions, the guard case being the correct English next to the mistake. **The L1 rules are gated by pair, and the gating is what earns them their confidence** — *ter 30 anos* → "I am 30 years old" is a near-certainty for a Portuguese writer and only a guess for anyone else. The two zh rules the plan itself named are the ones this pack **refuses** to implement: dropped articles and he/she slips are not detectable from text alone ("She said he was late" is perfect whichever pronoun was meant), and flagging them would mean correcting correct writing. **The miscollocation list forced the session's one real design change.** It had to file as `collocation` rather than as its own family — same rail, same phrasing, and an accepted chunk plants in the garden exactly as the coach's would — but `type` had been quietly doubling as the answer to *which engine found this*, and that breaks the instant an offline rule proposes a collocation. Migration `0013_suggestion_source` splits the two apart: each pass now scopes its DELETE by engine, and the span tiebreak moved with it (an exact offline card beats an overlapping LLM one by source, not by type — an offline miscollocation is as exact as an offline comma). Without it the coach silently wiped every offline chunk on the page and the offline pass left the coach's rows to pile up; both directions are now tested, and a pre-0013 collocation row correctly backfills to the coach, since the offline list did not exist yet. **The daily invitation's whole substance is one stored date** — no count, no run of days, nothing that gets worse for being away, so a month away reads exactly like a day away; it lives in its own file because that is the property this feature would lose silently, and the test is named for it rather than for the query. Both answers spend the day's invitation, because being asked again after "not today" would make no a negotiation. **False friends are the one thing here that never becomes a card**: ~19 curated en↔pt entries, shown as a lavender block above the WordCard's definition and as at most one companion note per pass, with no `fix` anywhere — *actually* may well be the word she meant, and this is the mistake that makes a learner feel foolish rather than merely corrected. zh has none, which is the honest answer and not an unwritten one: the trap needs a shared script. Copy for the invitation and the false friends is greped by tests the same way the journal's is (*streak / in a row / 连续 / todos os dias*; *wrong / mistake / errado*) — the framing is the feature, and it is the part a future edit would undo while meaning well. Verified: go build/vet, `go test ./internal/...` clean, tsc, vite build, vitest 172/172 (30 new rule cases, 7 invitation, plus false-friend shape/tone guards), and a live throwaway DB on :8099 with **no LLM configured at all** — offline `did a mistake` → card → accept → garden card *made a mistake*, example bounded to its own corrected sentence, journal `kept:1`. ⚠️ **Not deployed and not seen in a browser**, and this one carries a migration, so it is a deploy rather than a rebuild. The pt-PT copy added here joins the pack a native speaker still has not reviewed. - 2026-07-27: **Phase 21 deployed — the pt-PT pair has a voice** (user: "continue the build plan"; scope chosen: deploy Phase 21 to the VPS rather than start Phase 22). The plan's remaining line was "Piper pt-PT voice instance on parodia", and it hid two things. **A language was still a code change**: read-aloud knew exactly two, named in the Config struct as `TTSEndpointZH`/`TTSVoiceZH`, so adding Portuguese meant editing Go to add Portuguese. Petal now discovers its Piper instances from the environment — English keeps the unsuffixed pair, everything else is `TTS_ENDPOINT_`/`TTS_VOICE_`, base tag only because an env var name cannot hold pt-PT's hyphen — and a language configured by halves is dropped rather than routed, so it reaches the client as "no voice, use Web Speech" instead of erroring on every tap. fr and es now cost a compose service and two `.env` lines. **And the voice itself repeated Phase 21's own lesson in a new place**: `pt_PT-tugão-medium` is the *only* European Portuguese voice in Piper's catalogue — the other five are Brazilian — so, exactly as with `dictionary-pt` packaging VERO, the default anyone reaches for ships the wrong country. Then it wouldn't download at all: `piper.download_voices` pastes the voice name into the HTTP request line and `http.client` encodes that as ASCII, so it dies with `UnicodeEncodeError` on the *ã* before a byte leaves the container — a failure that lands on precisely the one voice this pair needs and on no other. The entrypoint falls back to fetching the model and its config itself with the path percent-encoded, which is all the downloader was missing. **The slow replay** (§5e) went in while there: `slow: true` raises `length_scale` to ~4/3, and the pace is part of the **cache key** — without that, asking to hear slowly a word already heard at speed serves the fast clip back, which is the one request where the difference is the entire point. **The L1 voice asks the pack, not the letters**: a new `locale` field, because "comum" is spelled the same in both halves and a detector would have to guess — the same reason the gloss shows both directions. **Deploying is what finally ran the reverse lookup against real data**, the item the previous session left open because this laptop has no `dict.db`: *data* → "date", *comum* → "common; usual", *tarde* → "evening; afternoon", *ali* → "there", with *think*, *computer* and *garden* correctly silent; and *think* glossing to **pensar** first confirms Phase 20's sense-agreement ordering on the real 550 MB database rather than on a fixture. zh flipped back is byte-for-byte ECDICT again. go build/vet/test, tsc, vitest 125/125, vite; laptop smoke against two fake Pipers, then the real thing on the box. Her data untouched: 8 documents, 33 versions, 103 suggestions, FTS matching, integrity ok, `schema_migrations` still at 11 (no migration in this phase). **Two things Phase 21 still owes, both said plainly**: the pack has not been read by a pt-PT speaker, and no pt-PT account exists — both writers are on the zh pair, so nothing she sees changed today and the browser half of the Portuguese experience has never had a human in front of it. - 2026-07-27: **Phase 21 (code half) — the pt-PT pair, and the plan's one-line assumption about the dictionary** (user: "let's continue the build plan"; scope confirmed: code only, the Piper voice and the deploy deferred, the pack written but flagged unreviewed). The plan said "Hunspell pt-PT vendored like en-US", and that turned out to be the load-bearing sentence. **nspell expands affixes eagerly on construction** — it materialises every surface form the moment you build it. English survives that; European Portuguese's 1,340 affix rules over 44,257 stems do not. Measured before deciding anything: ~340 MB of heap for the first 12,000 entries, and no return at all after three minutes on the whole file — over a gigabyte, in a browser, on a tablet. So the expansion moved to build time: `scripts/build_ptpt_dictionary.py` writes 1,039,058 forms, 2.66 MB gzipped, which the *same* nspell then reads in 842 ms using ~120 MB, and the runtime path stays byte-for-byte the English one. The `.aff` keeps only TRY/KEY/REP/MAP, which shape corrections rather than membership, so "telemovel" still corrects to "telemóvel". **A second thing the obvious route would have got wrong quietly**: npm's `dictionary-pt` is not European Portuguese — both it and `dictionary-pt-br` package VERO (Brasil), so vendoring the obvious package name ships Brazilian spellings under a pt-PT label. That is §3's pt-BR drift arriving through the *packaging* rather than through the model, and nobody reviewing the diff would see it. The real source is Projecto Natura's, packaged as `hunspell-pt-pt`; the build script now asserts the fault lines (`receção`/`húmido`/`pensámos` in, `recepção`/`úmido`/`ônibus`/`óptimo` out) before it writes a byte, and a vitest greps the built langpack for *sinônimo*, *arquivo*, *tela*, *você*. **Both-dictionaries spellcheck** landed as §3a specifies — flag only what every loaded dictionary rejects, interleave the correction pills so English can't fill all five — and dragged a smaller thing with it: the tokenizer had to become a property of the checker rather than a constant, because `[A-Za-z]` cuts "coração" into "cora", which is both silently unchecked *and* what a right-click would have looked up. The wide alphabet stays off for a writer with no Latin second language, where it could only earn her new squiggles. **Gloss both directions**: a Latin pair has no script boundary, so *data*, *sale* and *comum* are words on both sides and there is no honest way to know which she meant — Petal asks both and shows what answers, which needs no detector and therefore cannot be wrong about her writing. The reverse direction deliberately skips the English de-inflection walk, which over Portuguese would be right by accident and wrong by rule. **Writing the tests found the bug**: `extendedAlphabet` was a snapshot taken when the checker was built while `correct`/`suggest` read live — and her dictionary arrives *after* English, so the underlines would have been right while every lookup still resolved "cora". go build/vet/test, tsc, vite, vitest 116/116 clean; the shipped asset loaded in a real nspell; live smoke on a throwaway DB served both files and left the zh lookup untouched. **Two things outstanding and both said plainly**: the pack has *not* been read by a pt-PT speaker (SUGGESTIONS §3's own bar, and not one I can meet), and this laptop has no `dict.db`, so the reverse-lookup path is covered by a fixture rather than by a real collision — the first of those happens on the VPS. - 2026-07-27: **dict.db rebuilt with Spanish, and a log line caught lying** (user: "if we need to redeploy DreamDict to add Spanish support, then do so"). Millenia's dreamdict checkout held ~490 lines of uncommitted work; rather than pull over it, comparing file contents showed an earlier draft of the regional-variant work already committed upstream — nothing unique, but not mine to discard, so it was left alone and the rebuild ran from a clean clone pushed over from the laptop (millenia has no GitHub SSH). Import took 6m15s and added **es: 102,971 words**, leaving en/fr/pt-PT/zh byte-identical — the check that distinguishes "added a language" from "quietly changed everything". Coverage measured before shipping: **es 68.6%**, the best of the four; **zh re-measured at 53.2%**, so the ECDICT decision stands on fresh evidence rather than on the earlier number. Shipped direct millenia→parodia over headscale, hashed both ends, kept the April file for rollback. **The rebuild's real find was in Petal, not DreamDict**: the startup line reported `dictionary.Langs()`, a compile-time constant of *supported* languages, so it had been printing a cheerful `[en fr pt-PT es zh]` over a database with no Spanish in it — the exact failure it existed to catch, reported as success, and something I had already claimed as proof the deploy was good. It now counts rows. Chasing a failed SUBTLEX-US download (benign — the loader falls back to `.txt`) also confirmed English "frequency" is mostly SCOWL's commonness bucket, which independently vindicates the band chip reading `difficulty` instead. diff --git a/internal/db/db.go b/internal/db/db.go index 9fa7583..8e91c31 100644 --- a/internal/db/db.go +++ b/internal/db/db.go @@ -477,6 +477,26 @@ CREATE TABLE personal_words ( 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); +`, + }, + { + // Which engine proposed a row. Until now `type` doubled as that answer — + // 'mechanics' meant "the offline rule pack found this" and everything else + // meant "the model did". That breaks the moment an offline rule proposes a + // *collocation*: the miscollocation list (SUGGESTIONS §6) is the same + // family, the same rail and the same warm phrasing as the LLM coach, and it + // must stay type='collocation' so an accepted chunk still plants in the + // garden and still counts in the journal. With type no longer naming the + // engine, the two passes could not scope their own DELETEs — the coach + // would wipe the offline flags, and the offline pass would leave the + // coach's behind to accumulate. + // + // Existing mechanics rows are local by definition; everything else came + // from a model. + name: "0013_suggestion_source", + stmt: ` +ALTER TABLE suggestions ADD COLUMN source TEXT NOT NULL DEFAULT 'llm'; +UPDATE suggestions SET source = 'local' WHERE type = 'mechanics'; `, }, } diff --git a/internal/db/db_test.go b/internal/db/db_test.go index 8a9f6db..e705c0e 100644 --- a/internal/db/db_test.go +++ b/internal/db/db_test.go @@ -150,3 +150,80 @@ func TestResolvedAtBackfill(t *testing.T) { t.Errorf("pending row got resolved_at = %v, want NULL — nothing was decided", *pending) } } + +// TestSuggestionSourceBackfill runs migration 0013 against a database that +// predates it — the shape the live box is actually in. `source` is the column +// that lets the offline rule pack and the model share the collocation family +// without deleting each other's rows, and it can only do that if the existing +// rows are labelled correctly on the way in: everything the old deterministic +// pass wrote is local, and everything else came from a model. +func TestSuggestionSourceBackfill(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 0013. + if _, err := d.Exec(`ALTER TABLE suggestions DROP COLUMN source`); err != nil { + t.Fatalf("rewind schema: %v", err) + } + if _, err := d.Exec(`DELETE FROM schema_migrations WHERE name = '0013_suggestion_source'`); 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, typ string }{ + {"s-mech", SuggestionTypeMechanics}, + {"s-gram", SuggestionTypeGrammar}, + {"s-coll", SuggestionTypeCollocation}, + } { + if _, err := d.Exec( + `INSERT INTO suggestions (id, doc_id, from_pos, to_pos, original, replacement, explanation, type) + VALUES (?, 'd1', 0, 3, 'teh', 'the', 'x', ?)`, + s.id, s.typ, + ); 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() + + // A pre-0013 collocation row can only have come from the coach — the offline + // miscollocation list did not exist yet — so it must NOT be claimed as local. + for id, want := range map[string]string{ + "s-mech": SuggestionSourceLocal, + "s-gram": SuggestionSourceLLM, + "s-coll": SuggestionSourceLLM, + } { + var got string + if err := d2.QueryRow(`SELECT source FROM suggestions WHERE id = ?`, id).Scan(&got); err != nil { + t.Fatalf("read %s: %v", id, err) + } + if got != want { + t.Errorf("%s: source = %q, want %q", id, got, want) + } + } + + // And a row written after the migration defaults to the model, so a code path + // that forgets to name a source can never silently claim to be offline. + if _, err := d2.Exec( + `INSERT INTO suggestions (id, doc_id, from_pos, to_pos, original, replacement, explanation, type) + VALUES ('s-new', 'd1', 0, 3, 'teh', 'the', 'x', 'grammar')`, + ); err != nil { + t.Fatalf("insert new row: %v", err) + } + var fresh string + if err := d2.QueryRow(`SELECT source FROM suggestions WHERE id = 's-new'`).Scan(&fresh); err != nil { + t.Fatalf("read new row: %v", err) + } + if fresh != SuggestionSourceLLM { + t.Errorf("default source = %q, want %q", fresh, SuggestionSourceLLM) + } +} diff --git a/internal/db/models.go b/internal/db/models.go index 3942c08..1c9e073 100644 --- a/internal/db/models.go +++ b/internal/db/models.go @@ -106,7 +106,11 @@ type Suggestion struct { Explanation string `json:"explanation"` Type string `json:"type"` // grammar | phrasing | idiom | clarity | voice | collocation Status string `json:"status"` // pending | accepted | rejected - CreatedAt time.Time `json:"created_at"` + // Source names the engine that proposed the edit, not its family: an offline + // rule and the model can both propose a collocation, and the writer is never + // told which one spoke. It exists so each pass can replace its own rows. + Source string `json:"source"` // llm | local + CreatedAt time.Time `json:"created_at"` } // Suggestion type and status values, mirrored from the schema CHECK constraints. @@ -119,6 +123,12 @@ const ( SuggestionTypeCollocation = "collocation" SuggestionTypeMechanics = "mechanics" // deterministic rule-based pass (no LLM) + // Who proposed it. The offline rule pack ('local') runs on every edit inside + // the browser and survives a VPN-down box; the model ('llm') adds the long + // tail when it is reachable. + SuggestionSourceLLM = "llm" + SuggestionSourceLocal = "local" + SuggestionStatusPending = "pending" SuggestionStatusAccepted = "accepted" SuggestionStatusRejected = "rejected" diff --git a/internal/suggestions/handlers.go b/internal/suggestions/handlers.go index b3a194f..c3edd62 100644 --- a/internal/suggestions/handlers.go +++ b/internal/suggestions/handlers.go @@ -90,6 +90,22 @@ type mechanicsFinding struct { Original string `json:"original"` Replacement string `json:"replacement"` Explanation string `json:"explanation"` + // Which family this offline finding belongs to. Empty (the historical shape) + // means mechanics; the miscollocation rules send 'collocation' so a chunk the + // rule pack caught is indistinguishable from one the coach caught — same + // family, same rail, and the same planting into the garden on accept. + Type string `json:"type"` +} + +// localType maps a client-supplied family onto the two an offline rule may claim. +// Anything else — including the empty string older clients send — is mechanics, +// so a stray label can never smuggle a row into an LLM family and survive that +// pass's DELETE. +func localType(t string) string { + if strings.ToLower(strings.TrimSpace(t)) == db.SuggestionTypeCollocation { + return db.SuggestionTypeCollocation + } + return db.SuggestionTypeMechanics } // maxMechanicsFindings caps a single submission so a runaway client can't flood @@ -144,10 +160,16 @@ func (h *Handler) mechanics(w http.ResponseWriter, r *http.Request) { httputil.WriteJSON(w, http.StatusOK, out) } -// replaceMechanics swaps the document's pending mechanics rows for the supplied +// replaceMechanics swaps the document's pending offline rows for the supplied // findings in one transaction, leaving the LLM families and actioned rows // untouched. Findings the user already accepted or dismissed are suppressed (the // detector has no memory between runs), and malformed spans are skipped. +// +// The DELETE is scoped by *source*, not by type: the rule pack owns both the +// mechanics family and its share of the collocation family, and every run is a +// full recompute of the document, so everything it wrote last time goes. Scoping +// by type instead would strand offline collocations the current text no longer +// warrants — the one row nobody would ever replace. func (h *Handler) replaceMechanics(docID string, findings []mechanicsFinding) error { tx, err := h.DB.Begin() if err != nil { @@ -156,8 +178,8 @@ func (h *Handler) replaceMechanics(docID string, findings []mechanicsFinding) er defer tx.Rollback() if _, err := tx.Exec( - `DELETE FROM suggestions WHERE doc_id = ? AND status = ? AND type = ?`, - docID, db.SuggestionStatusPending, db.SuggestionTypeMechanics, + `DELETE FROM suggestions WHERE doc_id = ? AND status = ? AND source = ?`, + docID, db.SuggestionStatusPending, db.SuggestionSourceLocal, ); err != nil { return err } @@ -175,9 +197,10 @@ func (h *Handler) replaceMechanics(docID string, findings []mechanicsFinding) er continue } if _, err := tx.Exec( - `INSERT INTO suggestions (doc_id, from_pos, to_pos, original, replacement, explanation, type) - VALUES (?, ?, ?, ?, ?, ?, ?)`, - docID, f.From, f.To, f.Original, f.Replacement, f.Explanation, db.SuggestionTypeMechanics, + `INSERT INTO suggestions (doc_id, from_pos, to_pos, original, replacement, explanation, type, source) + VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, + docID, f.From, f.To, f.Original, f.Replacement, f.Explanation, + localType(f.Type), db.SuggestionSourceLocal, ); err != nil { return err } @@ -289,17 +312,22 @@ type pendingScope struct { forceType string // if set, every inserted row gets this type; else normalizeType } +// Every scope below is confined to source='llm'. The offline rule pack replaces +// its own rows wholesale on each edit (see replaceMechanics) and its findings +// must survive all three model passes — including the collocation coach, which +// now shares the collocation family with it. var ( // grammarScope owns the grammar/phrasing/idiom/clarity flags — everything but - // the other self-owned families (voice, collocation, mechanics), which run on - // their own cadence/pass and must survive a grammar checkpoint. Notably the - // deterministic mechanics pass writes its rows in the same /check request just - // before this DELETE runs, so excluding it here is what keeps them alive. - grammarScope = pendingScope{deleteWhere: "type NOT IN ('voice','collocation','mechanics')", 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} + // the other self-owned families (voice, collocation), which run on their own + // cadence/pass and must survive a grammar checkpoint. Notably the offline pass + // writes its rows in the same /check request just before this DELETE runs, so + // the source clause is also what keeps them alive. + grammarScope = pendingScope{deleteWhere: "source = 'llm' AND type NOT IN ('voice','collocation')", forceType: ""} + // voiceScope owns the model's voice flags only. + voiceScope = pendingScope{deleteWhere: "source = 'llm' AND type = 'voice'", forceType: db.SuggestionTypeVoice} + // collocationScope owns the model's collocation flags only — the rule pack's + // share of the same family is left standing. + collocationScope = pendingScope{deleteWhere: "source = 'llm' AND type = 'collocation'", forceType: db.SuggestionTypeCollocation} ) // replacePending swaps a document's pending suggestions within one family for a @@ -341,9 +369,9 @@ func (h *Handler) replacePending(docID, contentText string, raw []llm.RawSuggest } from, to := locate(contentText, s.Original) if _, err := tx.Exec( - `INSERT INTO suggestions (doc_id, from_pos, to_pos, original, replacement, explanation, type) - VALUES (?, ?, ?, ?, ?, ?, ?)`, - docID, from, to, s.Original, s.Replacement, s.Explanation, typ, + `INSERT INTO suggestions (doc_id, from_pos, to_pos, original, replacement, explanation, type, source) + VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, + docID, from, to, s.Original, s.Replacement, s.Explanation, typ, db.SuggestionSourceLLM, ); err != nil { return err } @@ -497,7 +525,7 @@ func (h *Handler) listForDoc(w http.ResponseWriter, r *http.Request) { func (h *Handler) fetchPending(userID, docID string) ([]db.Suggestion, error) { rows, err := h.DB.Query( `SELECT s.id, s.doc_id, s.from_pos, s.to_pos, s.original, s.replacement, - s.explanation, s.type, s.status, s.created_at + s.explanation, s.type, s.status, s.source, s.created_at FROM suggestions s JOIN documents d ON d.id = s.doc_id WHERE s.doc_id = ? AND d.user_id = ? AND s.status = ? @@ -514,7 +542,7 @@ func (h *Handler) fetchPending(userID, docID string) ([]db.Suggestion, error) { var s db.Suggestion if err := rows.Scan( &s.ID, &s.DocID, &s.FromPos, &s.ToPos, &s.Original, &s.Replacement, - &s.Explanation, &s.Type, &s.Status, &s.CreatedAt, + &s.Explanation, &s.Type, &s.Status, &s.Source, &s.CreatedAt, ); err != nil { return nil, err } @@ -526,11 +554,14 @@ func (h *Handler) fetchPending(userID, docID string) ([]db.Suggestion, error) { return dedupeSpans(out), nil } -// dedupeSpans resolves collisions between the deterministic mechanics family and -// the LLM families: when a mechanics finding and an LLM suggestion fight over the -// same characters, mechanics wins and the LLM card is dropped. Its span is exact -// (the detector matched it), whereas the LLM positions are only advisory -// (re-anchored by string at render), so the precise fix should own the span. +// dedupeSpans resolves collisions between the offline rule pack and the model: +// when a local finding and an LLM suggestion fight over the same characters, the +// local one wins and the LLM card is dropped. Its span is exact (the detector +// matched it), whereas the LLM positions are only advisory (re-anchored by string +// at render), so the precise fix should own the span. This is why the split is by +// source rather than by type — an offline miscollocation is as exact as an +// offline comma, and the coach's fuzzy version of the same chunk shouldn't +// double up next to it. // // This deliberately does NOT dedupe LLM-vs-LLM overlaps: voice (awareness-only, // no replacement) and collocation legitimately co-occupy the same span, and that @@ -540,7 +571,7 @@ func dedupeSpans(in []db.Suggestion) []db.Suggestion { type span struct{ from, to int } var claimed []span for _, s := range in { - if s.Type == db.SuggestionTypeMechanics && s.FromPos >= 0 { + if s.Source == db.SuggestionSourceLocal && s.FromPos >= 0 { claimed = append(claimed, span{s.FromPos, s.ToPos}) } } @@ -550,7 +581,7 @@ func dedupeSpans(in []db.Suggestion) []db.Suggestion { out := make([]db.Suggestion, 0, len(in)) for _, s := range in { - if s.Type != db.SuggestionTypeMechanics && s.FromPos >= 0 { + if s.Source != db.SuggestionSourceLocal && s.FromPos >= 0 { overlaps := false for _, sp := range claimed { if s.FromPos < sp.to && sp.from < s.ToPos { @@ -559,7 +590,7 @@ func dedupeSpans(in []db.Suggestion) []db.Suggestion { } } if overlaps { - continue // an exact mechanics fix owns these characters + continue // an exact offline fix owns these characters } } out = append(out, s) diff --git a/internal/suggestions/offline_test.go b/internal/suggestions/offline_test.go new file mode 100644 index 0000000..fc997b4 --- /dev/null +++ b/internal/suggestions/offline_test.go @@ -0,0 +1,203 @@ +package suggestions + +import ( + "encoding/json" + "net/http" + "testing" + + "gitea.parodia.dev/drwily/petal/internal/db" +) + +// The offline rule pack and the LLM now share the collocation family, which is +// the point: the writer sees one rail and is never told which engine spoke. What +// makes that safe is `source` — each pass replaces only its own rows. These tests +// pin the two ways that could go wrong, both of which the old type-scoped DELETEs +// would have hit. + +// pendingOfType counts the pending rows of one family in a response body. +func pendingOfType(got []db.Suggestion, typ string) []db.Suggestion { + var out []db.Suggestion + for _, s := range got { + if s.Type == typ { + out = append(out, s) + } + } + return out +} + +// TestOfflineCollocationFilesAsCollocation proves a miscollocation the rule pack +// found is stored in the collocation family (so accepting it plants a garden +// card, exactly as the coach's would) while still being marked as locally found. +func TestOfflineCollocationFilesAsCollocation(t *testing.T) { + srv, docID, _ := newTestServer(t, &stubClient{response: `{"suggestions":[]}`}) + + got := postMechanics(t, srv, docID, `[ + {"from":0,"to":13,"original":"do a decision","replacement":"make a decision","explanation":"pairing","type":"collocation"}, + {"from":20,"to":27,"original":"the the","replacement":"the","explanation":"doubled word","type":"mechanics"} + ]`) + if len(got) != 2 { + t.Fatalf("want both findings, got %+v", got) + } + coll := pendingOfType(got, db.SuggestionTypeCollocation) + if len(coll) != 1 { + t.Fatalf("want 1 collocation, got %+v", got) + } + if coll[0].Source != db.SuggestionSourceLocal { + t.Errorf("offline finding should be source=local, got %q", coll[0].Source) + } + if mech := pendingOfType(got, db.SuggestionTypeMechanics); len(mech) != 1 { + t.Fatalf("want 1 mechanics finding, got %+v", got) + } +} + +// TestUnknownLocalTypeFallsBackToMechanics: a family the offline pass isn't +// allowed to claim (or an older client sending none at all) must land in +// mechanics. Otherwise a stray label would smuggle a row into an LLM family, +// where nothing would ever replace it. +func TestUnknownLocalTypeFallsBackToMechanics(t *testing.T) { + srv, docID, _ := newTestServer(t, &stubClient{response: `{"suggestions":[]}`}) + + got := postMechanics(t, srv, docID, `[ + {"from":0,"to":5,"original":"aaaaa","replacement":"bbbbb","explanation":"x","type":"voice"}, + {"from":6,"to":11,"original":"ccccc","replacement":"ddddd","explanation":"y"} + ]`) + if len(got) != 2 { + t.Fatalf("want 2 findings, got %+v", got) + } + for _, s := range got { + if s.Type != db.SuggestionTypeMechanics { + t.Errorf("offline finding claimed family %q; only mechanics/collocation are allowed", s.Type) + } + } +} + +// TestCoachDoesNotWipeOfflineCollocations is the collision the source column +// exists for: the LLM collocation pass replaces the collocation family, and the +// rule pack's share of that family has to survive it. Before `source`, running +// the coach silently deleted every offline chunk on the page. +func TestCoachDoesNotWipeOfflineCollocations(t *testing.T) { + client := &stubClient{response: `{"suggestions":[ + {"original":"apple","replacement":"an apple","explanation":"article","type":"collocation"} + ]}`} + srv, docID, _ := newTestServer(t, client) + + // The seeded doc is "I has two apple." — the coach's flag anchors on "apple" + // at [10,15], so the offline finding is given a span well clear of it. Two + // findings fighting over the same characters is a different rule (see + // TestOfflineCardWinsSpanCollision); this test is about the DELETE. + postMechanics(t, srv, docID, `[ + {"from":0,"to":5,"original":"do a decision","replacement":"make a decision","explanation":"pairing","type":"collocation"} + ]`) + + rec := do(t, srv, http.MethodPost, "/docs/"+docID+"/collocation", "") + if rec.Code != http.StatusOK { + t.Fatalf("collocation pass: 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) + } + + var local, llm int + for _, s := range pendingOfType(got, db.SuggestionTypeCollocation) { + if s.Source == db.SuggestionSourceLocal { + local++ + } else { + llm++ + } + } + if local != 1 { + t.Errorf("the coach wiped the offline collocation: local=%d, got %+v", local, got) + } + if llm != 1 { + t.Errorf("want the coach's own flag alongside it: llm=%d, got %+v", llm, got) + } +} + +// TestOfflinePassReplacesItsOwnCollocations is the mirror: the rule pack +// recomputes the whole document every run, so a chunk the current text no longer +// warrants must go — and the coach's flags must stay. Scoping the offline DELETE +// by type instead of source would have stranded the first row forever. +func TestOfflinePassReplacesItsOwnCollocations(t *testing.T) { + client := &stubClient{response: `{"suggestions":[ + {"original":"apple","replacement":"an apple","explanation":"article","type":"collocation"} + ]}`} + srv, docID, _ := newTestServer(t, client) + + // A coach flag, then an offline chunk, then a rerun that no longer finds it. + do(t, srv, http.MethodPost, "/docs/"+docID+"/collocation", "") + postMechanics(t, srv, docID, `[ + {"from":0,"to":13,"original":"do a decision","replacement":"make a decision","explanation":"pairing","type":"collocation"} + ]`) + got := postMechanics(t, srv, docID, `[]`) + + for _, s := range got { + if s.Source == db.SuggestionSourceLocal { + t.Errorf("stale offline finding survived a recompute: %+v", s) + } + } + if len(pendingOfType(got, db.SuggestionTypeCollocation)) != 1 { + t.Fatalf("the coach's own flag should be untouched, got %+v", got) + } +} + +// TestOfflineCollocationPlantsOnAccept closes the loop the family split was for: +// a chunk the rule pack found, accepted, becomes a vocabulary-garden card — with +// no model involved anywhere in the path. +func TestOfflineCollocationPlantsOnAccept(t *testing.T) { + srv, docID, h := newTestServer(t, &stubClient{response: `{"suggestions":[]}`}) + if _, err := h.DB.Exec( + `UPDATE documents SET content_text = ? WHERE id = ?`, + "I had to do a decision about the job.", docID, + ); err != nil { + t.Fatalf("set content: %v", err) + } + + got := postMechanics(t, srv, docID, `[ + {"from":9,"to":22,"original":"do a decision","replacement":"make a decision","explanation":"pairing","type":"collocation"} + ]`) + if len(got) != 1 { + t.Fatalf("want the offline chunk, got %+v", got) + } + if rec := do(t, srv, http.MethodPost, "/suggestions/"+got[0].ID+"/accept", ""); rec.Code != http.StatusNoContent { + t.Fatalf("accept: code=%d body=%s", rec.Code, rec.Body) + } + + cards := gardenCards(t, h) + if len(cards) != 1 || cards[0].word != "make a decision" { + t.Fatalf("want a planted phrase card, got %+v", cards) + } + // The example is the corrected sentence — the phrasing she kept, not the one + // she just left behind. + if cards[0].example != "I had to make a decision about the job." { + t.Errorf("example should be the corrected sentence, got %q", cards[0].example) + } +} + +// TestOfflineCardWinsSpanCollision: the tiebreak is by engine, not by family. An +// offline miscollocation has an exact span; the coach's overlapping flag is only +// advisory, so it is the one that goes. +func TestOfflineCardWinsSpanCollision(t *testing.T) { + client := &stubClient{response: `{"suggestions":[ + {"original":"do a decision about","replacement":"decide about","explanation":"wordy","type":"collocation"} + ]}`} + srv, docID, h := newTestServer(t, client) + if _, err := h.DB.Exec( + `UPDATE documents SET content_text = ? WHERE id = ?`, + "I had to do a decision about the job.", docID, + ); err != nil { + t.Fatalf("set content: %v", err) + } + + do(t, srv, http.MethodPost, "/docs/"+docID+"/collocation", "") + got := postMechanics(t, srv, docID, `[ + {"from":9,"to":22,"original":"do a decision","replacement":"make a decision","explanation":"pairing","type":"collocation"} + ]`) + + if len(got) != 1 { + t.Fatalf("want the overlapping coach flag dropped, got %+v", got) + } + if got[0].Source != db.SuggestionSourceLocal { + t.Errorf("the exact offline card should own the span, got %+v", got[0]) + } +} diff --git a/web/src/App.tsx b/web/src/App.tsx index d432667..7a8dad2 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -307,6 +307,18 @@ export default function App() { [currentDoc, patchSummary, schedule], ) + // She took the kitten up on its daily invitation. The prompt becomes the + // blank page's title, so the question she agreed to answer stays in front of + // her while she answers it — rather than being said once and then gone the + // moment the bubble fades. + const handleAcceptInvitation = useCallback( + (prompt: string) => { + if (!currentDoc) return + handleTitleChange(prompt) + }, + [currentDoc, handleTitleChange], + ) + const handleEditorChange = useCallback( (change: EditorChange) => { setWordCount(change.word_count) @@ -597,6 +609,8 @@ export default function App() { editTick={editTick} acceptTick={acceptTick} text={docText} + blankPage={wordCount === 0 && docText.trim() === ''} + onAcceptInvitation={handleAcceptInvitation} /> diff --git a/web/src/api/client.ts b/web/src/api/client.ts index 07f4c9d..92dad79 100644 --- a/web/src/api/client.ts +++ b/web/src/api/client.ts @@ -157,6 +157,10 @@ export interface Suggestion { explanation: string type: SuggestionType status: 'pending' | 'accepted' | 'rejected' + // Which engine proposed it — the offline rule pack or the model. The rail + // deliberately renders both identically; this is here because the wire format + // carries it, not because the writer is ever shown it. + source?: 'llm' | 'local' created_at: string } @@ -174,14 +178,18 @@ export interface GrowthJournal { } // 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. +// The frontend owns offline detection; the backend only persists these. Spans +// are exact plaintext offsets. `type` names the family the finding belongs to: +// 'mechanics' for a fix to this sentence, 'collocation' for the miscollocation +// rules, whose findings are chunks worth keeping and are filed — and planted in +// the garden on accept — exactly like the LLM coach's. export interface MechanicsFinding { from: number to: number original: string replacement: string explanation: string + type: 'mechanics' | 'collocation' } // One dictionary's worth of personal words — the ones she's excused from diff --git a/web/src/components/Companion/PetalCompanion.tsx b/web/src/components/Companion/PetalCompanion.tsx index 5d984aa..fb6ec7a 100644 --- a/web/src/components/Companion/PetalCompanion.tsx +++ b/web/src/components/Companion/PetalCompanion.tsx @@ -13,6 +13,11 @@ interface Props { editTick: number acceptTick: number text: string + // The open document is still empty — the one state the daily writing + // invitation is offered in. + blankPage: boolean + // Called when she takes the invitation up, with the English prompt. + onAcceptInvitation: (prompt: string) => void } // Emoji placeholder per mood, used for any mood a companion has no Lottie for. @@ -30,17 +35,38 @@ const STORAGE_KEY = 'petal.companion' // useCompanion and shows a Mandarin-first speech bubble for cheers, tips, and // break reminders. Clicking the mascot opens a picker to switch companions // (the choice persists in localStorage). -export function PetalCompanion({ wordCount, saveStatus, llmDown, editTick, acceptTick, text }: Props) { +export function PetalCompanion({ + wordCount, + saveStatus, + llmDown, + editTick, + acceptTick, + text, + blankPage, + onAcceptInvitation, +}: Props) { const t = usePack() - const { mood, bubble, dismiss, holdBubble, releaseBubble } = useCompanion({ + const { + mood, + bubble, + dismiss, + holdBubble, + releaseBubble, + acceptInvite, + declineInvite, + setInviteHandler, + } = useCompanion({ wordCount, saveStatus, llmDown, editTick, acceptTick, text, + blankPage, }) + useEffect(() => setInviteHandler(onAcceptInvitation), [setInviteHandler, onAcceptInvitation]) + const [companionId, setCompanionId] = useState( () => readPref(STORAGE_KEY) || DEFAULT_COMPANION, ) @@ -179,6 +205,36 @@ export function PetalCompanion({ wordCount, saveStatus, llmDown, editTick, accep > {bubble.en}

+ + {/* The daily invitation's two answers. "Not today" is a real button + sitting level with the other one, not a small grey escape — a no + that has to be hunted for isn't much of a no. */} + {bubble.invite && ( +
+ + +
+ )} )} diff --git a/web/src/components/Companion/grammarLite.test.ts b/web/src/components/Companion/grammarLite.test.ts new file mode 100644 index 0000000..2b297d1 --- /dev/null +++ b/web/src/components/Companion/grammarLite.test.ts @@ -0,0 +1,261 @@ +import { afterEach, describe, expect, it } from 'vitest' +import { analyzeProse, mechanicsFindings } from './prose' +import { resetPackForTests, setPackLang } from '../../i18n' + +// Phase 22's offline half: the grammar-lite rule pack, the embedded +// miscollocation list, the false-friend heads-up, and the per-pair L1 rules. +// +// The bar these tests enforce is the one the pack promises: **precision over +// recall**. Every rule is pinned in two directions — the mistake it must catch, +// and the correct English next to it that it must leave alone. A rule that +// cannot be guarded that way was left out of the pack rather than tested +// loosely here. + +afterEach(() => resetPackForTests()) + +const rules = (text: string) => analyzeProse(text).map((h) => h.rule) +const findings = (text: string) => mechanicsFindings(text) + +// The one-click fix a given rule produced, if any. +function fix(text: string, original: string) { + return findings(text).find((f) => f.original.toLowerCase() === original.toLowerCase()) +} + +// Every finding must be able to anchor: its span has to be exactly the text it +// claims, or the editor applies the edit to the wrong characters. +function expectExactSpans(text: string) { + for (const f of findings(text)) { + expect(text.slice(f.from, f.to), `span mismatch for "${f.original}"`).toBe(f.original) + } + return findings(text) +} + +describe('prepositions', () => { + it('depend of → depend on, keeping the writer\'s own verb form', () => { + expect(fix('It all depends of the weather on the day we travel.', 'depends of')?.replacement).toBe( + 'depends on', + ) + expect(fix('Depending of the weather we will go to the beach today.', 'Depending of')?.replacement).toBe( + 'Depending on', + ) + }) + + it('discuss about → discuss (the preposition simply goes)', () => { + expect(fix('We discussed about the plan for a long time yesterday.', 'discussed about')?.replacement).toBe( + 'discussed', + ) + }) + + it('explain me → explain to me', () => { + expect(fix('Can you explain me the rules of this game again please.', 'explain me')?.replacement).toBe( + 'explain to me', + ) + }) + + it('listen the radio → listen to the radio', () => { + expect(fix('I listen the radio every morning while I make my coffee.', 'listen the')?.replacement).toBe( + 'listen to the', + ) + }) + + // The pairings deliberately NOT in the list, because they are only usually + // wrong. Each of these is correct English and must stay silent. + it('leaves the correct prepositions alone', () => { + expect(rules('It all depends on the weather on the day we travel.')).not.toContain('preposition') + expect(rules('We discussed the plan for a long time yesterday afternoon.')).not.toContain('preposition') + expect(rules('I listen to the radio every morning while I make coffee.')).not.toContain('preposition') + // Left out of the pack on purpose — "married with children" is a phrase, + // "arrive to" wants at or in, "different than" is ordinary American usage. + expect(rules('She is married with children and lives near the old harbour.')).not.toContain('preposition') + expect(rules('This result is different than the one we saw last week.')).not.toContain('preposition') + }) +}) + +describe('doubled comparatives', () => { + it('more better → better', () => { + expect(fix('This one is more better than the other one we tried.', 'more better')?.replacement).toBe('better') + }) + + it('most easiest → easiest', () => { + expect(fix('That was the most easiest question on the whole exam paper.', 'most easiest')?.replacement).toBe( + 'easiest', + ) + }) + + // The guard the generic /\w+er/ pattern would have failed: these are correct. + it('leaves ordinary "more/most + adjective" alone', () => { + expect(rules('She is more clever than anyone else in the whole class.')).not.toContain('doublecomp') + expect(rules('He was the most eager student in the room that morning.')).not.toContain('doublecomp') + expect(rules('This is the most beautiful garden I have ever seen here.')).not.toContain('doublecomp') + }) +}) + +describe('people is', () => { + it('people is → people are, and people has → people have', () => { + expect(fix('Many people is waiting outside the hall in the rain.', 'people is')?.replacement).toBe('people are') + expect(fix('Some people has never seen the sea in their whole life.', 'people has')?.replacement).toBe( + 'people have', + ) + }) + + it('leaves the plural alone', () => { + expect(rules('Many people are waiting outside the hall in the rain.')).not.toContain('peopleare') + }) +}) + +describe('miscollocations', () => { + // The whole point of the family: these file as 'collocation', not + // 'mechanics', so an accepted chunk plants in the vocabulary garden exactly + // as one the LLM coach proposed would. + it('files as the collocation family, not as mechanics', () => { + const f = fix('I had to do a decision about the job offer quickly.', 'do a decision') + expect(f?.replacement).toBe('make a decision') + expect(f?.type).toBe('collocation') + }) + + it('mechanics fixes keep their own family', () => { + expect(fix('I saw the the cat in the garden this morning.', 'the the')?.type).toBe('mechanics') + }) + + it('agrees with the tense the writer was already using', () => { + expect(fix('She did a mistake on the form and had to start again.', 'did a mistake')?.replacement).toBe( + 'made a mistake', + ) + expect(fix('He is making his homework at the kitchen table right now.', 'making his homework')?.replacement).toBe( + 'doing his homework', + ) + }) + + it('say me → tell me', () => { + expect(fix('Please say me what happened at the meeting this afternoon.', 'say me')?.replacement).toBe('tell me') + }) + + it('make a photo → take a photo', () => { + expect(fix('We made a photo together in front of the old church.', 'made a photo')?.replacement).toBe( + 'took a photo', + ) + }) + + it('strong rain → heavy rain', () => { + expect(fix('There was strong rain all afternoon and we stayed inside.', 'strong rain')?.replacement).toBe( + 'heavy rain', + ) + }) + + // "strong wind" is the correct pairing, and the rule that fixes "big wind" + // must not propose it as a change to itself. + it('never proposes a phrase identical to what she wrote', () => { + expect(rules('There was a strong wind blowing across the open field today.')).not.toContain('collocation') + expect(fix('There was a big wind blowing across the open field today.', 'big wind')?.replacement).toBe( + 'strong wind', + ) + }) + + it('leaves the correct pairings alone', () => { + expect(rules('I had to make a decision about the job offer quickly.')).not.toContain('collocation') + expect(rules('She does her homework at the kitchen table every evening.')).not.toContain('collocation') + expect(rules('We took a photo together in front of the old church.')).not.toContain('collocation') + }) +}) + +describe('false friends', () => { + it('flags a Portuguese false friend for the pt-PT pair, awareness-only', () => { + setPackLang('pt-PT') + const hints = analyzeProse('I will eventually finish the report before the end of the week.') + const ff = hints.find((h) => h.rule === 'falsefriend') + expect(ff).toBeDefined() + // Never a card: the word may well be the one she meant, and a one-click + // "fix" would be Petal deciding that for her. + expect(ff?.fix).toBeUndefined() + expect(findings('I will eventually finish the report before the end of the week.')).toEqual([]) + }) + + it('raises at most one per pass — a heads-up, not a sweep', () => { + setPackLang('pt-PT') + const hints = analyzeProse( + 'Actually I did not pretend to assist the lecture at the library this week.', + ) + expect(hints.filter((h) => h.rule === 'falsefriend')).toHaveLength(1) + }) + + it('says nothing for the zh pair, which has no false friends at all', () => { + setPackLang('zh') + expect(rules('I will eventually finish the report before the end of the week.')).not.toContain('falsefriend') + }) +}) + +describe('per-pair L1 interference', () => { + it('pt-PT: "have 30 years" → "am 30 years old"', () => { + setPackLang('pt-PT') + expect(fix('My sister I have 30 years and she is older than me.', 'I have 30 years')?.replacement).toBe( + 'I am 30 years old', + ) + // The subject and tense she wrote in are carried into the correction. + expect(fix('When we met she had twenty years and I was still at school.', 'she had twenty years')?.replacement).toBe( + 'she was twenty years old', + ) + }) + + it('pt-PT: "I am agree" → "I agree"', () => { + setPackLang('pt-PT') + expect(fix('I am agree with everything that was said at the meeting.', 'I am agree')?.replacement).toBe('I agree') + }) + + it('pt-PT: "since three years" → "for three years"', () => { + setPackLang('pt-PT') + expect(fix('I have lived in this city since three years and I love it.', 'since three years')?.replacement).toBe( + 'for three years', + ) + }) + + it('pt-PT: leaves "since" with a starting point alone', () => { + setPackLang('pt-PT') + expect(rules('I have lived in this city since 2020 and I still love it.')).not.toContain('since') + }) + + it('zh: "very like" → "really like", and "open the light" → "turn on the light"', () => { + setPackLang('zh') + expect(fix('I very like the small garden behind my grandmother house.', 'very like')?.replacement).toBe( + 'really like', + ) + expect(fix('Please open the light before you come into the dark room.', 'open the light')?.replacement).toBe( + 'turn on the light', + ) + expect(fix('She closed the television and went straight to bed last night.', 'closed the television')?.replacement).toBe( + 'turned off the television', + ) + }) + + it('zh: although…but is awareness-only — the "but" is too common to anchor a card to', () => { + setPackLang('zh') + const text = 'Although it was raining hard, but we still went to the park.' + expect(rules(text)).toContain('althoughbut') + expect(findings(text).some((f) => f.original.includes('but'))).toBe(false) + }) + + it('zh: leaves "very + adjective" alone', () => { + setPackLang('zh') + expect(rules('I am very happy about the small garden behind the house.')).not.toContain('veryverb') + }) + + // The gating is the reason these rules can be confident. A rule that is a + // near-certainty for one L1 is only a guess for another, and a guess does not + // belong in a rule pack that runs on every keystroke. + it('does not run one pair\'s interference rules for the other pair', () => { + setPackLang('zh') + expect(rules('I have 30 years and I still live near the old harbour.')).not.toContain('haveyears') + setPackLang('pt-PT') + expect(rules('I very like the small garden behind my grandmother house.')).not.toContain('veryverb') + }) +}) + +describe('spans stay exact across every new rule', () => { + it('anchors each finding to the text it claims', () => { + setPackLang('pt-PT') + expectExactSpans( + 'I have 30 years and I am agree that we depends of the weather, and she did a mistake since three years.', + ) + setPackLang('zh') + expectExactSpans('I very like to open the light, and many people is more better at it than me.') + }) +}) diff --git a/web/src/components/Companion/invitation.test.ts b/web/src/components/Companion/invitation.test.ts new file mode 100644 index 0000000..242a1c2 --- /dev/null +++ b/web/src/components/Companion/invitation.test.ts @@ -0,0 +1,111 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +import { markInvited, mayInvite, todayKey } from './invitation' +import { resetPackForTests, setPackLang } from '../../i18n' +import { zh } from '../../i18n/packs/zh' +import { ptPT } from '../../i18n/packs/pt-PT' +import { declined, invitations } from './tips' + +// The suite runs without a DOM, so localStorage is stubbed the same way +// prefs.test.ts does it. +function fakeStorage(): Storage { + const map = new Map() + return { + get length() { + return map.size + }, + key: (i: number) => [...map.keys()][i] ?? null, + getItem: (k: string) => map.get(k) ?? null, + setItem: (k: string, v: string) => void map.set(k, v), + removeItem: (k: string) => void map.delete(k), + clear: () => map.clear(), + } as Storage +} + +beforeEach(() => vi.stubGlobal('localStorage', fakeStorage())) +afterEach(() => { + vi.unstubAllGlobals() + resetPackForTests() +}) + +describe('once a day', () => { + it('offers, then does not offer again the same day', () => { + expect(mayInvite()).toBe(true) + markInvited() + expect(mayInvite()).toBe(false) + }) + + it('offers again the next day', () => { + const monday = new Date(2026, 6, 27, 10, 0) + const tuesday = new Date(2026, 6, 28, 9, 0) + markInvited(monday) + expect(mayInvite(monday)).toBe(false) + expect(mayInvite(tuesday)).toBe(true) + }) + + // The whole promise of §5c: nothing is counting. A month away has to look + // exactly like a day away, because the alternative is a streak, and a streak + // punishes exactly the person this feature is for. + it('treats a month away the same as a day away', () => { + const june = new Date(2026, 5, 1, 10, 0) + const july = new Date(2026, 6, 27, 10, 0) + markInvited(june) + expect(mayInvite(july)).toBe(true) + // And after being asked again, still only ever one stored value: a date. + markInvited(july) + expect(localStorage.getItem('petal.invited')).toBe(todayKey(july)) + // One stored value, and it is a date. Nothing accumulates. + expect(localStorage.length).toBe(1) + }) + + it('uses the writer\'s local day, so a late night and the small hours differ', () => { + const lateNight = new Date(2026, 6, 27, 23, 30) + const smallHours = new Date(2026, 6, 28, 1, 15) + markInvited(lateNight) + expect(mayInvite(smallHours)).toBe(true) + }) +}) + +describe('the invitation copy', () => { + const PACKS = [ + { name: 'zh', pack: zh }, + { name: 'pt-PT', pack: ptPT }, + ] + + it('every pack offers something to write about, and a way to say no', () => { + for (const { name, pack } of PACKS) { + expect(pack.companion.invitations.length, name).toBeGreaterThan(3) + expect(pack.companion.inviteAccept.trim(), name).not.toBe('') + expect(pack.companion.inviteDecline.trim(), name).not.toBe('') + expect(pack.companion.declined.native.trim(), name).not.toBe('') + } + }) + + // The copy is bound by the same rule as the timing: no streaks, no guilt, no + // counting of days. This is the part a well-meaning future edit would undo — + // "day 4 in a row!" is a natural thing to write and the wrong thing to say. + it('never invokes a streak, a target, or a missed day', () => { + const forbidden = + /streak|in a row|every day|don't break|dont break|missed|behind|连续|打卡|坚持|seguidos|todos os dias|falhaste/i + for (const { name, pack } of PACKS) { + const copy = [ + ...pack.companion.invitations.flatMap((l) => [l.native, l.en]), + pack.companion.inviteAccept, + pack.companion.inviteDecline, + pack.companion.declined.native, + pack.companion.declined.en, + ] + for (const line of copy) { + expect(line, `${name}: ${line}`).not.toMatch(forbidden) + } + } + }) + + it('reads from the pair in force, like every other companion line', () => { + setPackLang('pt-PT') + expect(invitations()).toBe(ptPT.companion.invitations) + expect(declined()).toBe(ptPT.companion.declined) + setPackLang('zh') + expect(invitations()).toBe(zh.companion.invitations) + }) +}) diff --git a/web/src/components/Companion/invitation.ts b/web/src/components/Companion/invitation.ts new file mode 100644 index 0000000..63b050b --- /dev/null +++ b/web/src/components/Companion/invitation.ts @@ -0,0 +1,36 @@ +// When the companion may offer its daily invitation to write. +// +// A handful of lines, pulled out of the timing engine on purpose: this is the +// part of §5c with the ethics in it, and it should be readable and testable on +// its own rather than buried in a heartbeat. +// +// The rule is a *date*, not a count and not a run of days. Petal remembers the +// last day it asked and nothing else — so there is no streak to break, no tally +// of days missed, and nothing that gets worse for being away a week. Coming +// back after a month looks exactly like coming back tomorrow, which is the only +// version of this feature worth shipping to someone learning a language. +// +// Either answer spends the day's invitation. Being asked again after saying no +// would turn "not today" into a negotiation. + +import { readPref, writePref } from '../../lib/prefs' + +const INVITED_KEY = 'petal.invited' + +// The local calendar day. Local rather than UTC because "today" is the writer's +// day: a nudge at 11pm and another at 1am would otherwise be two different days. +export function todayKey(now = new Date()): string { + const pad = (n: number) => String(n).padStart(2, '0') + return `${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())}` +} + +// mayInvite reports whether today's invitation is still unspent. +export function mayInvite(now = new Date()): boolean { + return readPref(INVITED_KEY) !== todayKey(now) +} + +// markInvited spends it — called when the invitation is *offered*, not when it +// is accepted, because declining has to count too. +export function markInvited(now = new Date()): void { + writePref(INVITED_KEY, todayKey(now)) +} diff --git a/web/src/components/Companion/prose.ts b/web/src/components/Companion/prose.ts index dfe2e7c..de9cf12 100644 --- a/web/src/components/Companion/prose.ts +++ b/web/src/components/Companion/prose.ts @@ -19,6 +19,7 @@ // fix-bearing hints so the same span never appears as both a bubble and a card. import { pack } from '../../i18n' +import type { PairLang } from '../../i18n' import type { Line } from './tips' // The pair's prose copy. Read per finding rather than captured once, so a rule @@ -41,8 +42,16 @@ export interface Fix { from: number to: number replacement: string + // Which suggestion family the card belongs to. Omitted means 'mechanics' — a + // fix to *this* sentence. The miscollocation rules set 'collocation', because + // what they hand over is a reusable chunk: the writer sees the same rail and + // the same warm phrasing as the LLM coach, and an accepted chunk plants in the + // vocabulary garden exactly as the coach's would. + family?: FindingFamily } +export type FindingFamily = 'mechanics' | 'collocation' + // A deterministic suggestion-card finding, derived from an applyable hint. Mirrors // the backend's card shape (original/replacement/explanation + span) so the card // pipeline can persist it as the 'mechanics' family. `explanation` is the English @@ -53,6 +62,9 @@ export interface MechanicsFinding { original: string replacement: string explanation: string + // The family the server should file it under (see Fix.family). Always sent, so + // the backend never has to infer it from the endpoint it arrived on. + type: FindingFamily } // ── small text helpers ────────────────────────────────────────────────────── @@ -630,19 +642,438 @@ function thanThen(text: string, out: ProseHint[]) { } } +// "people is" — people is already plural in English, and every language Petal +// pairs with has a singular word for it (人 / a gente / les gens / la gente). +// Universal rather than per-pair for exactly that reason. +const PEOPLE_IS_RE = /\b(people)\s+(is|was|has)\b/gi +const PEOPLE_PLURAL: Record = { is: 'are', was: 'were', has: 'have' } + +function peopleAre(text: string, out: ProseHint[]) { + let m: RegExpExecArray | null + PEOPLE_IS_RE.lastIndex = 0 + while ((m = PEOPLE_IS_RE.exec(text))) { + const plural = PEOPLE_PLURAL[m[2].toLowerCase()] + const fixed = `${m[1]} ${matchCase(m[2], plural)}` + out.push({ + id: `peopleare:${m.index}`, + rule: 'peopleare', + native: P().peopleArePlural(plural), + en: `“People” is plural in English: “people ${plural}”.`, + fix: { from: m.index, to: m.index + m[0].length, replacement: fixed }, + }) + } +} + +// ── preposition pairs ─────────────────────────────────────────────────────── +// The verbs and adjectives whose preposition English simply *decides* for you. +// There is no rule to learn here — "depend" takes "on" and that is the end of +// it — which is exactly what makes the offline pack the right place for them: +// they are data, not judgement, and a lookup is instant. +// +// Every entry is a pairing that is wrong in essentially all contexts. The ones +// that are only *usually* wrong were left out on purpose: "married with" is a +// mistake until "married with children", "arrive to" wants at or in depending +// on the noun, "different than" is ordinary American English. Precision over +// recall — a confident wrong correction costs more than a quiet miss. +interface PrepRule { + // Matched case-insensitively, with \b at both ends. The first group is the + // head word (kept, casing preserved), the rest is replaced wholesale. + re: RegExp + // The corrected phrase, with $1 standing for the captured head word. + to: string +} + +const PREPOSITIONS: PrepRule[] = [ + { re: /\b(depend|depends|depended|depending)\s+of\b/gi, to: '$1 on' }, + { re: /\b(discuss|discusses|discussed|discussing)\s+about\b/gi, to: '$1' }, + { re: /\b(participate|participates|participated|participating)\s+to\b/gi, to: '$1 in' }, + { re: /\b(interested)\s+(?:about|for)\b/gi, to: '$1 in' }, + { re: /\b(responsible)\s+of\b/gi, to: '$1 for' }, + { re: /\b(capable)\s+to\b/gi, to: '$1 of' }, + { re: /\b(afraid)\s+(?:from|of to)\b/gi, to: '$1 of' }, + { re: /\b(according)\s+with\b/gi, to: '$1 to' }, + { re: /\b(explain|explains|explained)\s+(me|us|him|her|them)\b/gi, to: '$1 to $2' }, + // "listen the radio" — the object of "listen" always arrives through "to". + { re: /\b(listen|listens|listened|listening)\s+(the|a|an|my|your|his|her|our|their|this|that|these|those|music|me|him|us|them)\b/gi, to: '$1 to $2' }, +] + +function prepositions(text: string, out: ProseHint[]) { + for (const rule of PREPOSITIONS) { + let m: RegExpExecArray | null + rule.re.lastIndex = 0 + while ((m = rule.re.exec(text))) { + // Rebuild the corrected phrase from the captures so the head word keeps the + // writer's own casing ("Depending of" → "Depending on"). + const fixed = rule.to.replace(/\$(\d)/g, (_, d: string) => m![Number(d)] ?? '') + if (fixed === m[0]) continue + out.push({ + id: `prep:${m.index}:${key(m[0])}`, + rule: 'preposition', + native: P().preposition(m[0].trim(), fixed), + en: `In English it's “${fixed}”, not “${m[0].trim()}”.`, + fix: { from: m.index, to: m.index + m[0].length, replacement: fixed }, + }) + } + } +} + +// Doubled comparatives and superlatives — "more better", "most easiest". The +// -er/-est ending already carries the comparison, so the "more"/"most" is the +// part that goes. An explicit form list rather than a generic /\w+er/ pattern, +// which would catch "more clever" and "most eager" (both perfectly correct). +const COMPARATIVE_FORMS = + 'better|worse|greater|older|younger|bigger|smaller|larger|faster|slower|higher|' + + 'lower|cheaper|stronger|weaker|easier|harder|earlier|later|sooner|longer|' + + 'shorter|taller|richer|poorer|happier|safer|nicer|closer|warmer|colder' +const SUPERLATIVE_FORMS = + 'best|worst|greatest|oldest|youngest|biggest|smallest|largest|fastest|slowest|' + + 'highest|lowest|cheapest|strongest|weakest|easiest|hardest|earliest|latest|' + + 'soonest|longest|shortest|tallest|richest|poorest|happiest|safest|nicest|' + + 'closest|warmest|coldest' +const DOUBLE_COMPARATIVE_RE = new RegExp( + `\\b(more)\\s+(${COMPARATIVE_FORMS})\\b|\\b(most)\\s+(${SUPERLATIVE_FORMS})\\b`, + 'gi', +) + +function doubleComparative(text: string, out: ProseHint[]) { + let m: RegExpExecArray | null + DOUBLE_COMPARATIVE_RE.lastIndex = 0 + while ((m = DOUBLE_COMPARATIVE_RE.exec(text))) { + const lead = m[1] ?? m[3] + const word = m[2] ?? m[4] + out.push({ + id: `doublecomp:${m.index}`, + rule: 'doublecomp', + native: P().doubleComparative(lead, word), + en: `“${word}” is already the comparison — “${lead}” isn't needed: just “${word}”.`, + fix: { from: m.index, to: m.index + m[0].length, replacement: matchCase(lead, word) }, + }) + } +} + +// ── miscollocations (the collocation family, offline half) ────────────────── +// A few dozen entries of curated data doing what the collocation coach does +// with a model behind a VPN. These are the do/make, say/tell, heavy-rain pairs +// that fill every ESL collocation workbook: the writer's grammar is perfect and +// the pairing is simply not the one English uses. +// +// They file as 'collocation', not 'mechanics', because that is what they are — +// and it earns the writer the rest of the family's behaviour for free: the same +// card, and a phrase card planted in the vocabulary garden when she accepts. +// The writer never needs to know which engine spoke. +// +// Each entry is a whole-phrase swap so the card can anchor by string, and the +// object is captured rather than listed, so "do a serious mistake" is caught +// alongside "do a mistake". +interface CollocationRule { + re: RegExp + to: string +} + +const MISCOLLOCATIONS: CollocationRule[] = [ + // do / make — the classic pair, in both directions. + { re: /\b(do|does|did|doing)\s+(a|an|the|my|your|his|her|our|their)\s+(decision|mistake|mistakes|effort|question|questions|progress|joke|jokes|favou?r)\b/gi, to: 'MAKE' }, + { re: /\b(make|makes|made|making)\s+(my|your|his|her|our|their|the)\s+(homework|housework|laundry|dishes|shopping)\b/gi, to: 'DO' }, + { re: /\b(make|makes|made|making)\s+(a|an|the|my|your|his|her|our|their)\s+(photo|photos|picture|pictures|shower|bath|walk|trip|nap|break|exam|exams|test|bus|taxi|train)\b/gi, to: 'TAKE' }, + { re: /\b(make|makes|made|making)\s+(a|an|the)\s+(party|baby|good time|meeting)\b/gi, to: 'HAVE' }, + { re: /\b(make|makes|made|making)\s+(a|an|the|my|your|his|her)\s+(question|questions)\b/gi, to: 'ASK' }, + { re: /\b(make|makes|made|making|do|does|did|doing)\s+attention\b/gi, to: 'PAY_ATTENTION' }, + // say / tell — "say me" for "tell me" is near-universal among learners. + { re: /\b(say|says|said|saying)\s+(me|him|her|us|them)\b/gi, to: 'TELL' }, + { re: /\b(say|says|said|saying)\s+(a|the)\s+(lie|lies|truth|joke|jokes|story|stories)\b/gi, to: 'TELL_A' }, + // Weather and intensity — English picks a different adjective per noun. + { re: /\b(strong|big|hard|huge)\s+(rain|snow|traffic|fog)\b/gi, to: 'HEAVY' }, + { re: /\b(strong|heavy|big)\s+(wind|winds)\b/gi, to: 'STRONG_WIND' }, +] + +// Rebuild the corrected phrase for one miscollocation match. Kept as code rather +// than a `to` template because the verb has to agree with the writer's own tense +// ("did a mistake" → "made a mistake"), and only the verb form knows that. +const VERB_FORMS: Record> = { + make: { base: 'make', s: 'makes', past: 'made', ing: 'making' }, + do: { base: 'do', s: 'does', past: 'did', ing: 'doing' }, + take: { base: 'take', s: 'takes', past: 'took', ing: 'taking' }, + have: { base: 'have', s: 'has', past: 'had', ing: 'having' }, + ask: { base: 'ask', s: 'asks', past: 'asked', ing: 'asking' }, + tell: { base: 'tell', s: 'tells', past: 'told', ing: 'telling' }, + pay: { base: 'pay', s: 'pays', past: 'paid', ing: 'paying' }, +} + +// Which slot of VERB_FORMS the writer's own verb occupies, so the replacement +// lands in the same tense she was writing in. +function verbSlot(verb: string): string { + const v = verb.toLowerCase() + if (v.endsWith('ing')) return 'ing' + if (v === 'did' || v === 'made' || v === 'took' || v === 'had' || v === 'told' || v === 'said' || v === 'paid' || v.endsWith('ed')) return 'past' + if (v === 'does' || v === 'says' || v === 'has' || v.endsWith('s')) return 's' + return 'base' +} + +function collocationFix(m: RegExpExecArray, to: string): string | null { + const slot = verbSlot(m[1]) + const conj = (v: string) => matchCase(m[1], VERB_FORMS[v][slot]) + switch (to) { + case 'MAKE': + case 'DO': + case 'TAKE': + case 'HAVE': + case 'ASK': + return `${conj(to.toLowerCase())} ${m[2]} ${m[3]}` + case 'TELL': + return `${conj('tell')} ${m[2]}` + case 'TELL_A': + return `${conj('tell')} ${m[2]} ${m[3]}` + case 'PAY_ATTENTION': + return `${conj('pay')} attention` + case 'HEAVY': + return `${matchCase(m[1], 'heavy')} ${m[2]}` + case 'STRONG_WIND': + return m[1].toLowerCase() === 'strong' ? null : `${matchCase(m[1], 'strong')} ${m[2]}` + default: + return null + } +} + +function miscollocations(text: string, out: ProseHint[]) { + for (const rule of MISCOLLOCATIONS) { + let m: RegExpExecArray | null + rule.re.lastIndex = 0 + while ((m = rule.re.exec(text))) { + const fixed = collocationFix(m, rule.to) + if (!fixed || fixed.toLowerCase() === m[0].toLowerCase()) continue + out.push({ + id: `colloc:${m.index}:${key(m[0])}`, + rule: 'collocation', + native: P().collocation(m[0].trim(), fixed), + en: `English usually pairs these differently: “${fixed}” rather than “${m[0].trim()}”.`, + fix: { from: m.index, to: m.index + m[0].length, replacement: fixed, family: 'collocation' }, + }) + } + } +} + +// ── false friends ─────────────────────────────────────────────────────────── +// A word that looks like one of hers and means something else. This is the +// mistake that makes a learner feel foolish rather than merely corrected, so +// Petal only ever raises an eyebrow: awareness-only, one per pass, and never a +// replacement. "Actually" really might be the word she wanted — the flag says +// what it means in English and lets her decide. +// +// The list is per pair and lives in the langpack (a zh pair has none: the trap +// needs a shared script to spring). See Pack.falseFriends. +function falseFriends(text: string, out: ProseHint[]) { + const list = pack().falseFriends + const words = Object.keys(list) + if (words.length === 0) return + // Escaped, because these keys are pack data: a future author writing "e.g." + // should get a heads-up, not a pattern that quietly matches everything. + const re = new RegExp(`\\b(${words.map((w) => w.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')).join('|')})\\b`, 'gi') + let m: RegExpExecArray | null + while ((m = re.exec(text))) { + const entry = list[m[1].toLowerCase()] + if (!entry) continue + out.push({ + id: `falsefriend:${m[1].toLowerCase()}`, + rule: 'falsefriend', + native: entry.native, + en: entry.en, + }) + return // one per pass — a heads-up, not a sweep + } +} + +// ── per-pair L1 interference ──────────────────────────────────────────────── +// Mistakes that are not "English mistakes" at all but the writer's own language +// showing through: *ter 30 anos* becomes "have 30 years", 很喜欢 becomes "very +// like", 开灯 becomes "open the light". They are gated by pair precisely so they +// can be confident — a pattern that is a near-certainty for a Portuguese speaker +// is only a guess for anybody else, and a guess doesn't belong in a rule pack. +// +// The rules a pair *doesn't* get are as deliberate as the ones it does. Mandarin +// drops articles and slips he/she, both of which the plan names — and neither is +// detectable from the text alone. "She said he was late" is a perfect sentence +// whichever pronoun was meant, and no offline rule can tell a missing "the" from +// a mass noun. Flagging them would mean correcting correct writing, which is the +// one thing this pack promises not to do. + +const NUMBER_WORD = + 'one|two|three|four|five|six|seven|eight|nine|ten|eleven|twelve|thirteen|' + + 'fourteen|fifteen|sixteen|seventeen|eighteen|nineteen|twenty|thirty|forty|' + + 'fifty|sixty|seventy|eighty|ninety' + +// *Ter X anos* / *avoir X ans* / *tener X años*: age is something you *have* in +// every Romance language and something you *are* in English. +const HAVE_YEARS_RE = new RegExp( + `\\b(I|you|we|they|he|she)\\s+(have|has|had)\\s+(\\d{1,3}|${NUMBER_WORD})\\s+years(\\s+old)?\\b`, + 'gi', +) + +// The English "to be" that matches the subject and the tense she wrote in. +function beFor(subject: string, verb: string): string { + const past = verb.toLowerCase() === 'had' + const s = subject.toLowerCase() + if (s === 'i') return past ? 'was' : 'am' + if (s === 'he' || s === 'she') return past ? 'was' : 'is' + return past ? 'were' : 'are' +} + +function haveYears(text: string, out: ProseHint[]) { + let m: RegExpExecArray | null + HAVE_YEARS_RE.lastIndex = 0 + while ((m = HAVE_YEARS_RE.exec(text))) { + const fixed = `${m[1]} ${beFor(m[1], m[2])} ${m[3]} years old` + out.push({ + id: `haveyears:${m.index}`, + rule: 'haveyears', + native: P().ageIsNotHave(m[3]), + en: `In English you *are* your age: “${fixed}”.`, + fix: { from: m.index, to: m.index + m[0].length, replacement: fixed }, + }) + } +} + +// *Estou de acordo* / *je suis d'accord*: agreement is a verb in English, so the +// "to be" in front of it has nothing to do. +const AM_AGREE_RE = /\b(I|you|we|they|he|she)\s+(am|are|is|was|were)\s+agree\b/gi + +function amAgree(text: string, out: ProseHint[]) { + let m: RegExpExecArray | null + AM_AGREE_RE.lastIndex = 0 + while ((m = AM_AGREE_RE.exec(text))) { + const past = /^(was|were)$/i.test(m[2]) + const verb = past ? 'agreed' : m[1].toLowerCase() === 'he' || m[1].toLowerCase() === 'she' ? 'agrees' : 'agree' + const fixed = `${m[1]} ${verb}` + out.push({ + id: `amagree:${m.index}`, + rule: 'amagree', + native: P().agreeIsAVerb, + en: `“Agree” is already the verb: “${fixed}”, not “${m[0].trim()}”.`, + fix: { from: m.index, to: m.index + m[0].length, replacement: fixed }, + }) + } +} + +// *desde há três anos* / *depuis trois ans*: a stretch of time takes "for"; +// "since" wants the moment it started. +const SINCE_DURATION_RE = new RegExp( + `\\b(since)\\s+((?:\\d{1,3}|${NUMBER_WORD}|a few|several|many)\\s+(?:years|months|weeks|days|hours|minutes))\\b`, + 'gi', +) + +function sinceDuration(text: string, out: ProseHint[]) { + let m: RegExpExecArray | null + SINCE_DURATION_RE.lastIndex = 0 + while ((m = SINCE_DURATION_RE.exec(text))) { + const fixed = `${matchCase(m[1], 'for')} ${m[2]}` + out.push({ + id: `since:${m.index}`, + rule: 'since', + native: P().forNotSince(m[2]), + en: `For a length of time use “for”: “${fixed}”. “Since” names when it started.`, + fix: { from: m.index, to: m.index + m[0].length, replacement: fixed }, + }) + } +} + +// 很喜欢 — 很 modifies adjectives *and* verbs in Mandarin, so "very" arrives in +// front of English verbs, where it cannot go. +const VERY_VERB_RE = + /\b(very)\s+(like|likes|liked|want|wants|wanted|enjoy|enjoys|enjoyed|hope|hopes|hoped|miss|misses|missed|need|needs|needed|love|loves|loved|agree|agrees|agreed|understand|understands)\b/gi + +function veryVerb(text: string, out: ProseHint[]) { + let m: RegExpExecArray | null + VERY_VERB_RE.lastIndex = 0 + while ((m = VERY_VERB_RE.exec(text))) { + const fixed = `${matchCase(m[1], 'really')} ${m[2]}` + out.push({ + id: `veryverb:${m.index}`, + rule: 'veryverb', + native: P().veryBeforeVerb(m[2]), + en: `“Very” goes with adjectives, not verbs — “${fixed}” (or “${m[2]} … very much”).`, + fix: { from: m.index, to: m.index + m[0].length, replacement: fixed }, + }) + } +} + +// 开灯 / 关电视 — Mandarin opens and closes appliances; English turns them on +// and off. +const TURN_FORMS: Record = { + base: 'turn', + s: 'turns', + past: 'turned', + ing: 'turning', +} +const OPEN_LIGHT_RE = + /\b(open|opens|opened|close|closes|closed)\s+(the|a|my|your|his|her|our|their)\s+(light|lights|lamp|tv|television|radio|computer|fan|heater|air conditioner|air-conditioner)\b/gi + +function openTheLight(text: string, out: ProseHint[]) { + let m: RegExpExecArray | null + OPEN_LIGHT_RE.lastIndex = 0 + while ((m = OPEN_LIGHT_RE.exec(text))) { + const opening = m[1].toLowerCase().startsWith('open') + const turn = matchCase(m[1], TURN_FORMS[verbSlot(m[1])] ?? 'turn') + const fixed = `${turn} ${opening ? 'on' : 'off'} ${m[2]} ${m[3]}` + out.push({ + id: `openlight:${m.index}`, + rule: 'openlight', + native: P().turnOnNotOpen(m[3], opening), + en: `In English you turn a ${m[3]} ${opening ? 'on' : 'off'}: “${fixed}”.`, + fix: { from: m.index, to: m.index + m[0].length, replacement: fixed }, + }) + } +} + +// 虽然…但是 is a matched pair in Mandarin; English takes one or the other, never +// both. Awareness-only: which half to drop is the writer's call, and the "but" +// on its own is far too common a word to anchor a card to. +const ALTHOUGH_BUT_RE = /\b(although|though|even though)\b[^.!?]{0,120}?,?\s+but\b/gi + +function althoughBut(text: string, out: ProseHint[]) { + let m: RegExpExecArray | null + ALTHOUGH_BUT_RE.lastIndex = 0 + while ((m = ALTHOUGH_BUT_RE.exec(text))) { + out.push({ + id: `althoughbut:${key(m[0])}`, + rule: 'althoughbut', + native: P().althoughOrBut(m[1]), + en: `English uses “${m[1]}” or “but”, not both — one of them can go.`, + }) + return + } +} + +type Rule = (text: string, out: ProseHint[]) => void + +// Which interference rules belong to which pair. A pair with no entry simply +// runs the shared pack, which is the correct behaviour for a language Petal has +// not studied yet rather than a gap to fill with guesses. +const L1_RULES: Partial> = { + zh: [veryVerb, openTheLight, althoughBut], + 'pt-PT': [haveYears, amAgree, sinceDuration], + fr: [haveYears, amAgree, sinceDuration], + es: [haveYears, amAgree, sinceDuration], +} + // ── orchestration ─────────────────────────────────────────────────────────── // Rules run in priority order — the ones the writer cares most about first, so // that when several fire at once the companion leads with the weightiest note. +// The false-friend heads-up sits near the top: of everything here it is the one +// that costs her most to find out about later. const RULES: Array<(text: string, out: ProseHint[]) => void> = [ runOns, commaSplices, + falseFriends, antecedents, oxford, + miscollocations, articles, uncountables, properCaps, subjectVerbAgreement, + peopleAre, + prepositions, + doubleComparative, pluralAfterNumber, doubleDeterminer, thereIsPlural, @@ -656,6 +1087,13 @@ const RULES: Array<(text: string, out: ProseHint[]) => void> = [ spaceAfterPunct, ] +// Every rule that runs for this writer: the shared pack, plus the interference +// rules belonging to her pair. Read per call rather than built once — the pair +// isn't known until /api/me answers, and the checker runs long before and after. +function rulesFor(): Rule[] { + return [...RULES, ...(L1_RULES[pack().code] ?? [])] +} + // analyzeProse returns context-aware hints, highest-priority first. It bails on // text too short to advise on (mid-thought drafts shouldn't get picked apart). // Hints that carry a `fix` are applyable (they also surface as suggestion cards); @@ -664,26 +1102,27 @@ export function analyzeProse(text: string): ProseHint[] { const englishWords = text.match(ENGLISH_WORD_RE)?.length ?? 0 if (englishWords < 8) return [] const out: ProseHint[] = [] - for (const rule of RULES) rule(text, out) + for (const rule of rulesFor()) rule(text, out) return out } // mechanicsFindings returns every applyable deterministic fix in the text, as // suggestion-card findings with exact spans. No word-count floor: a doubled word // or a stray lowercase “i” is worth fixing even in a short draft, the way a -// spell-checker would. The card pipeline persists these as the 'mechanics' -// family; collisions with the LLM cards are resolved server-side (mechanics -// wins, since its span is exact). +// spell-checker would. The card pipeline persists these under the family each +// finding names (mechanics, or collocation for the miscollocation rules); +// collisions with the LLM cards are resolved server-side (the offline card wins, +// since its span is exact). export function mechanicsFindings(text: string): MechanicsFinding[] { const hints: ProseHint[] = [] - for (const rule of RULES) rule(text, hints) + for (const rule of rulesFor()) rule(text, hints) const found: MechanicsFinding[] = [] for (const h of hints) { if (!h.fix) continue - const { from, to, replacement } = h.fix + const { from, to, replacement, family } = h.fix const original = text.slice(from, to) if (!original || original === replacement) continue - found.push({ from, to, original, replacement, explanation: h.en }) + found.push({ from, to, original, replacement, explanation: h.en, type: family ?? 'mechanics' }) } // Two rules can occasionally claim overlapping spans (e.g. a doubled word that // also reads as stacked determiners). Resolve to one card per stretch of text: diff --git a/web/src/components/Companion/tips.ts b/web/src/components/Companion/tips.ts index ad92fb8..e2ccde8 100644 --- a/web/src/components/Companion/tips.ts +++ b/web/src/components/Companion/tips.ts @@ -18,6 +18,8 @@ export const errors = (): Line[] => pack().companion.errors export const greeting = (): Line => pack().companion.greeting export const welcomeBack = (): Line => pack().companion.welcomeBack export const milestoneLine = (n: number): Line => pack().companion.milestone(n) +export const invitations = (): Line[] => pack().companion.invitations +export const declined = (): Line => pack().companion.declined // Word-count milestones worth a little cheer — every 100 words, on up. A count, // not copy: the same in every language. diff --git a/web/src/components/Companion/useCompanion.ts b/web/src/components/Companion/useCompanion.ts index f19dcc0..5002873 100644 --- a/web/src/components/Companion/useCompanion.ts +++ b/web/src/components/Companion/useCompanion.ts @@ -4,15 +4,18 @@ import { MILESTONES, bedtime, breaks, + declined, encouragements, errors, greeting, + invitations, milestoneLine, pick, tips, welcomeBack, type Line, } from './tips' +import { markInvited, mayInvite } from './invitation' import { analyzeProse } from './prose' import { personalCheer, warmPersonalCheers } from './journalCheers' import { playPop, playSound, type SoundName } from '../../audio/sounds' @@ -22,9 +25,12 @@ import { isBedtime } from '../../lib/night' // otherwise to an emoji placeholder (see PetalCompanion). export type Mood = 'idle' | 'happy' | 'talking' | 'sleeping' | 'celebrate' -export type BubbleTone = 'cheer' | 'tip' | 'break' | 'error' | 'bedtime' +export type BubbleTone = 'cheer' | 'tip' | 'break' | 'error' | 'bedtime' | 'invite' export interface Bubble extends Line { tone: BubbleTone + // Present only on the daily invitation: the two answers it can be given. The + // bubble is otherwise a thing to read, so this is the one that grows buttons. + invite?: { prompt: string } } interface Signals { @@ -40,6 +46,10 @@ interface Signals { // The document's plain text, used by the rules-based prose checker to offer // context-aware writing notes instead of only generic tips. text: string + // True when the open document is still empty — she has Petal in front of her + // and nothing started. The only condition under which the daily invitation is + // offered: a writer already mid-paragraph does not need to be invited. + blankPage: boolean } // Timing knobs (ms). Tuned to feel present but never naggy. @@ -48,6 +58,18 @@ const BREAK_MS = 25 * 60_000 // continuous writing → suggest a break const TIP_MIN_GAP = 4 * 60_000 // at most one spontaneous tip per this window const PROACTIVE_GAP = 40_000 // floor between any two unsolicited bubbles const BEDTIME_GAP = 30 * 60_000 // at most one "go to bed" nudge per this window +// How long an empty page sits there before the kitten offers something to write +// about. Long enough that a writer who opened Petal knowing what she wanted to +// say is already typing, short enough to still be an offer rather than an +// interruption. +const INVITE_AFTER_MS = 50_000 +// …and the window closes: past this the session has its own shape, and an +// invitation would be arriving out of nowhere. +const INVITE_WINDOW_MS = 8 * 60_000 + +// The once-a-day rule itself lives in ./invitation — it is the part of this +// feature with a promise in it (a date, never a streak), and it reads better +// stated once than tangled into the heartbeat below. // The late-night window itself (isBedtime) lives in ../../lib/night so the // companion nag and the night-mode theme/starfall share one definition. // How long a bubble lingers. These are *floors* — readBubbleMs extends them by @@ -64,7 +86,17 @@ const now = () => Date.now() // length of the native + English lines so denser advice stays up long enough // to actually finish reading. function readBubbleMs(b: Bubble): number { - const base = b.tone === 'cheer' ? CHEER_MS : b.tone === 'bedtime' ? BUBBLE_MS + 4_000 : BUBBLE_MS + // An invitation is the one bubble with a decision in it, so it gets the + // longest look — and it still leaves on its own, which is a third way of + // saying no that costs nothing. + const base = + b.tone === 'cheer' + ? CHEER_MS + : b.tone === 'invite' + ? BUBBLE_MS + 12_000 + : b.tone === 'bedtime' + ? BUBBLE_MS + 4_000 + : BUBBLE_MS const chars = b.native.length + b.en.length return Math.min(MAX_BUBBLE_MS, base + chars * READ_MS_PER_CHAR) } @@ -72,7 +104,15 @@ function readBubbleMs(b: Bubble): number { // useCompanion is the behavior engine: it watches writing signals and decides // when the kitten speaks, what mood it shows, and how to pace itself so the // companion feels alive without interrupting. UI-agnostic — returns state only. -export function useCompanion({ wordCount, saveStatus, llmDown, editTick, acceptTick, text }: Signals) { +export function useCompanion({ + wordCount, + saveStatus, + llmDown, + editTick, + acceptTick, + text, + blankPage, +}: Signals) { const [mood, setMood] = useState('idle') const [bubble, setBubble] = useState(null) @@ -84,6 +124,7 @@ export function useCompanion({ wordCount, saveStatus, llmDown, editTick, acceptT const lastBedtime = useRef(0) const nextMilestone = useRef(0) // index into MILESTONES const sleeping = useRef(false) + const invited = useRef(false) // this session, alongside the stored date // Latest text for the prose checker, read lazily by the heartbeat (kept in a // ref so per-keystroke changes don't re-arm the interval). @@ -144,6 +185,27 @@ export function useCompanion({ wordCount, saveStatus, llmDown, editTick, acceptT return { ...pick(tips()), tone: 'tip' } }, []) + // The daily invitation. Accepting hands the prompt back to the app (it titles + // the blank page with it, so the question stays in view while she answers it); + // declining costs nothing at all and says so. Either answer spends the day's + // one invitation — being asked twice after saying no would make "no" a + // negotiation. + const blankRef = useRef(blankPage) + blankRef.current = blankPage + const onInviteRef = useRef<((prompt: string) => void) | undefined>(undefined) + + const acceptInvite = useCallback((prompt: string) => { + clearTimeout(bubbleTimer.current) + clearTimeout(moodTimer.current) + setBubble(null) + setMood('happy') + onInviteRef.current?.(prompt) + }, []) + + const declineInvite = useCallback(() => { + say({ ...declined(), tone: 'tip' }) + }, [say]) + const dismiss = useCallback(() => { clearTimeout(bubbleTimer.current) clearTimeout(moodTimer.current) @@ -264,6 +326,26 @@ export function useCompanion({ wordCount, saveStatus, llmDown, editTick, acceptT const t = now() const idleFor = t - lastActivity.current + // An empty page, a little way into the session, and no invitation yet + // today: offer something small to write about. Checked before the idle + // branch on purpose — sitting in front of a blank page without typing is + // exactly the state this is for, and it is the one state the nap rule + // would otherwise swallow. + const sinceStart = t - sessionStart.current + if ( + blankRef.current && + !invited.current && + sinceStart > INVITE_AFTER_MS && + sinceStart < INVITE_WINDOW_MS && + mayInvite() + ) { + invited.current = true + markInvited() + const line = pick(invitations()) + say({ ...line, tone: 'invite', invite: { prompt: line.en } }) + return + } + if (idleFor > IDLE_MS) { sleeping.current = true if (!bubble) setMood('sleeping') @@ -297,6 +379,12 @@ export function useCompanion({ wordCount, saveStatus, llmDown, editTick, acceptT return () => clearInterval(id) }, [bubble, say, nextTip]) + // The app's handler for an accepted invitation, kept in a ref so a new + // callback identity never re-arms the heartbeat. + const setInviteHandler = useCallback((fn: (prompt: string) => void) => { + onInviteRef.current = fn + }, []) + useEffect( () => () => { clearTimeout(bubbleTimer.current) @@ -305,5 +393,14 @@ export function useCompanion({ wordCount, saveStatus, llmDown, editTick, acceptT [], ) - return { mood, bubble, dismiss, holdBubble, releaseBubble } + return { + mood, + bubble, + dismiss, + holdBubble, + releaseBubble, + acceptInvite, + declineInvite, + setInviteHandler, + } } diff --git a/web/src/components/Editor/WordCard.tsx b/web/src/components/Editor/WordCard.tsx index a23d3b9..f8e02d0 100644 --- a/web/src/components/Editor/WordCard.tsx +++ b/web/src/components/Editor/WordCard.tsx @@ -32,7 +32,12 @@ export function WordCard({ word, info, loading, saved, onToggleSave, style, onRe const reverse = info?.reverse ?? null // Null whenever the dictionary has no opinion — the chip then doesn't render. const band = info ? wordBand(info.frequency ?? 0, info.difficulty ?? -1) : null - const empty = !loading && !gloss && !reverse && definitions.length === 0 && synonyms.length === 0 + // A word that looks like one of hers and means something else. Curated per + // pair (the zh pack has none), and shown the moment she looks the word up — + // which is the moment she is deciding whether to trust it. + const falseFriend = t.falseFriends[word.toLowerCase()] ?? null + const empty = + !loading && !gloss && !reverse && !falseFriend && definitions.length === 0 && synonyms.length === 0 return (
)} + {/* The false-friend heads-up, above the definition because it is the one + thing here she might otherwise not think to check. Deliberately not a + warning: no red, no exclamation, and nothing to accept or dismiss — + the word may well be exactly the one she meant, and the card's job is + only to make sure she knows what it says in English. */} + {falseFriend && ( +
+

+ + 🫖 + + {falseFriend.native} +

+

+ {falseFriend.en} +

+
+ )} + {/* Chinese gloss first — it's what the Mandarin-speaking writer reaches for. */} {gloss && (

{ expect(ptPT.editor.alsoIn).not.toBe(zh.editor.alsoIn) }) }) + +// False friends are a per-pair dataset rather than copy: the Latin pairs carry +// the traps their writers actually fall into, and the zh pair legitimately has +// none. Both halves of that are worth pinning. +describe('false friends', () => { + it('the zh pair has none, because the trap needs a shared script', () => { + expect(Object.keys(zh.falseFriends)).toHaveLength(0) + }) + + it('the pt-PT pair carries the ones that cost most', () => { + // Not an exhaustive list — these are the four every European Portuguese + // speaker meets in their first month of writing English. + for (const word of ['actually', 'pretend', 'realize', 'library']) { + expect(ptPT.falseFriends[word], word).toBeDefined() + } + expect(Object.keys(ptPT.falseFriends).length).toBeGreaterThan(10) + }) + + it('is keyed by the lowercase English word, so a lookup can find it', () => { + for (const p of PACKS) { + for (const key of Object.keys(p.falseFriends)) { + expect(key, `${p.code}: "${key}" must be lowercase`).toBe(key.toLowerCase()) + expect(p.falseFriends[key].native.trim(), key).not.toBe('') + expect(p.falseFriends[key].en.trim(), key).not.toBe('') + } + } + }) + + // The heads-up must never read as an accusation: she may well have meant the + // word. It says what the English one means and stops there. + it('never tells her she is wrong', () => { + const forbidden = /wrong|mistake|error|incorrect|don't use|do not use|errado|erro|incorrecto/i + for (const p of PACKS) { + for (const [key, line] of Object.entries(p.falseFriends)) { + expect(line.native, `${p.code}/${key}`).not.toMatch(forbidden) + expect(line.en, `${p.code}/${key}`).not.toMatch(forbidden) + } + } + }) +}) diff --git a/web/src/i18n/packs/pt-PT.ts b/web/src/i18n/packs/pt-PT.ts index 7f34a2b..eb8df81 100644 --- a/web/src/i18n/packs/pt-PT.ts +++ b/web/src/i18n/packs/pt-PT.ts @@ -115,6 +115,23 @@ export const ptPT: Pack = { en: `Wow — ${words} words already! Amazing. 🎉`, }), + // Uma coisa pequena para escrever, oferecida uma vez por dia a uma página + // em branco. Memórias e opiniões, nunca exercícios: não há aqui nada que se + // possa errar, que é precisamente a intenção. + invitations: [ + { native: 'Escreve 50 palavras: uma coisa pequena que te fez sorrir hoje 🌸', en: 'Write 50 words: one small thing that made you smile today.' }, + { native: 'Escreve 50 palavras: a melhor coisa que comeste hoje', en: 'Write 50 words: the best thing you ate today.' }, + { native: 'Escreve 50 palavras: o que vês da tua janela neste momento', en: 'Write 50 words: what you can see out of your window right now.' }, + { native: 'Escreve 50 palavras: um sítio onde voltarias de bom grado', en: 'Write 50 words: somewhere you would happily go back to.' }, + { native: 'Escreve 50 palavras: uma coisa que aprendeste esta semana', en: 'Write 50 words: one thing you learned this week.' }, + { native: 'Escreve 50 palavras: algo para dizeres a ti própria daqui a um ano', en: 'Write 50 words: something to tell yourself a year from now.' }, + { native: 'Escreve 50 palavras: uma música que tens ouvido ultimamente', en: 'Write 50 words: a song you have had on lately.' }, + { native: 'Escreve 50 palavras: alguém a quem gostavas de agradecer hoje', en: 'Write 50 words: someone you would like to thank today.' }, + ], + inviteAccept: 'Vamos lá · Let’s write', + inviteDecline: 'Hoje não · Not today', + declined: { native: 'Está bem, volto a dormir 😴', en: 'Fair enough — back to my nap. 😴' }, + names: { cat: 'Gato dorminhoco', dog: 'Cão contente', @@ -146,6 +163,99 @@ export const ptPT: Pack = { itsOwn: '“it’s” = “it is”. Para dizer “o seu / dele”, é “its” — portanto “its own”.', itsIs: (rest) => `Aqui é “it’s ${rest}” (it is); “its” é o possessivo.`, thanNotThen: (word) => `Nas comparações usa-se “than”, não “then”: “${word} than”.`, + preposition: (wrong, right) => `Em inglês diz-se “${right}”, não “${wrong}” — esta preposição é fixa.`, + collocation: (wrong, right) => `Em inglês estas palavras andam juntas assim: “${right}”, e não “${wrong}”.`, + doubleComparative: (lead, word) => `“${word}” já é o comparativo — não precisa do “${lead}”: basta “${word}”.`, + peopleArePlural: (verb) => `“People” é plural em inglês: “people ${verb}”.`, + ageIsNotHave: (years) => `Em inglês a idade é com o verbo *to be*, não com *have*: “I am ${years} years old”.`, + agreeIsAVerb: '“Agree” já é o verbo — não leva *to be* à frente: diz-se “I agree”.', + forNotSince: (duration) => `Para uma duração usa-se “for”: “for ${duration}”. O “since” marca o início (since 2020).`, + veryBeforeVerb: (verb) => `“Very” só acompanha adjectivos, não verbos: “really ${verb}”, ou “${verb} … very much”.`, + turnOnNotOpen: (thing, on) => `Em inglês os aparelhos não se abrem nem se fecham — ligam-se e desligam-se: “turn ${on ? 'on' : 'off'} the ${thing}”.`, + althoughOrBut: (word) => `Em inglês usa-se “${word}” ou “but”, nunca os dois na mesma frase.`, + }, + + // Os falsos amigos entre o português e o inglês — a armadilha que faz uma + // pessoa sentir-se tola em vez de apenas corrigida. Por isso são só um aviso: + // o Petal nunca troca a palavra, porque “actually” pode muito bem ser mesmo a + // que ela queria. + falseFriends: { + actually: { + native: '“Actually” quer dizer *na verdade*, não *actualmente*. Para “actualmente” diz-se “currently” / “nowadays”.', + en: '“Actually” means *in fact*. For the Portuguese *actualmente*, English uses “currently”.', + }, + eventually: { + native: '“Eventually” quer dizer *por fim, mais cedo ou mais tarde* — não *eventualmente*. Para isso: “possibly” ou “if necessary”.', + en: '“Eventually” means *in the end*, not *possibly*.', + }, + pretend: { + native: '“Pretend” é *fingir*. Para *pretender* diz-se “intend” ou “plan to”.', + en: '“Pretend” means to fake something; the Portuguese *pretender* is “intend”.', + }, + realize: { + native: '“Realize” é *aperceber-se*. Para *realizar* (fazer, levar a cabo) diz-se “carry out” ou “achieve”.', + en: '“Realize” means to become aware; *realizar* is “carry out”.', + }, + assist: { + native: '“Assist” é *ajudar*. Para *assistir a* (ver) diz-se “attend” ou “watch”.', + en: '“Assist” means to help; *assistir a* is “attend” or “watch”.', + }, + library: { + native: '“Library” é a *biblioteca*. A *livraria* é “bookshop” / “bookstore”.', + en: '“Library” is where books are lent; a shop that sells them is a “bookshop”.', + }, + parents: { + native: '“Parents” são só o pai e a mãe. Os *parentes* são “relatives”.', + en: '“Parents” means mother and father only; *parentes* are “relatives”.', + }, + sensible: { + native: '“Sensible” é *sensato*. Para *sensível* diz-se “sensitive”.', + en: '“Sensible” means level-headed; *sensível* is “sensitive”.', + }, + push: { + native: '“Push” é *empurrar* — o contrário de *puxar* (“pull”).', + en: '“Push” is the opposite of *puxar*, which is “pull”.', + }, + college: { + native: '“College” é o ensino superior. O *colégio* (escola) é “school”.', + en: '“College” is higher education; a *colégio* is a “school”.', + }, + exit: { + native: '“Exit” é a *saída*. O *êxito* é “success”.', + en: '“Exit” is the way out; *êxito* is “success”.', + }, + fabric: { + native: '“Fabric” é o *tecido*. A *fábrica* é “factory”.', + en: '“Fabric” is cloth; a *fábrica* is a “factory”.', + }, + lecture: { + native: '“Lecture” é uma *aula / palestra*. A *leitura* é “reading”.', + en: '“Lecture” is a talk; *leitura* is “reading”.', + }, + notice: { + native: '“Notice” é *reparar* ou um *aviso*. A *notícia* é “news”.', + en: '“Notice” is to spot something; *notícia* is “news”.', + }, + support: { + native: '“Support” é *apoiar*. Para *suportar* (aguentar) diz-se “put up with” ou “bear”.', + en: '“Support” means to back someone up; *suportar* is to “bear”.', + }, + costume: { + native: '“Costume” é um *disfarce / traje*. Os *costumes* são “customs” ou “habits”.', + en: '“Costume” is fancy dress; *costumes* are “customs”.', + }, + deception: { + native: '“Deception” é *engano*. A *decepção* é “disappointment”.', + en: '“Deception” means being misled; *decepção* is “disappointment”.', + }, + injury: { + native: '“Injury” é uma *lesão*. A *injúria* é “insult”.', + en: '“Injury” is physical harm; *injúria* is an “insult”.', + }, + balcony: { + native: '“Balcony” é a *varanda*. O *balcão* é “counter”.', + en: '“Balcony” is a terrace; a *balcão* is a “counter”.', + }, }, docs: { diff --git a/web/src/i18n/packs/zh.ts b/web/src/i18n/packs/zh.ts index b93c45d..7f3c1ed 100644 --- a/web/src/i18n/packs/zh.ts +++ b/web/src/i18n/packs/zh.ts @@ -99,6 +99,25 @@ export const zh: Pack = { en: `Wow — ${words} words already! Amazing. 🎉`, }), + // A small thing to write about, offered once a day to an empty page. Each + // one is a memory or an opinion rather than an exercise — nothing here can + // be got wrong, which is the point. Fifty words is a deliberately small ask. + invitations: [ + { native: '写 50 个字:今天让你微笑的一件小事 🌸', en: 'Write 50 words: one small thing that made you smile today.' }, + { native: '写 50 个字:今天吃到最好吃的东西', en: 'Write 50 words: the best thing you ate today.' }, + { native: '写 50 个字:你现在窗外看到了什么?', en: "Write 50 words: what you can see out of your window right now." }, + { native: '写 50 个字:一个你很想再去一次的地方', en: 'Write 50 words: somewhere you would happily go back to.' }, + { native: '写 50 个字:这个星期学到的一件事', en: 'Write 50 words: one thing you learned this week.' }, + { native: '写 50 个字:给一年后的自己说句话', en: 'Write 50 words: something to tell yourself a year from now.' }, + { native: '写 50 个字:最近听到的一首歌', en: 'Write 50 words: a song you have had on lately.' }, + { native: '写 50 个字:今天想谢谢谁?', en: 'Write 50 words: someone you would like to thank today.' }, + ], + inviteAccept: '好,来写 · Let’s write', + inviteDecline: '今天不了 · Not today', + // The entire consequence of saying no. No streak was broken, because there + // is no streak. + declined: { native: '好吧,我继续睡 😴', en: 'Fair enough — back to my nap. 😴' }, + // Keyed by the companion ids in Companion/companions.ts. names: { cat: '瞌睡猫', @@ -131,8 +150,23 @@ export const zh: Pack = { itsOwn: '“it’s” = “it is”;表示“它的”要用 “its”,所以是 “its own”。', itsIs: (rest) => `这里应该是 “it’s ${rest}”(it is),“its” 是“它的”。`, thanNotThen: (word) => `比较的时候用 “than”,不是 “then”:“${word} than”。`, + preposition: (wrong, right) => `英文习惯说 “${right}”,不是 “${wrong}”,这个介词是固定搭配。`, + collocation: (wrong, right) => `英文里这两个词一般这样搭:“${right}”,而不是 “${wrong}”。`, + doubleComparative: (lead, word) => `“${word}” 本身已经是比较级了,前面不用再加 “${lead}”:写 “${word}” 就好。`, + peopleArePlural: (verb) => `“people” 在英文里是复数,要用 “people ${verb}”。`, + ageIsNotHave: (years) => `英文说年龄用 be 动词,不是 have:“I am ${years} years old”。`, + agreeIsAVerb: '“agree” 本身就是动词,前面不用加 be:说 “I agree” 就好。', + forNotSince: (duration) => `表示持续了多久用 “for”:“for ${duration}”;“since” 后面接的是起点(since 2020)。`, + veryBeforeVerb: (verb) => `“very” 只能修饰形容词,不能直接放在动词前面:可以说 “really ${verb}”,或者 “${verb} … very much”。`, + turnOnNotOpen: (thing, on) => `英文里电器是 turn ${on ? 'on' : 'off'},不用 open/close:“turn ${on ? 'on' : 'off'} the ${thing}”。`, + althoughOrBut: (word) => `英文里 “${word}” 和 “but” 只用一个就够了,不像中文的“虽然…但是…”。`, }, + // The zh pair has no false friends: the trap needs a shared script, and 汉字 + // and the Latin alphabet never look alike. An empty list here is the honest + // answer, not an unwritten one. + falseFriends: {}, + docs: { sortRecent: '最近 · Recent', sortTitle: '标题 · Title', diff --git a/web/src/i18n/types.ts b/web/src/i18n/types.ts index 8ef52a4..9f3b3cc 100644 --- a/web/src/i18n/types.ts +++ b/web/src/i18n/types.ts @@ -69,6 +69,13 @@ export interface Pack { milestone: (words: number) => Line // Mascot names, keyed by the companion ids in companions.ts. names: Record + // A once-a-day invitation to write, offered only to a blank page (§5c). + // Explicitly no streaks and no guilt: `declined` is the sleepy, entirely + // untroubled reply to "not today", and it is the only consequence there is. + invitations: Line[] + inviteAccept: string + inviteDecline: string + declined: Line } // Rule-based prose notes (see Companion/prose.ts). Detection is English @@ -95,8 +102,37 @@ export interface Pack { itsOwn: string itsIs: (rest: string) => string thanNotThen: (word: string) => string + // Grammar-lite, the offline half of the suggestion rail. `preposition` and + // `collocation` are single templates rather than a line per entry: the rule + // pack behind them is a few dozen curated pairs, and a pack author should be + // able to say "English says X, not Y" once and have every pair read well. + preposition: (wrong: string, right: string) => string + collocation: (wrong: string, right: string) => string + doubleComparative: (lead: string, word: string) => string + peopleArePlural: (verb: string) => string + // Interference from the writer's own language (see prose.ts L1_RULES). Every + // pack owns all of these even though a pair only ever runs some of them — + // the copy is cheap, and the day Petal decides a rule is safe for another + // pair it should not also need new strings. + ageIsNotHave: (years: string) => string + agreeIsAVerb: string + forNotSince: (duration: string) => string + veryBeforeVerb: (verb: string) => string + turnOnNotOpen: (thing: string, on: boolean) => string + althoughOrBut: (word: string) => string } + // Words that look like one of hers and mean something else, keyed by the + // lowercase English word. This is the mistake that makes a learner feel + // foolish rather than merely corrected, so both surfaces are heads-up only: + // a quiet block in the WordCard, and at most one gentle companion note per + // pass. Petal never "fixes" one — *actually* may well be the word she meant. + // + // A pair needs a shared script for the trap to spring at all, so the zh pack + // is legitimately empty; the Latin pairs carry the two dozen that account for + // most of the damage. + falseFriends: Record + docs: { sortRecent: string sortTitle: string