Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1f4ca4775a | ||
|
|
1bbc8fc8d3 | ||
|
|
e9b8595456 |
+59
-7
@@ -255,14 +255,65 @@ 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).
|
||||
- [ ] **Growth journal** (Q3 settled): local aggregation over accepted suggestions; growth-only, self-comparison-only framing; feeds companion cheers
|
||||
- [ ] **Plant accepted collocations** in the vocabulary garden as phrase cards (scheduler unchanged)
|
||||
- [ ] **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)
|
||||
**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.
|
||||
- **Faded** — a pattern corrected ≥2× in the earlier window and not since. **Guarded by "has she written lately?"**: without that check, a month away from Petal is reported back to her as progress, which is the one way this feature could lie. Test named for the guard, not the query.
|
||||
- **The dates had to come from her decision, not the model's proposal** — migration `0012_suggestion_resolved_at`. `created_at` is when a checkpoint *offered* an edit; a suggestion offered in April and accepted in June is June's growth. Existing rows backfill to `created_at`, which is exactly the approximation the journal would otherwise have had to make (and is very nearly right — edits are settled minutes after a checkpoint); pending rows keep NULL, because nothing has been decided. Tested against a database rewound to before the column, since that is the only shape the live box will ever present.
|
||||
- **Surface**: a second tab *inside* the garden (🌷 Garden / 🌱 Growth) rather than new chrome — same idea seen twice, the garden as objects and the journal as change over time. A review session hides the tabs: mid-flashcard is no moment to be offered a different page.
|
||||
- **Feeds the companion**, which was the point: on an accept the kitten prefers a line that is true of *her* ("you're using 'make a decision' on your own now! 🌱") over one that would fit anybody — half the time, so it stays a surprise, once per line per session, so personal praise never becomes wallpaper. The journal is fetched on the first accept and **never awaited**: the cheer goes out now, personal or not.
|
||||
- Copy is bound by the same two rules as the SQL, and a test greps both packs for *error/mistake/wrong/streak/average/erro/errada/错误* — the framing is the feature, and it's the part a future edit would quietly undo.
|
||||
- [x] **Plant accepted collocations** in the vocabulary garden as phrase cards ✅ (2026-07-27) — scheduler untouched, as predicted: `vocab.Plant` writes the same row `capture` does, so a three-word chunk climbs the SM-2-lite ladder exactly like a looked-up word, blossoms with `reps`, and cloze-blanks in review. The garden now holds both halves of learning — what she sought out, and what she was gently given.
|
||||
- **Only collocations.** The other families fix *this* sentence (a comma, "their"→"there"); a collocation is the one that hands over something reusable, and reusable is the only thing worth reviewing in a week.
|
||||
- **What isn't a chunk**: `PhraseKey` rejects single words (that's word choice, and lookup already gardens it), anything over 6 words or 60 runes (a rewritten sentence wearing a collocation's label makes a miserable flashcard), and digit/symbol-only text. The cap counts **runes** — a byte cap would drop Portuguese chunks for being accented.
|
||||
- **The example is the *corrected* sentence.** The stored `content_text` is still the pre-accept draft (the client applies the replacement in the editor), so the sentence around `original` is extracted and swapped server-side. Otherwise the flashcard would quiz her on the phrasing she had just left behind.
|
||||
- **ON CONFLICT DO NOTHING**, unlike capture's refresh-the-context upsert. Accepting the same collocation again months later is evidence the chunk is still being learned; the worst possible response is to overwrite its first context and reset a schedule it has been climbing. Test asserts the card keeps `interval_days = 7`.
|
||||
- **Best-effort, always.** Planting runs after the status write and swallows its own errors: accepting an edit is what she asked for, and it must not fail — or feel slower — because a flashcard couldn't be made. A rewrite too long to plant still returns 204.
|
||||
- Verified live on a throwaway DB (:8099, no dictionary, no LLM): accept → card `make a decision` with example *"I had to make a decision about the job."* bounded to its own sentence, then the journal reporting `kept:1`, `stuck:[{make a decision, docs:2}]` once the phrase appeared in a second document, and a seeded two-month-old pattern surfacing under `faded`.
|
||||
- Tests: `internal/vocab/plant_test.go` (PhraseKey table incl. rune-vs-byte, plant-once, unplantable is a silent no-op), `internal/suggestions/plant_test.go` (corrected-sentence example, only-collocations, idempotent-and-never-resets, sentence-rewrite skipped without failing the accept), `internal/suggestions/growth_test.go` (both windows, stuck needs a second document, the wrote-recently guard, still-happening excluded, and a per-writer isolation test seeding bob), `internal/db/db_test.go` (the backfill). Frontend: `journalCheers.test.ts` (silent before the fetch lands, once per line, one fetch however often warmed, silent on failure, pack resolved at call time) plus journal assertions in `i18n.test.ts`.
|
||||
- Verified: go build/vet, `go test ./internal/...` clean, tsc, vite build, vitest 131/131.
|
||||
- ⚠️ **Not deployed and not seen in a browser.** Same standing gap as Phase 21: no pt-PT account exists, and this was a code-only session. The pt-PT journal copy is part of the pack a native speaker still has not reviewed.
|
||||
- [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.
|
||||
|
||||
### Phase 23 — Choosing her own pair (2026-07-27)
|
||||
Raised by the user, not by the plan: *"I see no way to change my language in the mobile UI."* She was right, and the gap was total — `users.pair_lang` had been readable since Phase 19 and writable by nobody. `/api/me` was GET-only, `Upsert` deliberately skips the column, and no screen anywhere offered the choice. Phases 19–21 built the machinery for a second pair and then left the switch off the wall, which is why ⚠️ *"no pt-PT account exists yet"* stood unresolved through two phases: **nothing could create one.**
|
||||
- [x] **`PATCH /api/me`** (`auth.UpdateMeHandler`) — answers with the whole updated user rather than 204, so the client re-reads the pair from the server instead of assuming its own request took. One write reaches everything: the langpack, the Hunspell dictionary, the Piper voice, the lexicon provider and the prompt language all read `users.pair_lang` at use time.
|
||||
- [x] **The server refuses a pair it has no copy for.** `auth.shippedPairs` is deliberately *not* `internal/llm`'s language list — that one names every pair the **prompts** can talk about (cheap to add; fr and es have been in it since Phase 19), this one names every pair Petal can **render itself in**, which needs a langpack. Storing `fr` today would strand her on Chinese copy with no way back except a lucky guess at a button she cannot read.
|
||||
- [x] **The picker lives in the sidebar footer**, beside her name and the way out — because the sidebar *is* the mobile drawer, and it is the only chrome that is always one tap away on a phone. The status bar was the other candidate and is wrong: it exists only while a document is open, which is exactly the wrong moment to discover the app is speaking a language you can't read.
|
||||
- [x] **Each language names itself** — 中文, Português, and nothing else. The one place in Petal where bilingual copy would actively get in the way: a writer who has landed on the wrong pair cannot read "Portuguese" written in Chinese. The `aria-label` carries the English for a screen reader, which has no such problem.
|
||||
- [x] **No reload.** The pack was already a subscription (Phase 19), and `useSpellChecker` already reloads on `pack.code` while read-aloud already reads `pack().locale` — so the 2.66 MB pt-PT dictionary inflates, the wide alphabet turns on and the voice changes on the tap. Nothing here needed new plumbing; the switch is the only part that was missing.
|
||||
- [x] Tests: `internal/auth/pairlang_test.go` (round-trip and back again — a writer who tries a pair must be able to return; every unshipped code refused with the column unmoved; 400 vs 401 split so a lapsed session still becomes the sign-in overlay). `i18n.test.ts` asserts `shippedPacks()` offers exactly the pairs that have copy, and that every code it offers actually resolves.
|
||||
- Verified: go build/vet, `go test ./...` clean, tsc, vite build, vitest 173/173. **Not deployed and not seen in a browser** — same standing gap as Phases 21–22.
|
||||
|
||||
### Phase 24 (planned) — the fr and es pairs
|
||||
Scope agreed with the user 2026-07-27: *"switcher for Chinese and Portuguese now, plan support for others in a later session or two."* Phase 21 is the groove; the work per language is the same five items, and the order below is the order in which each one stops being a blocker for the next.
|
||||
1. **The langpack** (~450 lines, `web/src/i18n/packs/{fr,es}.ts`). TypeScript names every string a new pack still owes, so this is mechanical to *start* and slow to *finish* — the companion lines, the bedtime proverbs and the false-friend list are written for the pair, not translated from zh. es and fr both have real en-collisions to exploit (*actuellement*/*actually*, *librería*/*library*), so both want the `alsoIn` and false-friend blocks pt-PT proved. Add the code to `auth.shippedPairs` **in the same commit** — the picker and the server's allowlist are two halves of one fact.
|
||||
2. **A native-speaker review.** Standing at ⚠️ for pt-PT since Phase 21 and inherited here; expect a speaker to change the register before the vocabulary.
|
||||
3. **Hunspell dictionaries.** `scripts/build_ptpt_dictionary.py` generalizes — the eager-affix-expansion problem is French's and Spanish's too, and both are Latin-script so `extendedAlphabet` already covers them. Watch the same trap that caught pt: check what the *source* actually is before vendoring it (fr has `hunspell-fr-classique` vs `-moderne` vs `-toutesvariantes`; es is packaged per country).
|
||||
4. **Piper voices.** Phase 21 made this configuration rather than code: a compose service and two `.env` lines per language (`TTS_ENDPOINT_FR`/`TTS_VOICE_FR`). fr and es both have several European voices in Piper's catalogue, and unlike pt-PT the download path is plain ASCII — so this is the cheapest item on the list.
|
||||
5. **Lexicon coverage.** `dict.db` has held all five languages since Phase 20, so both directions should already answer; measure gloss coverage the way pt-PT's 62% was measured before assuming it.
|
||||
Not blockers, and cheap because Phase 19 did them: `internal/llm/lang.go` already carries fr and es, and `grammarLite`'s L1 rules already gate *ter 30 anos* / "I am agree" / "since three years" to pt+fr+es.
|
||||
|
||||
### 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
|
||||
@@ -278,6 +329,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_<LANG>`/`TTS_VOICE_<LANG>`, 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.
|
||||
|
||||
@@ -149,6 +149,12 @@ func main() {
|
||||
// this id and shows the signed-in writer.
|
||||
pr.Get("/me", users.MeHandler())
|
||||
|
||||
// …and the one thing about herself she can change: which language
|
||||
// Petal is her pair in. It lives here rather than under a /settings
|
||||
// tree because there is exactly one setting and it is a property of
|
||||
// the user row — the same row /me reads back.
|
||||
pr.Patch("/me", users.UpdateMeHandler())
|
||||
|
||||
llmClient := llm.NewLLMClient(cfg)
|
||||
sug := suggestions.New(database, llmClient)
|
||||
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"gitea.parodia.dev/drwily/petal/internal/db"
|
||||
)
|
||||
|
||||
// patchMe drives UpdateMeHandler as the given user would reach it: behind the
|
||||
// middleware, which is the only thing that puts an id in the context.
|
||||
func patchMe(t *testing.T, users *UserStore, id, body string) *httptest.ResponseRecorder {
|
||||
t.Helper()
|
||||
r := httptest.NewRequest(http.MethodPatch, "/me", strings.NewReader(body))
|
||||
r = r.WithContext(WithUser(r.Context(), id))
|
||||
w := httptest.NewRecorder()
|
||||
users.UpdateMeHandler()(w, r)
|
||||
return w
|
||||
}
|
||||
|
||||
func TestSetPairLang(t *testing.T) {
|
||||
_, users, _ := newStores(t)
|
||||
|
||||
if err := users.SetPairLang("bob", "pt-PT"); err != nil {
|
||||
t.Fatalf("set pt-PT: %v", err)
|
||||
}
|
||||
if u, _ := users.Get("bob"); u.PairLang != "pt-PT" {
|
||||
t.Fatalf("pair_lang = %q, want pt-PT", u.PairLang)
|
||||
}
|
||||
|
||||
// And back — a writer who tries a pair and doesn't like it must be able to
|
||||
// return, which is the whole reason the picker exists.
|
||||
if err := users.SetPairLang("bob", "zh"); err != nil {
|
||||
t.Fatalf("set zh: %v", err)
|
||||
}
|
||||
if u, _ := users.Get("bob"); u.PairLang != "zh" {
|
||||
t.Fatalf("pair_lang = %q, want zh", u.PairLang)
|
||||
}
|
||||
}
|
||||
|
||||
// A pair the frontend has no langpack for must not be storable. Accepting it
|
||||
// would leave her looking at Chinese copy with no way back except a lucky guess.
|
||||
func TestSetPairLangRejectsUnshippedPairs(t *testing.T) {
|
||||
_, users, _ := newStores(t)
|
||||
|
||||
for _, lang := range []string{"fr", "es", "pt-BR", "klingon", "", " "} {
|
||||
if err := users.SetPairLang("bob", lang); err == nil {
|
||||
t.Fatalf("stored unshipped pair %q", lang)
|
||||
}
|
||||
}
|
||||
if u, _ := users.Get("bob"); u.PairLang != "zh" {
|
||||
t.Fatalf("a refused write still moved pair_lang to %q", u.PairLang)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetPairLangUnknownUser(t *testing.T) {
|
||||
_, users, _ := newStores(t)
|
||||
if err := users.SetPairLang("nobody", "pt-PT"); err == nil {
|
||||
t.Fatal("set a pair language on an account that does not exist")
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateMeHandler(t *testing.T) {
|
||||
_, users, _ := newStores(t)
|
||||
|
||||
w := patchMe(t, users, "bob", `{"pair_lang":"pt-PT"}`)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200 (%s)", w.Code, w.Body.String())
|
||||
}
|
||||
// The whole user comes back, so the client can re-read the pair from the
|
||||
// server instead of assuming its request took.
|
||||
var got db.User
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &got); err != nil {
|
||||
t.Fatalf("decode: %v", err)
|
||||
}
|
||||
if got.ID != "bob" || got.PairLang != "pt-PT" {
|
||||
t.Fatalf("response = %+v, want bob on pt-PT", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateMeHandlerRejects(t *testing.T) {
|
||||
_, users, _ := newStores(t)
|
||||
|
||||
for name, body := range map[string]string{
|
||||
"unshipped pair": `{"pair_lang":"fr"}`,
|
||||
"missing field": `{}`,
|
||||
"not json": `pt-PT`,
|
||||
} {
|
||||
if w := patchMe(t, users, "bob", body); w.Code != http.StatusBadRequest {
|
||||
t.Fatalf("%s: status = %d, want 400", name, w.Code)
|
||||
}
|
||||
}
|
||||
if u, _ := users.Get("bob"); u.PairLang != "zh" {
|
||||
t.Fatalf("a rejected request still moved pair_lang to %q", u.PairLang)
|
||||
}
|
||||
|
||||
// A caller the middleware never resolved (or whose row is gone) is a lapsed
|
||||
// session, not a bad request — the client turns 401 into the sign-in overlay.
|
||||
if w := patchMe(t, users, "nobody", `{"pair_lang":"pt-PT"}`); w.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("unknown user: status = %d, want 401", w.Code)
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@ package auth
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"strings"
|
||||
@@ -68,6 +69,85 @@ func (u *UserStore) MeHandler() http.HandlerFunc {
|
||||
}
|
||||
}
|
||||
|
||||
// The pairs a writer may actually choose, in the order the picker offers them.
|
||||
//
|
||||
// This is deliberately *not* internal/llm's list of languages. That one names
|
||||
// every pair the prompts know how to talk about, which is a cheap thing to add;
|
||||
// this one names the pairs Petal can render itself in, which requires a langpack
|
||||
// on the frontend. Accepting a code with no pack would leave her looking at
|
||||
// Chinese with no way back except another guess, so the server refuses it. fr
|
||||
// and es join this list on the day their packs land, not before.
|
||||
var shippedPairs = []string{"zh", "pt-PT"}
|
||||
|
||||
func pairIsShipped(lang string) bool {
|
||||
for _, p := range shippedPairs {
|
||||
if p == lang {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// SetPairLang moves an account to another (English + X) pair.
|
||||
func (u *UserStore) SetPairLang(id, lang string) error {
|
||||
if !pairIsShipped(lang) {
|
||||
return errors.New("auth: unshipped pair language " + lang)
|
||||
}
|
||||
res, err := u.db.Exec(`UPDATE users SET pair_lang = ? WHERE id = ?`, lang, id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if n, err := res.RowsAffected(); err == nil && n == 0 {
|
||||
return sql.ErrNoRows
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// UpdateMeHandler changes the caller's own settings — today, the one setting
|
||||
// there is: which language Petal speaks alongside her English.
|
||||
//
|
||||
// It answers with the whole updated user rather than an empty 204 so the client
|
||||
// has one shape to trust: /api/me and this return the same thing, and the app
|
||||
// re-reads the pair from the response instead of assuming its request took.
|
||||
//
|
||||
// The pair language reaches further than the UI copy — it picks her Hunspell
|
||||
// dictionary, her read-aloud voice, which word-lookup provider answers, and the
|
||||
// language the prompts ask the model to explain in. All of those read
|
||||
// `users.pair_lang` at use time, so all of them follow from this one write.
|
||||
func (u *UserStore) UpdateMeHandler() http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
var body struct {
|
||||
PairLang string `json:"pair_lang"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
httputil.BadRequest(w, "invalid request body")
|
||||
return
|
||||
}
|
||||
lang := strings.TrimSpace(body.PairLang)
|
||||
if !pairIsShipped(lang) {
|
||||
// Name the ones that work. A writer who lands here has picked from a
|
||||
// stale client, and "not a language" tells her nothing.
|
||||
httputil.BadRequest(w, "unsupported language pair — Petal speaks "+strings.Join(shippedPairs, ", "))
|
||||
return
|
||||
}
|
||||
id := UserID(r.Context())
|
||||
if err := u.SetPairLang(id, lang); err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
httputil.ErrorJSON(w, http.StatusUnauthorized, "not signed in")
|
||||
return
|
||||
}
|
||||
httputil.ServerError(w, err)
|
||||
return
|
||||
}
|
||||
user, err := u.Get(id)
|
||||
if err != nil {
|
||||
httputil.ServerError(w, err)
|
||||
return
|
||||
}
|
||||
httputil.WriteJSON(w, http.StatusOK, user)
|
||||
}
|
||||
}
|
||||
|
||||
// Allowlist decides which of Authentik's users may write in this Petal.
|
||||
// Authentik fronts several applications; being a valid user there does not mean
|
||||
// being a user here.
|
||||
|
||||
@@ -459,6 +459,44 @@ CREATE TABLE personal_words (
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (user_id, lang, word)
|
||||
);
|
||||
`,
|
||||
},
|
||||
{
|
||||
// The growth journal reads the suggestions table as a record of what the
|
||||
// writer has been learning, and that reading only works if a row is dated
|
||||
// by *her decision* rather than by the model's proposal. `created_at` is
|
||||
// when a checkpoint offered the edit; a suggestion offered in April and
|
||||
// accepted in June is June's growth, not April's.
|
||||
//
|
||||
// Existing rows are backfilled to created_at — which is exactly the
|
||||
// approximation the journal would have had to make anyway, and is very
|
||||
// nearly right in practice since edits are settled minutes after a
|
||||
// checkpoint. Only pending rows keep a NULL: nothing has been decided.
|
||||
name: "0012_suggestion_resolved_at",
|
||||
stmt: `
|
||||
ALTER TABLE suggestions ADD COLUMN resolved_at DATETIME;
|
||||
UPDATE suggestions SET resolved_at = created_at WHERE status != 'pending';
|
||||
CREATE INDEX idx_suggestions_resolved ON suggestions(status, resolved_at);
|
||||
`,
|
||||
},
|
||||
{
|
||||
// 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';
|
||||
`,
|
||||
},
|
||||
}
|
||||
|
||||
@@ -83,3 +83,147 @@ func TestOpenMigratesAndSeeds(t *testing.T) {
|
||||
t.Errorf("expected exactly 1 local user after reopen, got %d", users)
|
||||
}
|
||||
}
|
||||
|
||||
// TestResolvedAtBackfill runs migration 0012 against a database that predates
|
||||
// it, which is the only shape that matters: on the live box the suggestions
|
||||
// table is years of settled edits with no resolved_at to their name. Backfilling
|
||||
// to created_at is exactly the approximation the growth journal would otherwise
|
||||
// have had to make, and a pending row must stay NULL — nothing has been decided.
|
||||
func TestResolvedAtBackfill(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "old.db")
|
||||
d, err := Open(path)
|
||||
if err != nil {
|
||||
t.Fatalf("open: %v", err)
|
||||
}
|
||||
|
||||
// Rewind to the state before 0012: drop the column and forget the migration.
|
||||
if _, err := d.Exec(`DROP INDEX idx_suggestions_resolved`); err != nil {
|
||||
t.Fatalf("rewind index: %v", err)
|
||||
}
|
||||
if _, err := d.Exec(`ALTER TABLE suggestions DROP COLUMN resolved_at`); err != nil {
|
||||
t.Fatalf("rewind schema: %v", err)
|
||||
}
|
||||
if _, err := d.Exec(`DELETE FROM schema_migrations WHERE name = '0012_suggestion_resolved_at'`); err != nil {
|
||||
t.Fatalf("rewind migration record: %v", err)
|
||||
}
|
||||
if _, err := d.Exec(`INSERT INTO documents (id, user_id) VALUES ('d1', ?)`, LocalUserID); err != nil {
|
||||
t.Fatalf("insert document: %v", err)
|
||||
}
|
||||
for _, s := range []struct{ id, status string }{
|
||||
{"s-old", "accepted"},
|
||||
{"s-open", "pending"},
|
||||
} {
|
||||
if _, err := d.Exec(
|
||||
`INSERT INTO suggestions (id, doc_id, from_pos, to_pos, original, replacement, explanation, type, status, created_at)
|
||||
VALUES (?, 'd1', 0, 3, 'teh', 'the', 'x', 'grammar', ?, '2026-01-02 03:04:05')`,
|
||||
s.id, s.status,
|
||||
); err != nil {
|
||||
t.Fatalf("seed %s: %v", s.id, err)
|
||||
}
|
||||
}
|
||||
d.Close()
|
||||
|
||||
d2, err := Open(path)
|
||||
if err != nil {
|
||||
t.Fatalf("reopen (migrate): %v", err)
|
||||
}
|
||||
defer d2.Close()
|
||||
|
||||
// Compared against created_at read back the same way: the driver renders a
|
||||
// DATETIME column itself, so the assertion is "the same instant", not a
|
||||
// particular text format.
|
||||
var settled, created *string
|
||||
if err := d2.QueryRow(
|
||||
`SELECT resolved_at, created_at FROM suggestions WHERE id = 's-old'`,
|
||||
).Scan(&settled, &created); err != nil {
|
||||
t.Fatalf("read settled row: %v", err)
|
||||
}
|
||||
if settled == nil || created == nil || *settled != *created {
|
||||
t.Errorf("resolved_at = %v, want it backfilled from created_at (%v)", settled, created)
|
||||
}
|
||||
|
||||
var pending *string
|
||||
if err := d2.QueryRow(`SELECT resolved_at FROM suggestions WHERE id = 's-open'`).Scan(&pending); err != nil {
|
||||
t.Fatalf("read pending row: %v", err)
|
||||
}
|
||||
if pending != nil {
|
||||
t.Errorf("pending row got resolved_at = %v, want NULL — nothing was decided", *pending)
|
||||
}
|
||||
}
|
||||
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
|
||||
+11
-1
@@ -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"
|
||||
|
||||
@@ -0,0 +1,230 @@
|
||||
package suggestions
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"gitea.parodia.dev/drwily/petal/internal/auth"
|
||||
"gitea.parodia.dev/drwily/petal/internal/httputil"
|
||||
"gitea.parodia.dev/drwily/petal/internal/vocab"
|
||||
)
|
||||
|
||||
// The growth journal.
|
||||
//
|
||||
// The suggestions table already records everything this needs — it is purely a
|
||||
// read-side view, with no new capture and no model call. Two framing rules
|
||||
// decide what may appear here, and they are enforced in the SQL rather than left
|
||||
// to the copy:
|
||||
//
|
||||
// 1. It reports growth, never an error tally. Nothing counts what she got
|
||||
// wrong this month; the signals are things that *stopped* happening and
|
||||
// phrasing that *stuck*.
|
||||
// 2. It only ever compares the writer to her own past self. There is no
|
||||
// target, no average, no other user anywhere in these queries.
|
||||
//
|
||||
// A quiet month is quiet: every signal below is omitted rather than softened
|
||||
// when the data isn't there, because an invented milestone is worse than none.
|
||||
|
||||
// Journal is one writer's growth over the recent windows.
|
||||
type Journal struct {
|
||||
// Kept / KeptBefore are edits she took on board in the last 30 days and in
|
||||
// the 30 before that — her own past self, the only comparison offered.
|
||||
Kept int `json:"kept"`
|
||||
KeptBefore int `json:"kept_before"`
|
||||
// Stuck: phrasing she was given that now turns up across her own writing.
|
||||
Stuck []Chunk `json:"stuck"`
|
||||
// Faded: things she used to need fixing and hasn't, recently.
|
||||
Faded []Fade `json:"faded"`
|
||||
}
|
||||
|
||||
// Chunk is a phrase that has stuck: it appears in Docs of her documents now.
|
||||
type Chunk struct {
|
||||
Phrase string `json:"phrase"`
|
||||
Docs int `json:"docs"`
|
||||
}
|
||||
|
||||
// Fade is a pattern that has stopped appearing. Times is how often it came up
|
||||
// during the earlier window — context for "and not since", never a scoreboard.
|
||||
type Fade struct {
|
||||
Pattern string `json:"pattern"`
|
||||
Times int `json:"times"`
|
||||
}
|
||||
|
||||
// Journal windows, in days. `recent` is the month being reported on; `history`
|
||||
// reaches back far enough that a pattern's absence means something (one quiet
|
||||
// fortnight doesn't).
|
||||
const (
|
||||
recentDays = 30
|
||||
historyDays = 120
|
||||
maxSignals = 3 // per list: a journal is a couple of warm lines, not a report
|
||||
)
|
||||
|
||||
// growth serves GET /api/suggestions/growth.
|
||||
func (h *Handler) growth(w http.ResponseWriter, r *http.Request) {
|
||||
userID := auth.UserID(r.Context())
|
||||
j := Journal{Stuck: []Chunk{}, Faded: []Fade{}}
|
||||
|
||||
err := h.DB.QueryRow(
|
||||
`SELECT
|
||||
sum(CASE WHEN s.resolved_at >= datetime('now', '-30 days') THEN 1 ELSE 0 END),
|
||||
sum(CASE WHEN s.resolved_at < datetime('now', '-30 days')
|
||||
AND s.resolved_at >= datetime('now', '-60 days') THEN 1 ELSE 0 END)
|
||||
FROM suggestions s JOIN documents d ON d.id = s.doc_id
|
||||
WHERE d.user_id = ? AND s.status = 'accepted' AND s.resolved_at IS NOT NULL`,
|
||||
userID,
|
||||
).Scan(&nullInt{&j.Kept}, &nullInt{&j.KeptBefore})
|
||||
if err != nil {
|
||||
httputil.ServerError(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
stuck, err := h.stuck(userID)
|
||||
if err != nil {
|
||||
httputil.ServerError(w, err)
|
||||
return
|
||||
}
|
||||
j.Stuck = stuck
|
||||
|
||||
faded, err := h.faded(userID)
|
||||
if err != nil {
|
||||
httputil.ServerError(w, err)
|
||||
return
|
||||
}
|
||||
j.Faded = faded
|
||||
|
||||
httputil.WriteJSON(w, http.StatusOK, j)
|
||||
}
|
||||
|
||||
// stuck finds accepted phrasing that now appears in more than one of her own
|
||||
// documents. One document is just the edit itself, still sitting where it was
|
||||
// applied; a second is her reaching for the phrase on her own, which is the
|
||||
// whole claim the line makes.
|
||||
func (h *Handler) stuck(userID string) ([]Chunk, error) {
|
||||
rows, err := h.DB.Query(
|
||||
`SELECT DISTINCT s.replacement
|
||||
FROM suggestions s JOIN documents d ON d.id = s.doc_id
|
||||
WHERE d.user_id = ? AND s.status = 'accepted'
|
||||
AND s.resolved_at >= datetime('now', '-120 days')
|
||||
AND trim(s.replacement) != ''
|
||||
ORDER BY s.resolved_at DESC
|
||||
LIMIT 40`,
|
||||
userID,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
// vocab.PhraseKey is the same definition of "a learnable chunk" the garden
|
||||
// plants, so the journal and the garden can never disagree about what counts.
|
||||
var phrases []string
|
||||
for rows.Next() {
|
||||
var replacement string
|
||||
if err := rows.Scan(&replacement); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if key := vocab.PhraseKey(replacement); key != "" {
|
||||
phrases = append(phrases, key)
|
||||
}
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
out := []Chunk{}
|
||||
for _, p := range phrases {
|
||||
var docs int
|
||||
if err := h.DB.QueryRow(
|
||||
`SELECT count(*) FROM documents WHERE user_id = ? AND instr(lower(content_text), ?) > 0`,
|
||||
userID, p,
|
||||
).Scan(&docs); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if docs >= 2 {
|
||||
out = append(out, Chunk{Phrase: p, Docs: docs})
|
||||
}
|
||||
}
|
||||
sortDesc(out, func(c Chunk) int { return c.Docs })
|
||||
return trim(out, maxSignals), nil
|
||||
}
|
||||
|
||||
// faded finds patterns she used to be corrected on during the earlier part of
|
||||
// the history window and hasn't been since.
|
||||
//
|
||||
// The guard that makes this honest: it says nothing at all unless she has
|
||||
// actually been writing lately. Without it, a month away from Petal would be
|
||||
// reported back to her as progress, which is the one way this feature could lie.
|
||||
func (h *Handler) faded(userID string) ([]Fade, error) {
|
||||
var wroteRecently int
|
||||
if err := h.DB.QueryRow(
|
||||
`SELECT count(*) FROM suggestions s JOIN documents d ON d.id = s.doc_id
|
||||
WHERE d.user_id = ? AND s.resolved_at >= datetime('now', '-30 days')`,
|
||||
userID,
|
||||
).Scan(&wroteRecently); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if wroteRecently == 0 {
|
||||
return []Fade{}, nil
|
||||
}
|
||||
|
||||
rows, err := h.DB.Query(
|
||||
`SELECT lower(trim(s.original)) AS pattern, count(*) AS times
|
||||
FROM suggestions s JOIN documents d ON d.id = s.doc_id
|
||||
WHERE d.user_id = ? AND s.status = 'accepted'
|
||||
AND s.resolved_at < datetime('now', '-30 days')
|
||||
AND s.resolved_at >= datetime('now', '-120 days')
|
||||
AND trim(s.original) != ''
|
||||
AND pattern NOT IN (
|
||||
SELECT lower(trim(s2.original))
|
||||
FROM suggestions s2 JOIN documents d2 ON d2.id = s2.doc_id
|
||||
WHERE d2.user_id = ? AND s2.status = 'accepted'
|
||||
AND s2.resolved_at >= datetime('now', '-30 days'))
|
||||
GROUP BY pattern
|
||||
HAVING times >= 2
|
||||
ORDER BY times DESC
|
||||
LIMIT 3`,
|
||||
userID, userID,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
out := []Fade{}
|
||||
for rows.Next() {
|
||||
var f Fade
|
||||
if err := rows.Scan(&f.Pattern, &f.Times); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, f)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// nullInt scans a possibly-NULL aggregate into an int (SUM over no rows is
|
||||
// NULL, which is a zero here, not an error).
|
||||
type nullInt struct{ dst *int }
|
||||
|
||||
func (n *nullInt) Scan(v any) error {
|
||||
switch t := v.(type) {
|
||||
case int64:
|
||||
*n.dst = int(t)
|
||||
case nil:
|
||||
*n.dst = 0
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func sortDesc[T any](s []T, key func(T) int) {
|
||||
for i := 1; i < len(s); i++ {
|
||||
for j := i; j > 0 && key(s[j]) > key(s[j-1]); j-- {
|
||||
s[j], s[j-1] = s[j-1], s[j]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func trim[T any](s []T, n int) []T {
|
||||
if len(s) > n {
|
||||
return s[:n]
|
||||
}
|
||||
return s
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
package suggestions
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"testing"
|
||||
|
||||
"gitea.parodia.dev/drwily/petal/internal/db"
|
||||
)
|
||||
|
||||
// resolved seeds one already-settled suggestion, dated `daysAgo` at the moment
|
||||
// she decided it (the journal reads decisions, not proposals).
|
||||
func resolved(t *testing.T, h *Handler, docID, status, original, replacement string, daysAgo int) {
|
||||
t.Helper()
|
||||
_, err := h.DB.Exec(
|
||||
`INSERT INTO suggestions (doc_id, from_pos, to_pos, original, replacement, explanation, type, status, created_at, resolved_at)
|
||||
VALUES (?, 0, 0, ?, ?, '', 'collocation', ?, datetime('now', ?), datetime('now', ?))`,
|
||||
docID, original, replacement, status,
|
||||
"-"+strconv.Itoa(daysAgo)+" days", "-"+strconv.Itoa(daysAgo)+" days",
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("seed resolved suggestion: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func seedDoc(t *testing.T, h *Handler, userID, text string) string {
|
||||
t.Helper()
|
||||
var id string
|
||||
if err := h.DB.QueryRow(
|
||||
`INSERT INTO documents (user_id, content_text) VALUES (?, ?) RETURNING id`, userID, text,
|
||||
).Scan(&id); err != nil {
|
||||
t.Fatalf("seed doc: %v", err)
|
||||
}
|
||||
return id
|
||||
}
|
||||
|
||||
func readJournal(t *testing.T, srv http.Handler) Journal {
|
||||
t.Helper()
|
||||
rec := do(t, srv, http.MethodGet, "/suggestions/growth", "")
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("growth: got %d, want 200 (body %s)", rec.Code, rec.Body.String())
|
||||
}
|
||||
var j Journal
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &j); err != nil {
|
||||
t.Fatalf("decode journal: %v", err)
|
||||
}
|
||||
return j
|
||||
}
|
||||
|
||||
// TestJournalIsEmptyForANewWriter: nothing to report reports nothing. Empty
|
||||
// lists, not nulls, so the frontend never has to guess.
|
||||
func TestJournalIsEmptyForANewWriter(t *testing.T) {
|
||||
srv, _, _ := newTestServer(t, &stubClient{})
|
||||
j := readJournal(t, srv)
|
||||
if j.Kept != 0 || j.KeptBefore != 0 || len(j.Stuck) != 0 || len(j.Faded) != 0 {
|
||||
t.Fatalf("new writer got a journal: %+v", j)
|
||||
}
|
||||
}
|
||||
|
||||
// TestKeptComparesHerToHerOwnPastSelf.
|
||||
func TestKeptCountsTwoWindows(t *testing.T) {
|
||||
srv, docID, h := newTestServer(t, &stubClient{})
|
||||
for i := 0; i < 3; i++ {
|
||||
resolved(t, h, docID, "accepted", "do a decision", "make a decision", 5)
|
||||
}
|
||||
resolved(t, h, docID, "accepted", "big rain", "heavy rain", 40)
|
||||
resolved(t, h, docID, "rejected", "no thanks", "no, thank you", 5) // decisions kept only
|
||||
resolved(t, h, docID, "accepted", "long ago", "long since", 200) // outside both windows
|
||||
|
||||
j := readJournal(t, srv)
|
||||
if j.Kept != 3 {
|
||||
t.Errorf("Kept = %d, want 3", j.Kept)
|
||||
}
|
||||
if j.KeptBefore != 1 {
|
||||
t.Errorf("KeptBefore = %d, want 1", j.KeptBefore)
|
||||
}
|
||||
}
|
||||
|
||||
// TestStuckNeedsASecondDocument: a phrase sitting in the one document it was
|
||||
// applied to has not stuck — it's just the edit, where she left it. A second
|
||||
// document is her reaching for it herself, which is the claim the line makes.
|
||||
func TestStuckNeedsASecondDocument(t *testing.T) {
|
||||
srv, docID, h := newTestServer(t, &stubClient{})
|
||||
if _, err := h.DB.Exec(`UPDATE documents SET content_text = ? WHERE id = ?`,
|
||||
"I had to make a decision.", docID); err != nil {
|
||||
t.Fatalf("set content: %v", err)
|
||||
}
|
||||
resolved(t, h, docID, "accepted", "do a decision", "make a decision", 10)
|
||||
resolved(t, h, docID, "accepted", "do a photo", "take a photo", 10)
|
||||
|
||||
if j := readJournal(t, srv); len(j.Stuck) != 0 {
|
||||
t.Fatalf("one document counted as sticking: %+v", j.Stuck)
|
||||
}
|
||||
|
||||
// She uses it again, elsewhere, on her own.
|
||||
seedDoc(t, h, db.LocalUserID, "Later I had to Make A Decision about the flat.")
|
||||
j := readJournal(t, srv)
|
||||
if len(j.Stuck) != 1 {
|
||||
t.Fatalf("Stuck = %+v, want just the phrase she reused", j.Stuck)
|
||||
}
|
||||
if j.Stuck[0].Phrase != "make a decision" || j.Stuck[0].Docs != 2 {
|
||||
t.Errorf("Stuck[0] = %+v, want {make a decision 2} (case-insensitive)", j.Stuck[0])
|
||||
}
|
||||
}
|
||||
|
||||
// TestFadedNeedsRecentWriting is the guard that keeps this feature honest: a
|
||||
// month away from Petal must never be reported back as progress.
|
||||
func TestFadedNeedsRecentWriting(t *testing.T) {
|
||||
srv, docID, h := newTestServer(t, &stubClient{})
|
||||
resolved(t, h, docID, "accepted", "在 the morning", "in the morning", 60)
|
||||
resolved(t, h, docID, "accepted", "在 the morning", "in the morning", 55)
|
||||
|
||||
if j := readJournal(t, srv); len(j.Faded) != 0 {
|
||||
t.Fatalf("silence reported as growth: %+v", j.Faded)
|
||||
}
|
||||
|
||||
// She has been writing again this month — now the absence means something.
|
||||
resolved(t, h, docID, "accepted", "big rain", "heavy rain", 3)
|
||||
j := readJournal(t, srv)
|
||||
if len(j.Faded) != 1 || j.Faded[0].Pattern != "在 the morning" || j.Faded[0].Times != 2 {
|
||||
t.Fatalf("Faded = %+v, want the pattern she stopped needing (twice, back then)", j.Faded)
|
||||
}
|
||||
}
|
||||
|
||||
// TestFadedExcludesWhatStillHappens: a pattern corrected again this month has
|
||||
// not faded, however often it came up before.
|
||||
func TestFadedExcludesWhatStillHappens(t *testing.T) {
|
||||
srv, docID, h := newTestServer(t, &stubClient{})
|
||||
resolved(t, h, docID, "accepted", "在 the morning", "in the morning", 60)
|
||||
resolved(t, h, docID, "accepted", "在 the morning", "in the morning", 55)
|
||||
resolved(t, h, docID, "accepted", "在 the morning", "in the morning", 2)
|
||||
|
||||
if j := readJournal(t, srv); len(j.Faded) != 0 {
|
||||
t.Fatalf("Faded = %+v, want empty — it still happens", j.Faded)
|
||||
}
|
||||
}
|
||||
|
||||
// TestJournalIsPerWriter: another account's learning is never anyone else's
|
||||
// journal, and the only comparison Petal draws is with her own past self.
|
||||
func TestJournalIsPerWriter(t *testing.T) {
|
||||
srv, _, h := newTestServer(t, &stubClient{})
|
||||
if _, err := h.DB.Exec(`INSERT INTO users (id, email) VALUES ('bob', 'bob@example.com')`); err != nil {
|
||||
t.Fatalf("seed user: %v", err)
|
||||
}
|
||||
bobDoc := seedDoc(t, h, "bob", "Bob had to make a decision.")
|
||||
seedDoc(t, h, "bob", "Bob will make a decision again.")
|
||||
resolved(t, h, bobDoc, "accepted", "do a decision", "make a decision", 5)
|
||||
resolved(t, h, bobDoc, "accepted", "big rain", "heavy rain", 60)
|
||||
resolved(t, h, bobDoc, "accepted", "big rain", "heavy rain", 55)
|
||||
|
||||
j := readJournal(t, srv)
|
||||
if j.Kept != 0 || j.KeptBefore != 0 || len(j.Stuck) != 0 || len(j.Faded) != 0 {
|
||||
t.Fatalf("bob's learning leaked into the local user's journal: %+v", j)
|
||||
}
|
||||
}
|
||||
@@ -11,6 +11,7 @@ import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
@@ -20,6 +21,7 @@ import (
|
||||
"gitea.parodia.dev/drwily/petal/internal/db"
|
||||
"gitea.parodia.dev/drwily/petal/internal/httputil"
|
||||
"gitea.parodia.dev/drwily/petal/internal/llm"
|
||||
"gitea.parodia.dev/drwily/petal/internal/vocab"
|
||||
)
|
||||
|
||||
// Handler holds the dependencies for the checkpoint + suggestion routes. The
|
||||
@@ -60,6 +62,10 @@ func (h *Handler) RegisterDocRoutes(r chi.Router) {
|
||||
// actions.
|
||||
func (h *Handler) Routes() chi.Router {
|
||||
r := chi.NewRouter()
|
||||
// The growth journal reads the same table these actions write, so it lives
|
||||
// here rather than growing its own mount. A literal segment, so it can never
|
||||
// be shadowed by an id.
|
||||
r.Get("/growth", h.growth)
|
||||
r.Post("/{id}/accept", h.accept)
|
||||
r.Post("/{id}/dismiss", h.dismiss)
|
||||
r.Post("/{id}/chat", h.chat)
|
||||
@@ -84,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
|
||||
@@ -138,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 {
|
||||
@@ -150,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
|
||||
}
|
||||
@@ -169,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
|
||||
}
|
||||
@@ -283,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
|
||||
@@ -335,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
|
||||
}
|
||||
@@ -491,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 = ?
|
||||
@@ -508,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
|
||||
}
|
||||
@@ -520,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
|
||||
@@ -534,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})
|
||||
}
|
||||
}
|
||||
@@ -544,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 {
|
||||
@@ -553,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)
|
||||
@@ -577,7 +614,7 @@ func (h *Handler) dismiss(w http.ResponseWriter, r *http.Request) {
|
||||
// no rows and surfaces as a 404.
|
||||
func (h *Handler) setStatus(w http.ResponseWriter, r *http.Request, status string) {
|
||||
res, err := h.DB.Exec(
|
||||
`UPDATE suggestions SET status = ?
|
||||
`UPDATE suggestions SET status = ?, resolved_at = datetime('now')
|
||||
WHERE id = ? AND status = ?
|
||||
AND doc_id IN (SELECT id FROM documents WHERE user_id = ?)`,
|
||||
status, chi.URLParam(r, "id"), db.SuggestionStatusPending,
|
||||
@@ -591,9 +628,72 @@ func (h *Handler) setStatus(w http.ResponseWriter, r *http.Request, status strin
|
||||
httputil.ErrorJSON(w, http.StatusNotFound, "pending suggestion not found")
|
||||
return
|
||||
}
|
||||
if status == db.SuggestionStatusAccepted {
|
||||
h.plant(chi.URLParam(r, "id"), auth.UserID(r.Context()))
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
// plant grows an accepted collocation into a vocabulary-garden phrase card. It
|
||||
// runs after the status write and swallows its own errors: accepting an edit is
|
||||
// the thing the writer asked for, and it must not fail — or even feel slower —
|
||||
// because a flashcard couldn't be made.
|
||||
//
|
||||
// Only collocations are planted. The other families correct *this* sentence
|
||||
// ("their" → "there", a comma, a clearer clause); a collocation is the one that
|
||||
// hands over a reusable chunk, which is the only thing worth reviewing in a week.
|
||||
func (h *Handler) plant(id, userID string) {
|
||||
var s db.Suggestion
|
||||
var contentText string
|
||||
err := h.DB.QueryRow(
|
||||
`SELECT s.type, s.original, s.replacement, s.explanation, s.doc_id, d.content_text
|
||||
FROM suggestions s JOIN documents d ON d.id = s.doc_id
|
||||
WHERE s.id = ? AND d.user_id = ?`,
|
||||
id, userID,
|
||||
).Scan(&s.Type, &s.Original, &s.Replacement, &s.Explanation, &s.DocID, &contentText)
|
||||
if err != nil {
|
||||
if !errors.Is(err, sql.ErrNoRows) {
|
||||
log.Printf("suggestions: could not read %s for planting: %v", id, err)
|
||||
}
|
||||
return
|
||||
}
|
||||
if s.Type != db.SuggestionTypeCollocation || strings.TrimSpace(s.Replacement) == "" {
|
||||
return
|
||||
}
|
||||
// The stored text is still the pre-accept draft — the client applies the
|
||||
// replacement in the editor. Correct the sentence here so the flashcard
|
||||
// quizzes the phrasing she is keeping, not the one she just left behind.
|
||||
docID := s.DocID
|
||||
if _, err := vocab.Plant(h.DB, userID, vocab.Phrase{
|
||||
Text: s.Replacement,
|
||||
Meaning: s.Explanation,
|
||||
Example: correctedSentence(contentText, s.Original, s.Replacement),
|
||||
DocID: &docID,
|
||||
}); err != nil {
|
||||
log.Printf("suggestions: could not plant %s: %v", id, err)
|
||||
}
|
||||
}
|
||||
|
||||
// correctedSentence returns the sentence of contentText containing original,
|
||||
// with original swapped for replacement. Returns "" when the original isn't
|
||||
// found (the draft moved on) — a card with no example still reviews, just
|
||||
// without the cloze, so there's nothing to fall back to and nothing to guess.
|
||||
func correctedSentence(contentText, original, replacement string) string {
|
||||
idx := strings.Index(contentText, original)
|
||||
if original == "" || idx < 0 {
|
||||
return ""
|
||||
}
|
||||
start := strings.LastIndexAny(contentText[:idx], ".!?\n")
|
||||
end := strings.IndexAny(contentText[idx+len(original):], ".!?\n")
|
||||
if end < 0 {
|
||||
end = len(contentText)
|
||||
} else {
|
||||
end += idx + len(original) + 1 // keep the terminator
|
||||
}
|
||||
sentence := strings.TrimSpace(contentText[start+1 : end])
|
||||
return strings.Replace(sentence, original, replacement, 1)
|
||||
}
|
||||
|
||||
// locate finds the plaintext offsets of original within contentText. Returns
|
||||
// (-1, -1) when not found; the frontend anchors by string regardless, so a miss
|
||||
// here is non-fatal.
|
||||
|
||||
@@ -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])
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,186 @@
|
||||
package suggestions
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"testing"
|
||||
|
||||
"gitea.parodia.dev/drwily/petal/internal/db"
|
||||
)
|
||||
|
||||
// seedSuggestion writes one pending suggestion against the seeded doc, after
|
||||
// replacing the doc's text so the sentence around `original` is under the test's
|
||||
// control.
|
||||
func seedSuggestion(t *testing.T, h *Handler, docID, text, sType, original, replacement, explanation string) string {
|
||||
t.Helper()
|
||||
if _, err := h.DB.Exec(`UPDATE documents SET content_text = ? WHERE id = ?`, text, docID); err != nil {
|
||||
t.Fatalf("set content: %v", err)
|
||||
}
|
||||
var id string
|
||||
err := h.DB.QueryRow(
|
||||
`INSERT INTO suggestions (doc_id, from_pos, to_pos, original, replacement, explanation, type, status)
|
||||
VALUES (?, 0, 0, ?, ?, ?, ?, 'pending') RETURNING id`,
|
||||
docID, original, replacement, explanation, sType,
|
||||
).Scan(&id)
|
||||
if err != nil {
|
||||
t.Fatalf("seed suggestion: %v", err)
|
||||
}
|
||||
return id
|
||||
}
|
||||
|
||||
type card struct {
|
||||
word, definition, example string
|
||||
interval int
|
||||
}
|
||||
|
||||
func gardenCards(t *testing.T, h *Handler) []card {
|
||||
t.Helper()
|
||||
rows, err := h.DB.Query(
|
||||
`SELECT word, definition, example, interval_days FROM vocab_words WHERE user_id = ? ORDER BY word`,
|
||||
db.LocalUserID,
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("read garden: %v", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []card
|
||||
for rows.Next() {
|
||||
var c card
|
||||
if err := rows.Scan(&c.word, &c.definition, &c.example, &c.interval); err != nil {
|
||||
t.Fatalf("scan: %v", err)
|
||||
}
|
||||
out = append(out, c)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// TestAcceptedCollocationIsPlanted walks the whole hand-over: a collocation the
|
||||
// writer accepts becomes a phrase card whose example is the *corrected*
|
||||
// sentence, so the flashcard quizzes the phrasing she kept.
|
||||
func TestAcceptedCollocationIsPlanted(t *testing.T) {
|
||||
srv, docID, h := newTestServer(t, &stubClient{})
|
||||
id := seedSuggestion(t, h, docID,
|
||||
"Yesterday was hard. I had to do a decision about the job. Then I slept.",
|
||||
db.SuggestionTypeCollocation, "do a decision", "make a decision",
|
||||
"English pairs “make” with “decision”.")
|
||||
|
||||
if rec := do(t, srv, http.MethodPost, "/suggestions/"+id+"/accept", ""); rec.Code != http.StatusNoContent {
|
||||
t.Fatalf("accept: got %d, want 204", rec.Code)
|
||||
}
|
||||
|
||||
cards := gardenCards(t, h)
|
||||
if len(cards) != 1 {
|
||||
t.Fatalf("garden has %d cards, want 1: %+v", len(cards), cards)
|
||||
}
|
||||
got := cards[0]
|
||||
if got.word != "make a decision" {
|
||||
t.Errorf("word = %q, want %q", got.word, "make a decision")
|
||||
}
|
||||
if got.example != "I had to make a decision about the job." {
|
||||
t.Errorf("example = %q — want the corrected sentence, bounded to its own sentence", got.example)
|
||||
}
|
||||
if got.definition != "English pairs “make” with “decision”." {
|
||||
t.Errorf("definition = %q, want the explanation", got.definition)
|
||||
}
|
||||
if got.interval != 1 {
|
||||
t.Errorf("interval_days = %d, want 1 (due tomorrow, like a fresh capture)", got.interval)
|
||||
}
|
||||
}
|
||||
|
||||
// TestOnlyCollocationsArePlanted: the other families correct this sentence and
|
||||
// hand over nothing reusable. A dismissed collocation is not a lesson either.
|
||||
func TestOnlyCollocationsArePlanted(t *testing.T) {
|
||||
srv, docID, h := newTestServer(t, &stubClient{})
|
||||
|
||||
grammar := seedSuggestion(t, h, docID, "I has two apples.",
|
||||
db.SuggestionTypeGrammar, "I has", "I have", "Subject–verb agreement.")
|
||||
if rec := do(t, srv, http.MethodPost, "/suggestions/"+grammar+"/accept", ""); rec.Code != http.StatusNoContent {
|
||||
t.Fatalf("accept grammar: got %d", rec.Code)
|
||||
}
|
||||
|
||||
dismissed := seedSuggestion(t, h, docID, "We must take a photo of it.",
|
||||
db.SuggestionTypeCollocation, "do a photo", "take a photo", "Photos are taken.")
|
||||
if rec := do(t, srv, http.MethodPost, "/suggestions/"+dismissed+"/dismiss", ""); rec.Code != http.StatusNoContent {
|
||||
t.Fatalf("dismiss: got %d", rec.Code)
|
||||
}
|
||||
|
||||
if cards := gardenCards(t, h); len(cards) != 0 {
|
||||
t.Fatalf("garden grew %d card(s) from a grammar fix and a dismissal: %+v", len(cards), cards)
|
||||
}
|
||||
}
|
||||
|
||||
// TestPlantingIsIdempotentAndNeverResets: accepting the same chunk again is
|
||||
// evidence it's still being learned — the worst possible response is to wipe the
|
||||
// card's first context and the schedule it has been climbing.
|
||||
func TestPlantingIsIdempotentAndNeverResets(t *testing.T) {
|
||||
srv, docID, h := newTestServer(t, &stubClient{})
|
||||
first := seedSuggestion(t, h, docID, "I had to do a decision.",
|
||||
db.SuggestionTypeCollocation, "do a decision", "make a decision", "First explanation.")
|
||||
if rec := do(t, srv, http.MethodPost, "/suggestions/"+first+"/accept", ""); rec.Code != http.StatusNoContent {
|
||||
t.Fatalf("accept: got %d", rec.Code)
|
||||
}
|
||||
// The card climbs a little.
|
||||
if _, err := h.DB.Exec(
|
||||
`UPDATE vocab_words SET reps = 3, interval_days = 7 WHERE user_id = ? AND word = 'make a decision'`,
|
||||
db.LocalUserID,
|
||||
); err != nil {
|
||||
t.Fatalf("advance card: %v", err)
|
||||
}
|
||||
|
||||
second := seedSuggestion(t, h, docID, "Later I must do a decision again.",
|
||||
db.SuggestionTypeCollocation, "do a decision", "make a decision", "Second explanation.")
|
||||
if rec := do(t, srv, http.MethodPost, "/suggestions/"+second+"/accept", ""); rec.Code != http.StatusNoContent {
|
||||
t.Fatalf("accept again: got %d", rec.Code)
|
||||
}
|
||||
|
||||
cards := gardenCards(t, h)
|
||||
if len(cards) != 1 {
|
||||
t.Fatalf("garden has %d cards, want 1 (one chunk, one card)", len(cards))
|
||||
}
|
||||
if cards[0].definition != "First explanation." {
|
||||
t.Errorf("definition = %q — the existing card should win", cards[0].definition)
|
||||
}
|
||||
if cards[0].example != "I had to make a decision." {
|
||||
t.Errorf("example = %q — the first context should survive", cards[0].example)
|
||||
}
|
||||
if cards[0].interval != 7 {
|
||||
t.Errorf("interval_days = %d, want 7 — progress must not be reset", cards[0].interval)
|
||||
}
|
||||
}
|
||||
|
||||
// TestSentenceRewriteIsNotAPhraseCard: a "collocation" long enough to be a
|
||||
// rewritten sentence makes a miserable flashcard, so it is dropped rather than
|
||||
// planted — and the accept still succeeds.
|
||||
func TestSentenceRewriteIsNotAPhraseCard(t *testing.T) {
|
||||
srv, docID, h := newTestServer(t, &stubClient{})
|
||||
long := "I would like to take this opportunity to thank you for everything"
|
||||
id := seedSuggestion(t, h, docID, "I want thank you for everything.",
|
||||
db.SuggestionTypeCollocation, "I want thank you for everything", long, "More natural.")
|
||||
if rec := do(t, srv, http.MethodPost, "/suggestions/"+id+"/accept", ""); rec.Code != http.StatusNoContent {
|
||||
t.Fatalf("accept: got %d, want 204 — a skipped card must never fail the accept", rec.Code)
|
||||
}
|
||||
if cards := gardenCards(t, h); len(cards) != 0 {
|
||||
t.Fatalf("planted a sentence as a phrase card: %+v", cards)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCorrectedSentence(t *testing.T) {
|
||||
const text = "One thing. I had to do a decision fast! Another thing."
|
||||
cases := []struct {
|
||||
name, original, replacement, want string
|
||||
}{
|
||||
{"bounded to its sentence", "do a decision", "make a decision", "I had to make a decision fast!"},
|
||||
{"original no longer present", "do a choice", "make a choice", ""},
|
||||
{"empty original", "", "make a decision", ""},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
if got := correctedSentence(text, tc.original, tc.replacement); got != tc.want {
|
||||
t.Errorf("correctedSentence = %q, want %q", got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
// A document with no terminator at all is one sentence, and still works.
|
||||
if got := correctedSentence("i had to do a decision", "do a decision", "make a decision"); got != "i had to make a decision" {
|
||||
t.Errorf("unterminated doc: got %q", got)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
package vocab
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"strings"
|
||||
"unicode"
|
||||
)
|
||||
|
||||
// Planting: the garden's second source.
|
||||
//
|
||||
// Capture (handlers.go) records words the writer *sought out*. Planting records
|
||||
// phrasing she was gently *given* — an accepted collocation like "make a
|
||||
// decision" is a learnable chunk exactly like a looked-up word, and the SM-2-lite
|
||||
// scheduler doesn't care that it's three words rather than one. Together the two
|
||||
// halves make the garden a record of both sides of learning.
|
||||
//
|
||||
// Everything here is best-effort by design: planting hangs off accepting a
|
||||
// suggestion, and that accept must succeed whether or not a card comes of it.
|
||||
|
||||
// Execer is the slice of *sql.DB (or *sql.Tx) that planting needs.
|
||||
type Execer interface {
|
||||
Exec(query string, args ...any) (sql.Result, error)
|
||||
}
|
||||
|
||||
// Phrase is one chunk to plant.
|
||||
type Phrase struct {
|
||||
Text string // the corrected phrasing, e.g. "make a decision"
|
||||
Meaning string // why it's better — the suggestion's explanation
|
||||
Example string // the sentence she met it in, already corrected
|
||||
DocID *string // where, so "where did I see this?" stays one tap
|
||||
}
|
||||
|
||||
// Phrase-card caps. A collocation is a short chunk; anything longer is a
|
||||
// rewritten sentence wearing a collocation's label, and a sentence makes a
|
||||
// miserable flashcard. Both bounds are deliberately tight — the cost of
|
||||
// skipping a real chunk is one missing card, the cost of planting a sentence is
|
||||
// a garden the writer stops trusting.
|
||||
const (
|
||||
maxPhraseRunes = 60
|
||||
maxPhraseWords = 6
|
||||
minPhraseWords = 2
|
||||
)
|
||||
|
||||
// PhraseKey normalizes a replacement into a garden key, or returns "" when the
|
||||
// text isn't a plantable chunk.
|
||||
//
|
||||
// Lowercasing matches capture's normalization, so a phrase and a looked-up word
|
||||
// share one UNIQUE(user_id, word) namespace rather than colliding sideways.
|
||||
// Single words are rejected on purpose: a one-word fix is word choice, and word
|
||||
// choice already reaches the garden through lookup — planting it here would give
|
||||
// it a card with no gloss and no phonetic, which reviews badly.
|
||||
func PhraseKey(text string) string {
|
||||
// Collapse all whitespace (a replacement can carry a newline from the
|
||||
// editor) so the key is stable and the word count is honest.
|
||||
s := strings.Join(strings.Fields(strings.ToLower(text)), " ")
|
||||
// Trim the punctuation a phrase picks up from the sentence around it, but
|
||||
// leave inner marks alone: "can't afford" and "in one's own time" are chunks.
|
||||
s = strings.Trim(s, `.,;:!?…"'“”‘’()[]`)
|
||||
s = strings.TrimSpace(s)
|
||||
if s == "" || len([]rune(s)) > maxPhraseRunes {
|
||||
return ""
|
||||
}
|
||||
n := len(strings.Fields(s))
|
||||
if n < minPhraseWords || n > maxPhraseWords {
|
||||
return ""
|
||||
}
|
||||
// A chunk of pure digits or symbols ("12 000", "-- --") isn't vocabulary.
|
||||
if !strings.ContainsFunc(s, unicode.IsLetter) {
|
||||
return ""
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// Plant adds a phrase card to the garden, due tomorrow like any fresh capture.
|
||||
// It reports whether a new card was created.
|
||||
//
|
||||
// ON CONFLICT DO NOTHING, unlike capture's refresh-the-context upsert: accepting
|
||||
// the same collocation again months later is evidence the chunk is still being
|
||||
// learned, and the last thing that should do is overwrite the card's first
|
||||
// context or disturb a schedule it has been climbing. An existing card wins.
|
||||
func Plant(ex Execer, userID string, p Phrase) (bool, error) {
|
||||
key := PhraseKey(p.Text)
|
||||
if key == "" {
|
||||
return false, nil
|
||||
}
|
||||
res, err := ex.Exec(
|
||||
`INSERT INTO vocab_words (user_id, word, gloss, definition, phonetic, example, doc_id, due_at, interval_days)
|
||||
VALUES (?, ?, '', ?, '', ?, ?, datetime('now', '+1 day'), 1)
|
||||
ON CONFLICT(user_id, word) DO NOTHING`,
|
||||
userID, key,
|
||||
clamp(strings.TrimSpace(p.Meaning), maxDefinitionLen),
|
||||
clamp(strings.TrimSpace(p.Example), maxExampleLen),
|
||||
p.DocID,
|
||||
)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
n, err := res.RowsAffected()
|
||||
return n > 0, err
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
package vocab
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"gitea.parodia.dev/drwily/petal/internal/db"
|
||||
)
|
||||
|
||||
func TestPhraseKey(t *testing.T) {
|
||||
cases := []struct {
|
||||
in, want string
|
||||
}{
|
||||
{"make a decision", "make a decision"},
|
||||
{"Make A Decision", "make a decision"}, // shares one namespace with lookups
|
||||
{" make a\ndecision ", "make a decision"}, // the editor's whitespace
|
||||
{"“make a decision.”", "make a decision"}, // punctuation from the sentence around it
|
||||
{"can’t afford it", "can’t afford it"}, // inner marks are part of the chunk
|
||||
{"decision", ""}, // word choice, not a chunk — lookup's job
|
||||
{"", ""}, //
|
||||
{"...", ""}, //
|
||||
{"12 000", ""}, // digits aren't vocabulary
|
||||
{"a b c d e f g", ""}, // a clause wearing a chunk's label
|
||||
{"in one’s own good time again", "in one’s own good time again"}, // six words is still a chunk
|
||||
}
|
||||
for _, tc := range cases {
|
||||
if got := PhraseKey(tc.in); got != tc.want {
|
||||
t.Errorf("PhraseKey(%q) = %q, want %q", tc.in, got, tc.want)
|
||||
}
|
||||
}
|
||||
// The length cap counts runes, not bytes — otherwise a Portuguese chunk well
|
||||
// inside the limit would be dropped for being accented.
|
||||
accented := "ãããããã ãããããã ãããããã ãããããã ãããããã" // 34 runes, 64 bytes
|
||||
if got := PhraseKey(accented); got != accented {
|
||||
t.Errorf("PhraseKey(%d runes / %d bytes) = %q, want it kept", len([]rune(accented)), len(accented), got)
|
||||
}
|
||||
long := "ãããããããããããã ãããããããããããã ãããããããããããã ãããããããããããã ãããããããããããã ãããããããããããã"
|
||||
if got := PhraseKey(long); got != "" {
|
||||
t.Errorf("PhraseKey(%d runes) = %q, want \"\"", len([]rune(long)), got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPlantCreatesOnceAndReportsIt(t *testing.T) {
|
||||
database, err := db.Open(filepath.Join(t.TempDir(), "test.db"))
|
||||
if err != nil {
|
||||
t.Fatalf("open db: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { database.Close() })
|
||||
|
||||
p := Phrase{Text: "make a decision", Meaning: "why", Example: "I had to make a decision."}
|
||||
created, err := Plant(database, db.LocalUserID, p)
|
||||
if err != nil || !created {
|
||||
t.Fatalf("first plant: created=%v err=%v", created, err)
|
||||
}
|
||||
created, err = Plant(database, db.LocalUserID, p)
|
||||
if err != nil || created {
|
||||
t.Fatalf("second plant: created=%v err=%v, want false", created, err)
|
||||
}
|
||||
|
||||
// A card that isn't plantable is a silent no-op, not an error: planting hangs
|
||||
// off accepting an edit, and that accept must never fail for a flashcard.
|
||||
created, err = Plant(database, db.LocalUserID, Phrase{Text: "decision"})
|
||||
if err != nil || created {
|
||||
t.Fatalf("unplantable: created=%v err=%v", created, err)
|
||||
}
|
||||
|
||||
var n int
|
||||
if err := database.QueryRow(`SELECT count(*) FROM vocab_words WHERE user_id = ?`, db.LocalUserID).Scan(&n); err != nil {
|
||||
t.Fatalf("count: %v", err)
|
||||
}
|
||||
if n != 1 {
|
||||
t.Fatalf("garden has %d cards, want 1", n)
|
||||
}
|
||||
}
|
||||
@@ -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}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
+34
-2
@@ -157,18 +157,39 @@ 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
|
||||
}
|
||||
|
||||
// The growth journal (GET /api/suggestions/growth). `kept`/`kept_before` are
|
||||
// the last thirty days and the thirty before them — the only comparison Petal
|
||||
// draws is with her own past self. `stuck` is phrasing she was given that now
|
||||
// turns up across her own documents; `faded` is what she used to be corrected
|
||||
// on and hasn't been lately. Both lists are empty when the data isn't there:
|
||||
// nothing here is padded to fill a page.
|
||||
export interface GrowthJournal {
|
||||
kept: number
|
||||
kept_before: number
|
||||
stuck: { phrase: string; docs: number }[]
|
||||
faded: { pattern: string; times: number }[]
|
||||
}
|
||||
|
||||
// A deterministic, rule-based fix detected client-side (see Companion/prose.ts).
|
||||
// The frontend owns mechanics detection; the backend only persists these as the
|
||||
// 'mechanics' suggestion family. Spans are exact plaintext offsets.
|
||||
// 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
|
||||
@@ -230,6 +251,13 @@ export const api = {
|
||||
// the hardcoded local user, so the frontend needs no separate mode for it.
|
||||
me: () => req<Me>('/me'),
|
||||
|
||||
// Move to another (English + X) pair. Answers with the whole updated user, so
|
||||
// the caller re-reads the pair from the server rather than assuming its own
|
||||
// request took — a code the server won't ship comes back 400 and the app is
|
||||
// still on a language it can render.
|
||||
setPairLang: (lang: string) =>
|
||||
req<Me>('/me', { method: 'PATCH', body: JSON.stringify({ pair_lang: lang }) }),
|
||||
|
||||
listDocs: () => req<DocSummary[]>('/docs'),
|
||||
createDoc: () => req<Document>('/docs', { method: 'POST' }),
|
||||
getDoc: (id: string) => req<Document>(`/docs/${id}`),
|
||||
@@ -267,6 +295,10 @@ export const api = {
|
||||
// opening bubble (the explanation itself stays English in the card body).
|
||||
translateSuggestion: (id: string) =>
|
||||
req<{ translation: string }>(`/suggestions/${id}/translate`, { method: 'POST' }),
|
||||
// The growth journal: her own accepted edits read back as patterns. Purely a
|
||||
// read-side view of a table Petal already keeps, computed locally with no
|
||||
// model call, so it costs nothing and leaves nothing.
|
||||
growth: () => req<GrowthJournal>('/suggestions/growth'),
|
||||
|
||||
// Version history. listVersions returns metadata only (no bodies); getVersion
|
||||
// loads one full snapshot for preview; snapshotDoc takes an explicit restore
|
||||
|
||||
@@ -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<string>(
|
||||
() => readPref(STORAGE_KEY) || DEFAULT_COMPANION,
|
||||
)
|
||||
@@ -179,6 +205,36 @@ export function PetalCompanion({ wordCount, saveStatus, llmDown, editTick, accep
|
||||
>
|
||||
{bubble.en}
|
||||
</p>
|
||||
|
||||
{/* 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 && (
|
||||
<div className="mt-3 flex flex-wrap items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
acceptInvite(bubble.invite!.prompt)
|
||||
}}
|
||||
className="rounded-full px-3.5 py-1.5 text-sm font-bold"
|
||||
style={{ background: 'var(--color-accent)', color: 'var(--color-plum)' }}
|
||||
>
|
||||
{t.companion.inviteAccept}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
declineInvite()
|
||||
}}
|
||||
className="rounded-full px-3.5 py-1.5 text-sm font-semibold"
|
||||
style={{ background: 'var(--color-surface-alt)', color: 'var(--color-plum)' }}
|
||||
>
|
||||
{t.companion.inviteDecline}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
@@ -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.')
|
||||
})
|
||||
})
|
||||
@@ -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<string, string>()
|
||||
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)
|
||||
})
|
||||
})
|
||||
@@ -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))
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const growth = vi.fn()
|
||||
vi.mock('../../api/client', () => ({ api: { growth: () => growth() } }))
|
||||
|
||||
import { personalCheer, resetPersonalCheersForTests, warmPersonalCheers } from './journalCheers'
|
||||
import { resetPackForTests, setPackLang } from '../../i18n'
|
||||
|
||||
const journal = {
|
||||
kept: 4,
|
||||
kept_before: 2,
|
||||
stuck: [{ phrase: 'make a decision', docs: 3 }],
|
||||
faded: [{ pattern: '在 the morning', times: 2 }],
|
||||
}
|
||||
|
||||
// Let the warm-up promise settle.
|
||||
const settle = () => new Promise((r) => setTimeout(r, 0))
|
||||
|
||||
describe('personal cheers', () => {
|
||||
beforeEach(() => {
|
||||
resetPersonalCheersForTests()
|
||||
resetPackForTests()
|
||||
growth.mockReset()
|
||||
})
|
||||
|
||||
it('says nothing before the journal has arrived — the cheer never waits', () => {
|
||||
growth.mockResolvedValue(journal)
|
||||
warmPersonalCheers()
|
||||
expect(personalCheer()).toBeNull()
|
||||
})
|
||||
|
||||
it('serves each personal line once, then falls silent', async () => {
|
||||
growth.mockResolvedValue(journal)
|
||||
warmPersonalCheers()
|
||||
await settle()
|
||||
|
||||
const first = personalCheer()
|
||||
const second = personalCheer()
|
||||
expect(first).not.toBeNull()
|
||||
expect(second).not.toBeNull()
|
||||
expect(first!.en).not.toBe(second!.en)
|
||||
// Both lines used: personal praise repeated is wallpaper, so the caller is
|
||||
// handed back to the generic pool instead.
|
||||
expect(personalCheer()).toBeNull()
|
||||
})
|
||||
|
||||
it('fetches once however often it is warmed', async () => {
|
||||
growth.mockResolvedValue(journal)
|
||||
warmPersonalCheers()
|
||||
warmPersonalCheers()
|
||||
await settle()
|
||||
warmPersonalCheers()
|
||||
expect(growth).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('is silent when the journal fails, rather than failing visibly', async () => {
|
||||
growth.mockRejectedValue(new Error('offline'))
|
||||
warmPersonalCheers()
|
||||
await settle()
|
||||
expect(personalCheer()).toBeNull()
|
||||
})
|
||||
|
||||
it('speaks the pair the writer is in, resolved at call time', async () => {
|
||||
growth.mockResolvedValue({ ...journal, faded: [] })
|
||||
warmPersonalCheers()
|
||||
await settle()
|
||||
|
||||
setPackLang('pt-PT')
|
||||
const line = personalCheer()
|
||||
expect(line).not.toBeNull()
|
||||
expect(line!.native).toContain('make a decision')
|
||||
expect(line!.native).not.toBe(line!.en)
|
||||
// The English half is the same sentence in every pack.
|
||||
expect(line!.en).toContain('make a decision')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,62 @@
|
||||
// Personal material for the companion, drawn from the growth journal.
|
||||
//
|
||||
// The kitten's cheers are warm but generic — they'd be the same words for
|
||||
// anybody. The journal already knows things that are true of *this* writer and
|
||||
// nobody else ("you're using 'make a decision' on your own now"), and that is a
|
||||
// far better thing to hear after accepting an edit. §5a's own example.
|
||||
//
|
||||
// Three rules keep it from wearing out:
|
||||
// * A given line is served once per session. Personal praise repeated is
|
||||
// wallpaper, and wallpaper is worse than the generic cheer it replaced.
|
||||
// * The journal is fetched lazily, on the first accept, and never awaited —
|
||||
// the first cheer of a session is generic, and that's fine.
|
||||
// * Lines are built at call time from the pack, like every other companion
|
||||
// line, so a bubble composed after /api/me is in the right language.
|
||||
|
||||
import { api, type GrowthJournal } from '../../api/client'
|
||||
import { pack, type Line } from '../../i18n'
|
||||
|
||||
let journal: GrowthJournal | null = null
|
||||
let inFlight = false
|
||||
let served = new Set<string>()
|
||||
|
||||
// warmPersonalCheers starts the one fetch this module ever needs. Safe to call
|
||||
// often; a failure is silent and simply leaves the companion with its generic
|
||||
// pool, which is exactly the pre-journal behaviour.
|
||||
export function warmPersonalCheers(): void {
|
||||
if (journal || inFlight) return
|
||||
inFlight = true
|
||||
api
|
||||
.growth()
|
||||
.then((j) => {
|
||||
journal = j
|
||||
})
|
||||
.catch(() => {
|
||||
/* no journal, no personal cheer — never a visible failure */
|
||||
})
|
||||
.finally(() => {
|
||||
inFlight = false
|
||||
})
|
||||
}
|
||||
|
||||
// personalCheer returns an unserved line about her own writing, or null when
|
||||
// there is none — the caller falls back to the generic pool.
|
||||
export function personalCheer(): Line | null {
|
||||
if (!journal) return null
|
||||
const t = pack()
|
||||
const candidates = [
|
||||
...journal.stuck.map((s) => ({ key: `stuck:${s.phrase}`, line: () => t.journal.cheerStuck(s.phrase) })),
|
||||
...journal.faded.map((f) => ({ key: `faded:${f.pattern}`, line: () => t.journal.cheerFaded(f.pattern) })),
|
||||
].filter((c) => !served.has(c.key))
|
||||
if (candidates.length === 0) return null
|
||||
const chosen = candidates[Math.floor(Math.random() * candidates.length)]
|
||||
served.add(chosen.key)
|
||||
return chosen.line()
|
||||
}
|
||||
|
||||
// Test seam, matching resetPackForTests: module state is per-session by design.
|
||||
export function resetPersonalCheersForTests(): void {
|
||||
journal = null
|
||||
inFlight = false
|
||||
served = new Set()
|
||||
}
|
||||
@@ -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<string, string> = { 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<string, Record<string, string>> = {
|
||||
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<string, string> = {
|
||||
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<Record<PairLang, Rule[]>> = {
|
||||
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:
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -4,16 +4,20 @@ 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'
|
||||
import { isBedtime } from '../../lib/night'
|
||||
|
||||
@@ -21,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 {
|
||||
@@ -39,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.
|
||||
@@ -47,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
|
||||
@@ -63,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)
|
||||
}
|
||||
@@ -71,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<Mood>('idle')
|
||||
const [bubble, setBubble] = useState<Bubble | null>(null)
|
||||
|
||||
@@ -83,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).
|
||||
@@ -143,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)
|
||||
@@ -197,7 +260,13 @@ export function useCompanion({ wordCount, saveStatus, llmDown, editTick, acceptT
|
||||
firstAccept.current = false
|
||||
return
|
||||
}
|
||||
say({ ...pick(encouragements()), tone: 'cheer' }, { celebrate: true })
|
||||
// Prefer something true of her own writing over a line that would fit
|
||||
// anybody — but only sometimes, so the personal ones stay a small surprise
|
||||
// rather than the new default. The journal is fetched on this first accept
|
||||
// and never awaited: the cheer goes out now, personal or not.
|
||||
warmPersonalCheers()
|
||||
const personal = Math.random() < 0.5 ? personalCheer() : null
|
||||
say({ ...(personal ?? pick(encouragements())), tone: 'cheer' }, { celebrate: true })
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [acceptTick])
|
||||
|
||||
@@ -257,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')
|
||||
@@ -290,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)
|
||||
@@ -298,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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import { api, type DocSummary, type Tag, type TagColor } from '../../api/client'
|
||||
import { DocListItem } from './DocListItem'
|
||||
import { SearchBox } from './SearchBox'
|
||||
import { TagChip } from './TagChip'
|
||||
import { LanguagePicker } from './LanguagePicker'
|
||||
import { usePack, type Pack } from '../../i18n'
|
||||
|
||||
interface Props {
|
||||
@@ -156,6 +157,11 @@ export function DocList({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* The pair Petal speaks. Unlike the rows above it this is not about any
|
||||
document, and unlike sign-out it is offered whether or not there is an
|
||||
account behind the session — a local-dev build still has a langpack. */}
|
||||
<LanguagePicker />
|
||||
|
||||
{/* Who's writing, and the way out. Shown only when there's a real account
|
||||
behind the session — a local-dev build has nobody to sign out as. */}
|
||||
{account && (
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
import { useState } from 'react'
|
||||
import { api } from '../../api/client'
|
||||
import { setPackLang, shippedPacks, usePack } from '../../i18n'
|
||||
|
||||
// Which language Petal is her pair in, and how she changes it.
|
||||
//
|
||||
// It lives in the sidebar footer next to her name and the way out, because the
|
||||
// pair is a property of the writer rather than of a document — and because the
|
||||
// sidebar is the mobile drawer, which is the only chrome that is always one tap
|
||||
// away on a phone. The status bar would have been the other candidate; it only
|
||||
// exists while a document is open, which is exactly the wrong time to discover
|
||||
// the app is speaking a language you can't read.
|
||||
//
|
||||
// Each language names itself. A writer who has landed on the wrong pair cannot
|
||||
// read a label that says "Portuguese" in Chinese, so the buttons say 中文 and
|
||||
// Português and nothing else — the one place in Petal where bilingual copy would
|
||||
// actively get in the way.
|
||||
export function LanguagePicker() {
|
||||
const t = usePack()
|
||||
const packs = shippedPacks()
|
||||
const [saving, setSaving] = useState<string | null>(null)
|
||||
const [failed, setFailed] = useState(false)
|
||||
|
||||
// Nothing to choose between — a deployment with one pack shows no picker
|
||||
// rather than a single button that does nothing.
|
||||
if (packs.length < 2) return null
|
||||
|
||||
const choose = async (code: string) => {
|
||||
if (code === t.code || saving) return
|
||||
setSaving(code)
|
||||
setFailed(false)
|
||||
try {
|
||||
const me = await api.setPairLang(code)
|
||||
// The server's answer, not the code we asked for. Everything downstream —
|
||||
// her dictionary, the read-aloud voice, the word lookups — follows the
|
||||
// pack, so it must follow what was actually stored.
|
||||
setPackLang(me.pair_lang)
|
||||
} catch {
|
||||
// A 401 has already surfaced as the sign-in overlay through the client's
|
||||
// interceptor; anything else leaves her on the pair she was already on,
|
||||
// which is a working app and worth saying so plainly.
|
||||
setFailed(true)
|
||||
} finally {
|
||||
setSaving(null)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-1 px-1">
|
||||
<div className="flex items-center gap-2 text-xs" style={{ color: 'var(--color-muted)' }}>
|
||||
<span className="shrink-0 font-semibold">{t.docs.language}</span>
|
||||
<div className="ml-auto flex shrink-0 gap-1">
|
||||
{packs.map((p) => {
|
||||
const active = p.code === t.code
|
||||
return (
|
||||
<button
|
||||
key={p.code}
|
||||
type="button"
|
||||
onClick={() => void choose(p.code)}
|
||||
disabled={saving !== null}
|
||||
aria-pressed={active}
|
||||
// The one label a writer on the wrong pair still recognises.
|
||||
aria-label={`Petal speaks ${p.nativeName}`}
|
||||
lang={p.code}
|
||||
className="petal-tap-sm px-2.5 py-1 text-xs font-bold transition-colors disabled:opacity-60"
|
||||
style={{
|
||||
borderRadius: 'var(--radius-pill)',
|
||||
background: active ? 'var(--color-accent)' : 'var(--color-surface)',
|
||||
color: active ? '#fff' : 'var(--color-plum)',
|
||||
boxShadow: active ? 'none' : 'var(--shadow-soft)',
|
||||
}}
|
||||
>
|
||||
{p.nativeName}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
{failed && (
|
||||
<span className="text-[0.7rem]" style={{ color: 'var(--color-accent)' }}>
|
||||
{t.docs.languageFailed}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -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 (
|
||||
<div
|
||||
@@ -130,6 +135,28 @@ export function WordCard({ word, info, loading, saved, onToggleSave, style, onRe
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 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 && (
|
||||
<div
|
||||
className="mt-2.5 rounded-xl px-2.5 py-2"
|
||||
style={{ background: 'var(--color-lavender)' }}
|
||||
>
|
||||
<p className="leading-snug" style={{ color: 'var(--color-plum)' }}>
|
||||
<span aria-hidden className="mr-1">
|
||||
🫖
|
||||
</span>
|
||||
{falseFriend.native}
|
||||
</p>
|
||||
<p className="mt-0.5 text-xs leading-snug" style={{ color: 'var(--color-muted)' }}>
|
||||
{falseFriend.en}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Chinese gloss first — it's what the Mandarin-speaking writer reaches for. */}
|
||||
{gloss && (
|
||||
<p
|
||||
|
||||
@@ -3,6 +3,7 @@ import { api, type VocabGrade, type VocabWord } from '../../api/client'
|
||||
import { speak, speechSupported, stopSpeech } from '../../audio/speech'
|
||||
import { useFocusTrap } from '../../hooks/useFocusTrap'
|
||||
import { usePack, type Line } from '../../i18n'
|
||||
import { JournalView } from './JournalView'
|
||||
|
||||
// GardenPanel is the vocabulary garden: every word the writer has looked up,
|
||||
// grown into a blossom that opens further the more she remembers it, plus a
|
||||
@@ -51,6 +52,7 @@ export function GardenPanel({ onClose, onOpenDoc }: Props) {
|
||||
const [cursor, setCursor] = useState(0)
|
||||
const [revealed, setRevealed] = useState(false)
|
||||
const [expanded, setExpanded] = useState<string | null>(null)
|
||||
const [tab, setTab] = useState<'garden' | 'journal'>('garden')
|
||||
const panelRef = useFocusTrap<HTMLElement>()
|
||||
|
||||
// Read-aloud is fire-and-forget, so a word she tapped could still be speaking
|
||||
@@ -155,7 +157,7 @@ export function GardenPanel({ onClose, onOpenDoc }: Props) {
|
||||
<div>
|
||||
<div className="text-base font-extrabold text-plum">{t.garden.titleWithFlower}</div>
|
||||
<div className="text-xs" style={{ color: 'var(--color-muted)' }}>
|
||||
{queue ? t.garden.reviewing : t.garden.subtitle}
|
||||
{queue ? t.garden.reviewing : tab === 'journal' ? t.journal.subtitle : t.garden.subtitle}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
@@ -171,6 +173,31 @@ export function GardenPanel({ onClose, onOpenDoc }: Props) {
|
||||
</button>
|
||||
</header>
|
||||
|
||||
{/* The two halves of learning: words and phrasing she has collected
|
||||
(garden), and how her writing has changed (journal). A review
|
||||
session takes over the panel entirely — mid-flashcard is no moment
|
||||
to be offered a different page. */}
|
||||
{!queue && (
|
||||
<div className="flex gap-1 px-4 pt-3">
|
||||
{(['garden', 'journal'] as const).map((id) => (
|
||||
<button
|
||||
key={id}
|
||||
type="button"
|
||||
onClick={() => setTab(id)}
|
||||
aria-pressed={tab === id}
|
||||
className="flex-1 rounded-full px-3 py-1.5 text-xs font-bold"
|
||||
style={{
|
||||
background: tab === id ? 'var(--color-surface-alt)' : 'transparent',
|
||||
border: `1px solid ${tab === id ? 'var(--color-border)' : 'transparent'}`,
|
||||
color: tab === id ? 'var(--color-plum)' : 'var(--color-muted)',
|
||||
}}
|
||||
>
|
||||
{id === 'garden' ? t.journal.tabGarden : t.journal.tabJournal}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{queue ? (
|
||||
<ReviewSession
|
||||
queue={queue}
|
||||
@@ -180,6 +207,8 @@ export function GardenPanel({ onClose, onOpenDoc }: Props) {
|
||||
onGrade={grade}
|
||||
onQuit={() => setQueue(null)}
|
||||
/>
|
||||
) : tab === 'journal' ? (
|
||||
<JournalView />
|
||||
) : (
|
||||
<GardenView
|
||||
words={words}
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { api, type GrowthJournal } from '../../api/client'
|
||||
import { usePack } from '../../i18n'
|
||||
|
||||
// The growth journal — the garden's second tab.
|
||||
//
|
||||
// It lives here rather than behind its own button because it is the same idea:
|
||||
// the garden shows what she is learning as objects, the journal shows it as
|
||||
// change over time. Both are read back out of work she already did.
|
||||
//
|
||||
// What this component deliberately does NOT render: a score, a percentage, a
|
||||
// target, a streak, or anything at all when there is nothing honest to say. The
|
||||
// backend already refuses to report growth for a month she was simply away; the
|
||||
// only job left here is to stay quiet when it hands back an empty journal.
|
||||
|
||||
export function JournalView() {
|
||||
const t = usePack()
|
||||
const [journal, setJournal] = useState<GrowthJournal | null>(null)
|
||||
const [error, setError] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
let live = true
|
||||
api
|
||||
.growth()
|
||||
.then((j) => live && setJournal(j))
|
||||
.catch(() => live && setError(true))
|
||||
return () => {
|
||||
live = false
|
||||
}
|
||||
}, [])
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<Wrap>
|
||||
<p className="text-sm" style={{ color: 'var(--color-muted)' }}>
|
||||
Couldn’t read your journal just now.
|
||||
</p>
|
||||
</Wrap>
|
||||
)
|
||||
}
|
||||
if (!journal) {
|
||||
return (
|
||||
<Wrap>
|
||||
<p className="text-sm" style={{ color: 'var(--color-muted)' }}>
|
||||
Loading…
|
||||
</p>
|
||||
</Wrap>
|
||||
)
|
||||
}
|
||||
|
||||
const nothingYet = journal.kept === 0 && journal.stuck.length === 0 && journal.faded.length === 0
|
||||
if (nothingYet) {
|
||||
return (
|
||||
<Wrap>
|
||||
<div className="px-3 py-10 text-center" style={{ color: 'var(--color-muted)' }}>
|
||||
<div className="mb-2 text-4xl">🌱🐱💤</div>
|
||||
<p className="text-sm leading-relaxed">{t.journal.empty}</p>
|
||||
</div>
|
||||
</Wrap>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Wrap>
|
||||
<div className="flex flex-col gap-5">
|
||||
{journal.kept > 0 && (
|
||||
<Section head={t.journal.keptHead}>
|
||||
<p className="text-sm font-bold text-plum">{t.journal.kept(journal.kept)}</p>
|
||||
{journal.kept_before > 0 && (
|
||||
<p className="text-xs" style={{ color: 'var(--color-muted)' }}>
|
||||
{t.journal.keptBefore(journal.kept_before)}
|
||||
</p>
|
||||
)}
|
||||
</Section>
|
||||
)}
|
||||
|
||||
{journal.stuck.length > 0 && (
|
||||
<Section head={t.journal.stuckHead}>
|
||||
<ul className="flex flex-col gap-2">
|
||||
{journal.stuck.map((s) => (
|
||||
<li key={s.phrase} className="flex items-start gap-2 text-sm leading-snug text-plum">
|
||||
<span aria-hidden>🌸</span>
|
||||
<span>{t.journal.stuck(s.phrase, s.docs)}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</Section>
|
||||
)}
|
||||
|
||||
{journal.faded.length > 0 && (
|
||||
<Section head={t.journal.fadedHead}>
|
||||
<ul className="flex flex-col gap-2">
|
||||
{journal.faded.map((f) => (
|
||||
<li key={f.pattern} className="flex items-start gap-2 text-sm leading-snug text-plum">
|
||||
<span aria-hidden>🌿</span>
|
||||
<span>{t.journal.faded(f.pattern, f.times)}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</Section>
|
||||
)}
|
||||
</div>
|
||||
</Wrap>
|
||||
)
|
||||
}
|
||||
|
||||
function Wrap({ children }: { children: React.ReactNode }) {
|
||||
return <div className="min-h-0 flex-1 overflow-y-auto px-5 py-4">{children}</div>
|
||||
}
|
||||
|
||||
function Section({ head, children }: { head: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<section
|
||||
className="rounded-2xl px-4 py-3"
|
||||
style={{ background: 'var(--color-surface-alt)', border: '1px solid var(--color-border)' }}
|
||||
>
|
||||
<h3 className="mb-1.5 text-[11px] font-extrabold uppercase tracking-wide" style={{ color: 'var(--color-muted)' }}>
|
||||
{head}
|
||||
</h3>
|
||||
{children}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { onPackChange, pack, resetPackForTests, setPackLang } from './index'
|
||||
import { onPackChange, pack, resetPackForTests, setPackLang, shippedPacks } from './index'
|
||||
import { zh } from './packs/zh'
|
||||
import { ptPT } from './packs/pt-PT'
|
||||
import type { Pack } from './types'
|
||||
@@ -61,6 +61,23 @@ describe('pack selection', () => {
|
||||
expect(seen).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
// What the sidebar picker offers. It is derived from the packs rather than
|
||||
// listed a second time, so a pack that ships is a pair she can choose — and a
|
||||
// pair with no pack can never be offered, which is the invariant the server's
|
||||
// matching allowlist exists to enforce from the other side.
|
||||
it('offers exactly the pairs it has copy for', () => {
|
||||
const codes = shippedPacks().map((p) => p.code)
|
||||
expect(codes.sort()).toEqual(['pt-PT', 'zh'])
|
||||
// Every offered pair names itself, because a writer stranded on the wrong
|
||||
// pack can only read the label that is in her own language.
|
||||
for (const p of shippedPacks()) expect(p.nativeName.length).toBeGreaterThan(0)
|
||||
// Anything the picker offers must actually resolve.
|
||||
for (const code of codes) {
|
||||
setPackLang(code)
|
||||
expect(pack().code).toBe(code)
|
||||
}
|
||||
})
|
||||
|
||||
it('stops notifying after unsubscribe', () => {
|
||||
const seen = vi.fn()
|
||||
const off = onPackChange(seen)
|
||||
@@ -97,6 +114,9 @@ describe('the zh pack', () => {
|
||||
expect(zh.garden.growing(2)).toContain('2 blossoms growing')
|
||||
expect(zh.history.daysAgo(1)).toBe('1 day ago · 1 天前')
|
||||
expect(zh.history.daysAgo(3)).toBe('3 days ago · 3 天前')
|
||||
expect(zh.journal.kept(1)).toContain('1 thing you took on board')
|
||||
expect(zh.journal.kept(9)).toContain('9 things you took on board')
|
||||
expect(zh.journal.stuck('make a decision', 3)).toContain('3 of your pieces')
|
||||
})
|
||||
|
||||
// A pack with a hole in it renders an empty label rather than failing, which
|
||||
@@ -194,6 +214,20 @@ describe('the pt-PT pack', () => {
|
||||
expect(ptPT.garden.reviewDue(4)).toContain('4 palavras ·')
|
||||
expect(ptPT.garden.growing(1)).toContain('1 flor no jardim')
|
||||
expect(ptPT.garden.growing(3)).toContain('3 flores no jardim')
|
||||
expect(ptPT.journal.kept(1)).toContain('1 coisa que')
|
||||
expect(ptPT.journal.kept(5)).toContain('5 coisas que')
|
||||
})
|
||||
|
||||
// The growth journal is the one surface that talks about her progress, so it
|
||||
// is the one most easily spoiled by a stray comparison. The rule is enforced
|
||||
// in SQL on the backend; here it is enforced in the copy.
|
||||
it('keeps the journal to growth and to her own past self', () => {
|
||||
const text = JSON.stringify({ zh: zh.journal, pt: ptPT.journal }, (_k, v) =>
|
||||
typeof v === 'function' ? JSON.stringify(v(2, 3)) : v,
|
||||
)
|
||||
for (const bad of ['error', 'mistake', 'wrong', 'streak', 'average', 'erro', 'errada', '错误']) {
|
||||
expect(text.toLowerCase(), `the journal must not talk about "${bad}"`).not.toContain(bad)
|
||||
}
|
||||
})
|
||||
|
||||
it('says the collision line the zh pair never needed', () => {
|
||||
@@ -203,3 +237,43 @@ describe('the pt-PT pack', () => {
|
||||
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)
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
@@ -26,6 +26,15 @@ const PACKS: Partial<Record<PairLang, Pack>> = { zh, 'pt-PT': ptPT }
|
||||
|
||||
const DEFAULT_LANG: PairLang = 'zh'
|
||||
|
||||
// The pairs the picker may offer, derived from PACKS rather than listed again —
|
||||
// a pack that exists is a pair Petal can render itself in, and that is the whole
|
||||
// condition. The server keeps its own copy of this list (auth.shippedPairs) and
|
||||
// refuses anything outside it; the two are expected to land together when a new
|
||||
// pack ships.
|
||||
export function shippedPacks(): Pack[] {
|
||||
return Object.values(PACKS).filter((p): p is Pack => Boolean(p))
|
||||
}
|
||||
|
||||
type Listener = () => void
|
||||
|
||||
let current: Pack = zh
|
||||
|
||||
@@ -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: {
|
||||
@@ -160,6 +270,8 @@ export const ptPT: Pack = {
|
||||
noMatches: 'Sem resultados · No matches',
|
||||
tags: 'Etiquetas · Tags',
|
||||
newTagPlaceholder: 'Nova etiqueta · New tag',
|
||||
language: 'Idioma · Language',
|
||||
languageFailed: 'Não deu para mudar — continua na mesma língua · Couldn’t switch',
|
||||
},
|
||||
|
||||
editor: {
|
||||
@@ -258,6 +370,30 @@ export const ptPT: Pack = {
|
||||
gradeEasy: { native: 'Fácil', en: 'Easy' },
|
||||
},
|
||||
|
||||
journal: {
|
||||
tabGarden: '🌷 Jardim · Garden',
|
||||
tabJournal: '🌱 Progresso · Growth',
|
||||
subtitle: 'Your own writing, month by month — only ever you and your past self',
|
||||
empty: 'Escreve mais um pouco — esta página nasce do teu próprio trabalho. · Keep writing; this page grows out of your own work.',
|
||||
keptHead: 'Este mês · This month',
|
||||
kept: (n) => `${n} coisa${n === 1 ? '' : 's'} que aproveitaste · ${n} thing${n === 1 ? '' : 's'} you took on board`,
|
||||
keptBefore: (n) => `${n} no mês anterior · ${n} the month before`,
|
||||
stuckHead: 'Ficou contigo · Stayed with you',
|
||||
stuck: (phrase, docs) =>
|
||||
`«${phrase}» — já a usas sozinha, em ${docs} textos teus · now in ${docs} of your pieces`,
|
||||
fadedHead: 'Já não precisas de corrigir · You stopped needing this',
|
||||
faded: (pattern, times) =>
|
||||
`«${pattern}» — ${times}× nessa altura, nenhuma este mês · ${times}× back then, none this month`,
|
||||
cheerStuck: (phrase) => ({
|
||||
native: `Já escreves «${phrase}» sozinha! 🌱`,
|
||||
en: `You’re using “${phrase}” on your own now! 🌱`,
|
||||
}),
|
||||
cheerFaded: (pattern) => ({
|
||||
native: `Há já algum tempo que «${pattern}» não precisa de correção 😌`,
|
||||
en: `“${pattern}” hasn’t needed fixing in a while 😌`,
|
||||
}),
|
||||
},
|
||||
|
||||
history: {
|
||||
title: 'Histórico · History',
|
||||
kinds: {
|
||||
|
||||
@@ -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',
|
||||
@@ -145,6 +179,8 @@ export const zh: Pack = {
|
||||
noMatches: '没有找到 · No matches',
|
||||
tags: '标签 · Tags',
|
||||
newTagPlaceholder: '新标签 · New tag',
|
||||
language: '语言 · Language',
|
||||
languageFailed: '没能换成功,还是原来的语言 · Couldn’t switch — still the same language',
|
||||
},
|
||||
|
||||
editor: {
|
||||
@@ -244,6 +280,29 @@ export const zh: Pack = {
|
||||
gradeEasy: { native: '太简单', en: 'Easy' },
|
||||
},
|
||||
|
||||
journal: {
|
||||
tabGarden: '🌷 花园 · Garden',
|
||||
tabJournal: '🌱 成长 · Growth',
|
||||
subtitle: 'Your own writing, month by month — only ever you and your past self',
|
||||
empty: '再写一阵子,这里就会长出东西来。· Keep writing — this page grows out of your own work.',
|
||||
keptHead: '这个月 · This month',
|
||||
kept: (n) => `你采纳了 ${n} 处建议 · ${n} thing${n === 1 ? '' : 's'} you took on board`,
|
||||
keptBefore: (n) => `上个月是 ${n} 处 · ${n} the month before`,
|
||||
stuckHead: '记住了 · Stayed with you',
|
||||
stuck: (phrase, docs) => `“${phrase}” — 你后来又自己用了,出现在 ${docs} 篇里 · now in ${docs} of your pieces`,
|
||||
fadedHead: '不再需要改了 · You stopped needing this',
|
||||
faded: (pattern, times) =>
|
||||
`“${pattern}” — 以前改过 ${times} 次,这个月一次都没有 · ${times}× back then, none this month`,
|
||||
cheerStuck: (phrase) => ({
|
||||
native: `“${phrase}” 你现在自己就会用了!🌱`,
|
||||
en: `You’re using “${phrase}” on your own now! 🌱`,
|
||||
}),
|
||||
cheerFaded: (pattern) => ({
|
||||
native: `好久没见你写错 “${pattern}” 了 😌`,
|
||||
en: `“${pattern}” hasn’t needed fixing in a while 😌`,
|
||||
}),
|
||||
},
|
||||
|
||||
history: {
|
||||
title: '历史 · History',
|
||||
kinds: {
|
||||
|
||||
@@ -69,6 +69,13 @@ export interface Pack {
|
||||
milestone: (words: number) => Line
|
||||
// Mascot names, keyed by the companion ids in companions.ts.
|
||||
names: Record<string, string>
|
||||
// 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<string, Line>
|
||||
|
||||
docs: {
|
||||
sortRecent: string
|
||||
sortTitle: string
|
||||
@@ -109,6 +145,12 @@ export interface Pack {
|
||||
noMatches: string
|
||||
tags: string
|
||||
newTagPlaceholder: string
|
||||
// The language picker in the sidebar. `language` labels it; `languageFailed`
|
||||
// is what she reads if the change doesn't reach the server — it has to say
|
||||
// that nothing moved, because the app is still speaking the old pair and a
|
||||
// silent no-op would read as Petal ignoring her.
|
||||
language: string
|
||||
languageFailed: string
|
||||
}
|
||||
|
||||
editor: {
|
||||
@@ -199,6 +241,34 @@ export interface Pack {
|
||||
gradeEasy: Line
|
||||
}
|
||||
|
||||
// The growth journal, a second tab inside the garden: what she has been
|
||||
// learning, read back out of her own accepted edits.
|
||||
//
|
||||
// Every line here is bound by two rules the backend enforces in SQL, and the
|
||||
// copy must not undo them: it reports growth rather than tallying mistakes,
|
||||
// and the only writer it ever compares her to is herself. A pack author has
|
||||
// room to change the warmth and the word order; there is no room for a line
|
||||
// that grades her, congratulates her on beating anyone, or invents a streak.
|
||||
journal: {
|
||||
tabGarden: string
|
||||
tabJournal: string
|
||||
subtitle: string
|
||||
// Nothing to say yet — a quiet month stays quiet rather than being padded.
|
||||
empty: string
|
||||
keptHead: string
|
||||
kept: (n: number) => string
|
||||
// Last month's number, offered flat: no better, no worse, just her own past.
|
||||
keptBefore: (n: number) => string
|
||||
stuckHead: string
|
||||
stuck: (phrase: string, docs: number) => string
|
||||
fadedHead: string
|
||||
faded: (pattern: string, times: number) => string
|
||||
// The journal's material, handed to the companion. Genuinely personal
|
||||
// praise beats a generic cheer, which is the whole point of §5a.
|
||||
cheerStuck: (phrase: string) => Line
|
||||
cheerFaded: (pattern: string) => Line
|
||||
}
|
||||
|
||||
history: {
|
||||
title: string
|
||||
kinds: Record<string, Line> // manual | auto | pre_restore
|
||||
|
||||
Reference in New Issue
Block a user