Compare commits

...
Author SHA1 Message Date
prosolis 15398eab4d What the browser found that the tests could not
Four defects behind passing tests, and the reason they passed: the
fixtures were written by the same hand as the code and all argued
their own case.

Claude-Session: https://claude.ai/code/session_01GJHNvirh7Hzhc9RL3HAvz7
2026-07-29 00:38:42 -07:00
prosolis 2c5b05b398 A word of her own language could never reach the garden
Right-clicking "carro" in a Portuguese document showed a full word card
— gloss, phonetic, two definitions — and stored nothing. The forward
lookup of a Portuguese word answers empty; everything the card renders
comes out of `reverse`, and the capture gate only ever read the forward
fields. Academic while every document was English. On a Portuguese
document it emptied the garden of exactly the words she met.

So the reverse reading now counts as known, and fills `definition` (its
gloss is the English sense — "carro" → "car; automobile; machine",
which is what a review card wants) and `phonetic`. Forward lookups are
untouched: the reverse fields are read only where the forward ones are
empty, so an English word with a Portuguese gloss captures exactly as
it did.

Claude-Session: https://claude.ai/code/session_01GJHNvirh7Hzhc9RL3HAvz7
2026-07-29 00:35:15 -07:00
prosolis acb35108c0 Curated against English had quietly become curated against writing
Seen live: "Esta manhã acordei cedo e fui correr ao longo da marginal.
O ar estava fresco e havia poucas pessoas na rua." — unremarkable
Portuguese, two marker hits, zero English hits, and a verdict of
English. Corrected as English, read aloud in an American voice.

The list was missing the ordinary machinery of the language: the
contractions (ao, à, num), the tenses a diary is written in (estava,
havia, fomos), the words that join two clauses (até, depois, então,
onde). Each clears the bar the list already set — an English sentence
has no reason to contain them — so their absence bought nothing.

The floor stays at three. What changed is that three is now reachable
by prose rather than only by a paragraph that argues its own case. fr
and es get the same additions by analogy; neither has an account yet to
catch it live, which is exactly how this one survived. "sin" and "tan"
stay out of the es list: both are English words.

Two regression tests, pointed in opposite directions — ordinary
Portuguese must read as hers, and English about Portugal, English
quoting Portuguese, and a plain English diary must all still read as
English, held verdict included.

Claude-Session: https://claude.ai/code/session_01GJHNvirh7Hzhc9RL3HAvz7
2026-07-29 00:28:32 -07:00
prosolis e67f77eb05 The pass announces the verdict it just decided
Storing doc_lang on the document row is not enough on its own. The
editor sees that row when the document is opened or saved, and the pass
that decides the verdict runs after a save — so the client was always
one save behind, and read-aloud is reached for precisely when she has
stopped typing and no further save is coming. Heard in a browser: a
Portuguese paragraph read in an American voice, twice, until another
keystroke went in.

/check, /voice and /collocation now answer with X-Petal-Doc-Lang. A
header rather than a wider body: all three answer with a bare array of
the unified pending set and every caller reads it as one, and a verdict
is metadata about the pass rather than another suggestion. It reaches
the app through the same handler shape onUnauthorized already uses.

Claude-Session: https://claude.ai/code/session_01GJHNvirh7Hzhc9RL3HAvz7
2026-07-29 00:21:15 -07:00
prosolis c719effe1d The verdict has to reach the editor while she is still typing
Caught in the browser: a Portuguese document was read aloud in an
American voice until the page was reloaded. The code picking the voice
was right — the verdict never arrived. useAutoSave discarded the save
response, which was fine while every field in it was one the client had
just sent, and stopped being fine when doc_lang arrived: a field only
the server decides.

The hook now hands the saved row back, and App lifts exactly one field
out of it, only when it changed. Copying the rest back mid-keystroke is
a way to lose a character, not to gain one.

Claude-Session: https://claude.ai/code/session_01GJHNvirh7Hzhc9RL3HAvz7
2026-07-29 00:15:34 -07:00
prosolis 1fdc206576 Merge feat/document-language: Petal follows the writing into her own language
Phase 28 whole — corrections and explanations in the document's
language, the translate card pointed the right way, and a garden that
knows which language each card is in.

Claude-Session: https://claude.ai/code/session_01GJHNvirh7Hzhc9RL3HAvz7
2026-07-29 00:07:30 -07:00
prosolis 466055020f The garden learns which language a card is in, and read-aloud stops guessing
Phase 28 (c), the last of the phase. A word met inside a Portuguese
document is a Portuguese card: migration 0018 mirrors documents.doc_lang
onto vocab_words, set server-side from the ownership lookup capture was
already making. Every card still reviews — filtering the queue to the
half she is learning would drop the words she actually met.

Read-aloud was the larger surprise. detectLang routed Han/kana to
Chinese and everything else to en-US, so the zh pair was accidentally
right and every Latin pair wrong. doc_lang now reaches the client
read-only on the document JSON, and docLang(text, verdict) answers for a
passage taken out of it — with the script test still winning, because
quoted Chinese must never be spelled out one "Chinese letter" at a time.

Claude-Session: https://claude.ai/code/session_01GJHNvirh7Hzhc9RL3HAvz7
2026-07-28 23:45:26 -07:00
prosolis 29eb2fe1fc The translate card, pointed the other way, and a call that no longer happens
Phase 28's step (b): both remaining items are about direction, and both had
a wrong answer that looked right.

isTranslation could not simply be read backwards. readsAsEnglish is a
deliberately low bar — Latin letters, not swamped by another script — which
every Portuguese sentence clears as easily as English does, so swapping its
two halves would have called every genuine Portuguese correction inside a
Portuguese document a translation. The flipped direction uses sentenceLang
from doclang.go instead, where English has its own curated marker list and
has to out-evidence the pair language to win. The English-document path is
untouched; reconcilePending carries the verdict to ask the question the
right way round.

The tap-through's whole observable change is a model call that stops
happening. /suggestions/{id}/translate now recovers the explanation's
language by re-running targetFor rather than assuming the pair, which gives
today's answer everywhere except the case that was broken: the Portuguese
writer whose explanation already arrived in Portuguese, previously
round-tripped through the model into Portuguese again. It answers "" there,
and the client's existing `res.translation.trim() || explanation` fallback
seeds the bubble with the explanation itself — no frontend change at all.
It deliberately does not render that explanation into English on the
grounds that English is technically the other half: an unasked-for
rendering into the language she is practising is noise, not a seed.

Tests pin both directions of the detector, with Portuguese-in-Portuguese as
the case the file exists for, plus four handler tests through the real
/check and /translate paths — including the skipped seed asserting the
model was never called, and the learning_pair zh learner whose English
explanation still renders into Chinese.

Left of the phase: (c) the garden's language tagging and read-aloud.

Claude-Session: https://claude.ai/code/session_01GJHNvirh7Hzhc9RL3HAvz7
2026-07-28 23:29:50 -07:00
prosolis 76dede8856 Correct the language she wrote in, not the one she was practising
Every pass was English-shaped: CheckpointMessages took the text and the tone and
nothing else, so there was never a language decision to get wrong. On the live
build two pt-PT sentences drew no cards at all — Petal read the Portuguese, said
nothing about it, and filed a mechanics note about the one English line.

The rule is two decisions reading different state. What gets corrected follows
the document. What language the explanation is written in follows the writer —
the half of her pair she is not learning, from users.direction — because an
explanation is teaching, and teaching lands in the language she reads most
easily. Those coincide for every account that exists today (learnerPairs is
{"zh"}), which is a fact about the roster and not about the design, so Target
keeps them apart. It carries a third language too: the collocation gloss is
addressed to her rather than to the document, and folding it into Explain would
have quietly moved it into English on every English document.

The document verdict is a proportion, not a presence — one Portuguese quotation
must not flip an English essay. Per sentence, three-way: pair, English, or no
answer. The third value is the load-bearing one; counting the undecided as
English is exactly what would hold a journal of short Portuguese sentences in
English forever, so the Latin pairs needed an englishMarkers list curated against
pt/fr/es as carefully as latinMarkers was curated against English. Hysteresis at
70/40 because a bilingual paragraph would otherwise alternate its cards' language
every few keystrokes, and hysteresis needs a yesterday — hence the column. Plus a
corroboration floor: a ratio computed over "Não. Eu." is 100% of nothing, and a
flip rewrites every card in the document.

The verdict folds into the chunk salt beside the tone, so a document that changes
language re-opens every sentence rather than serving back cards in a language it
no longer speaks.

checkpointSystemPrompt could not simply take a language — it opens by naming the
reader an ESL learner, and appending "explain in Portuguese" hands the model two
contradictory framings. Separate constants, sharing the JSON contract below the
framing. Both carry a "never translate it into English" line, which is the
instruction the model will most want to disobey. The English prompts are
untouched byte for byte, and a golden says so out loud.

Collocation deliberately did not move: its prompt is per-language knowledge, not
framing, and "natives usually say" for Portuguese is a claim Petal cannot back.

Not deployed and not smoked against a real model. The tests drive the real router
and a real DB; what none of them prove is how Qwen behaves on a Portuguese
document, in particular whether the never-translate line holds.

Claude-Session: https://claude.ai/code/session_01GJHNvirh7Hzhc9RL3HAvz7
2026-07-28 23:20:53 -07:00
prosolis db9cfb7abf Merge fix/companion-flicker-probe: the kitten holds still, for real this time 2026-07-28 22:21:33 -07:00
prosolis f82f2b589d Measure the corner the kitten sits in, not the kitten
The overlap test read the badge's own box, which bobs, shrinks 10% when it
yields, and grows 4% on hover — so the mascot's answer to "is a card in my way"
depended on what it was currently doing. Against the live build, with three
cards up and no bubble, it flipped between drawn widths of 293 and 264 on its
own: the 0.9 yield scale, cycling.

Measure a probe span instead. It sits exactly where the badge sits, never
animates, and moves the size variable to .petal-corner so both are sized from
the same number. Nothing the mascot does can now change what it yields to.

Cards also settle a pixel from the corner routinely — a rail re-pack, a resize,
a browser bar appearing — so hold a yield until the card has retreated 24px
rather than deciding on the exact edge.

Verified in a real browser over CDP: seven overlap depths against the mascot's
top edge, each settling once and holding, drawn width steady where it used to
swing.

Claude-Session: https://claude.ai/code/session_01GJHNvirh7Hzhc9RL3HAvz7
2026-07-28 22:21:33 -07:00
prosolis 5cd3aeabde Merge fix/companion-overlap-flicker: the kitten holds still 2026-07-28 21:59:20 -07:00
prosolis 047f4ae67f Stop the kitten measuring the size it shrank itself to
The mascot shrinks a step when it yields to a card, and the overlap test read
getBoundingClientRect, which reports the scaled box. So a card resting at the
mascot's edge drove a loop: overlap, shrink, no overlap, grow, overlap again,
every poll tick.

Measure the layout box instead — centre plus offsetWidth/offsetHeight, which
the yield transform doesn't move. Hysteresis would have hidden the pulsing;
this removes the path that caused it.

Claude-Session: https://claude.ai/code/session_01GJHNvirh7Hzhc9RL3HAvz7
2026-07-28 21:59:20 -07:00
prosolis f2dd30628a Merge feat/keyboard-triage: the whole queue answered without a mouse
Completes item 8 of the UX review. Item 3's incremental surfacing is now the
only thing left in it.

Claude-Session: https://claude.ai/code/session_01GJHNvirh7Hzhc9RL3HAvz7
2026-07-28 21:11:49 -07:00
prosolis c6bf36bddf Triage the whole queue from the keyboard, and never type an n
The last of the UX review's item 8. Ctrl+. and Ctrl+, step through the
underlines from anywhere in the text; the card that opens takes focus and
answers Tab / Shift+Tab / Enter / Del / ? / Esc itself. Answering a card
advances to the next by itself, and the last one closes and puts the caret
back in the prose — so a document is triaged in five presses of Enter.

The item asked for bare Tab or n/p. Neither can exist in a text editor: an
unmodified letter is a letter. They work fine once a card holds focus, which
is where the item wanted them; getting there needs a chord that is safe to
press mid-sentence, and mid-composition, so the entry keys are IME-guarded
like every other binding.

The queue is the underlines read off the decoration DOM in document order,
not the suggestion list: a stop she cannot see is worse than one she never
visits, and it guarantees the card can anchor itself.

Escape is stopped at the card. Unhandled it would also have left
distraction-free mode, restoring the sidebar and — via the rail-follows-the-
mode fix — pulling the rail out from under her mid-triage.

The legend is bilingual and leads with the pair language, unlike the card's
English buttons: those name what she is learning, this is an instruction for
operating Petal, like the status bar. Key names are as printed on her
keyboard (Entrée, Suppr, Intro, Supr). The es pack's own punctuation test
caught the "?" and is right in general; the key cap is one named exemption.

Verified in a real browser at 1517x810 on a fresh database with no model,
over CDP — a keystroke feature deserves real keystrokes. Both layouts, wrap
in both directions, the accept/dismiss/advance loop, Ask Petal and back, the
full triage-to-empty criterion, and Accept-all clicked from a keyboard card.
The wiring has no unit test for the reason items 6, 7 and 8 recorded: jsdom
has no layout. triage.ts is pure and tested; browser-verified is written
down as browser-verified.

Claude-Session: https://claude.ai/code/session_01GJHNvirh7Hzhc9RL3HAvz7
2026-07-28 21:11:42 -07:00
prosolis 178cb7ae67 Merge: the zh learner direction, the es pair, IME composition guards 2026-07-28 20:04:22 -07:00
42 changed files with 2443 additions and 147 deletions
+53
View File
File diff suppressed because one or more lines are too long
+180 -8
View File
@@ -958,7 +958,146 @@ categories are the unit she can reason about — five article fixes are one
decision, but her whole queue is not — and a single button that rewrites the decision, but her whole queue is not — and a single button that rewrites the
document in one press is the opposite of a tool that teaches. document in one press is the opposite of a tool that teaches.
**Still open in item 8:** the keyboard triage flow. ### 8 — Keyboard triage DONE (eleventh session). The item's own keys were the one part that couldn't be built.
This is the last of item 8, and the last small item in the review. It arrives with
half of it already built by item 7: because the anchored popover is the primary
surface in *both* layouts, one keyboard flow covers rail and no-rail, and there
was never a question of driving two.
**The item asks for Tab/Shift+Tab "or n/p", and n/p cannot exist.** This is a
text editor. An unmodified letter is a letter, and `n` would type an n in the
middle of her sentence. Tab is nearly as bad while the caret is in the prose.
Both are fine *once a card is open and holding focus* — which is exactly where
the item asks for them — so the only real design question was how to get there,
and that needs a key that is safe to press mid-sentence. Mid-*composition*, even:
she writes Chinese, and a Chinese IME uses `,` and `.` to page its candidate
window, so the entry chord is guarded by `fromIME` like every other key Petal
binds.
**The shape, then:**
- `Ctrl/Cmd+.` and `Ctrl/Cmd+,` step to the next/previous underline from
anywhere in the text, which is both how triage is entered and how it is
continued. They join the existing `Ctrl+F` / `Ctrl+D` / `Ctrl+J` family in the
same handler.
- The card that opens **takes focus**, and from there the item's keys work as
written: `Tab`/`Shift+Tab` step, `Enter` accepts, `Del`/`Backspace` dismisses,
`Esc` leaves. `?` opens Ask Petal — the panel focuses its own input, and
`Esc` there steps back out to the card rather than out of triage, so the one
detour that matters to an ESL writer isn't a mouse-only feature.
- **Answering a card advances by itself.** Accept or dismiss and the next stop
opens focused; the last one closes the card and puts the caret back in the
text, just past the span she was reading about. That is the whole acceptance
criterion — a document triaged without the mouse is five presses of Enter.
**Implemented:**
- `triage.ts` — `stepId`, `entryId`, `idAfterRemoval`. Pure, and taking the queue
as an argument, so wrap-around, caret-relative entry and "where does an
answered card hand over to" are testable without a ProseMirror document or a
layout — the same split `acceptBatch.ts` used, for the same reason.
- **The queue is the underlines, not the suggestion list.** Read off the
decoration DOM in document order. A suggestion the editor couldn't anchor has
no underline, and a triage stop she cannot see is worse than one she never
visits; reading the DOM also guarantees every stop can be anchored, which is
what the card needs to position itself.
- `SuggestionCard.tsx` — `keyboard` mode: `tabIndex={-1}`, focus on mount *and on
every step* (stepping keeps the same component mounted and swaps the suggestion
inside it), `focus({ preventScroll: true })` for AskPetal's reason, an accent
border where a pointer would otherwise be saying "this one", and the legend.
- `EditorCore.tsx` — `orderedSpans` / `openTriageAt` / `stepTriage` / `exitTriage`,
and `queueTriageAfter`, which notes the next stop *before* the action, because
the queue has to be read while the answered card is still in it.
- An Accept-all pressed from a triage card resumes after **her** card, not after
whichever member of the batch happened to be last — the queue is in document
order and a category is scattered through it.
**Two things the code had to be told, and both are about other people's keys.**
- **Escape is overloaded.** App has a window listener where Escape leaves
distraction-free mode; unhandled, one press would have closed the card *and*
restored the sidebar *and* — via item 7's rail-follows-the-mode — pulled the
rail out from under her. In triage that key means "this card", never "the
writing mode", so the card stops the event.
- **The Spanish pack's own test caught the legend.** `?` in a Spanish `Line`
must open with `¿`, and the i18n suite says so for every native half in the
pack. It is right, and it is wrong here: this `?` is a key cap, no more Spanish
punctuation than `Esc`. The exemption is one named entry with the reason
written next to it, rather than a loosened rule.
**The legend is bilingual, against the card's own convention.** Accept, Dismiss
and Ask Petal stay English because they name the thing she is learning to talk
about (item 5's reasoning, and item 8's for the Accept-all label). This isn't
that: it is an instruction for operating Petal, like the status bar, so it is
bilingual and leads with the pair language. The key *names* are what is printed
on her keyboard, so fr says `Entrée`/`Suppr`/`Échap` and es says `Intro`/`Supr` —
a legend she has to translate back to find the key is not a legend.
**Verified in a real browser at the review's own 1517×810**, on a fresh database
with no model at all (the rule pack from item 3b needs none), against the served
bundle hash checked against `web/dist` first. Nineteen assertions on a clean run,
then the acceptance criterion itself:
- *Entry.* Five underlines from one typed paragraph. `Ctrl+.` opened the first
card after the caret, focused, accent-bordered, legend showing both halves —
and **did not type a period into her sentence**.
- *Walking.* Tab through all five to the last, once more to wrap to the first,
Shift+Tab to wrap backwards. Every step landed on the card it should.
- *Answering.* Enter accepted and the next card opened focused by itself
(5 → 4 underlines, text corrected); Del dismissed and advanced (4 → 3, text
untouched); `?` opened Ask Petal with its input focused, and Escape there came
back to the card rather than out of triage.
- *The criterion.* From `Ctrl+.`, **five presses of Enter and nothing else**:
zero underlines left, `I want an apple and an orange. She has three cats. He
walk to an office.`, card closed, caret back in the prose, no horizontal
overflow. No mouse after the initial click into the document.
- *Both layouts.* Escape out of distraction-free (rail gone, sidebar back), then
`Ctrl+,` — a card opened, focused, on the last underline before the caret,
with no rail anywhere.
- *The mixed path.* Accept-all **clicked** while a keyboard card was open: whole
category applied, triage ended cleanly with focus in the text. No page errors
in any run.
**No Chrome extension this session** — it wasn't connected — so the browser was
driven over CDP against a real headless Chrome instead. That turned out to be
the better tool for this item and is worth recording: `Input.dispatchKeyEvent`
produces genuine trusted keystrokes, which is the only honest way to test a
feature that *is* keystrokes. It also sidesteps the tenth session's rAF trap —
`document.visibilityState` reads `visible`, so `recomputeRail` runs. The driver
is ~70 lines (`connect` → `key`/`click`/`typeText`/`shot`/`ev`).
**A measurement trap, and a cheap one.** The first run reported zero of
everything because the click that focused the editor was at y=300 and the empty
document's prose box ends at y=231. Nothing errored; the text simply went
nowhere. The second reported five underlines becoming three, because it reused
the *previous run's document* — where two of those spans had already been
dismissed, and item 8's own settled-spans work was correctly refusing to raise
them again. **Reset the database between browser runs**, or the feature you
shipped last session will look like the bug you're chasing this one.
**Deliberately not done:**
- No keyboard binding for Accept-all. Every other triage key answers the card in
front of her; a key that rewrites parts of the document she cannot see is a
different kind of decision, and it is one worth the deliberate reach for a
button. The path still works if she clicks it, and is tested.
- No visual cue in the text beyond the existing active-span glow (which the rail
already drives), and no "3 of 5" counter on the card. The status bar already
counts the queue, and a position indicator turns walking one's own mistakes
into a progress bar — the pressure this review's non-goals rule out.
Coverage: `triage.test.ts` (wrap-around at both ends, entry from either
direction with the caret before/on/after a span, a current card that has left the
queue, single-item and empty queues, handover after one answer and after an
Accept-all swept several, never handing back the card just answered, and a card
that was never in the queue — the provisional rule-pack case from item 3b), and
an `i18n.test.ts` case that every pack names all five keys in both halves.
**The wiring itself has no unit test**, for the reason items 6, 7 and 8 all
recorded: jsdom has no layout, every rect is zero, and a test there would pass
whatever the code did. It is browser-verified only, and is written down as such.
**Item 8 is now complete.**
--- ---
@@ -1071,6 +1210,21 @@ of it is stale until a screenshot forces a paint. Second, **the binary embeds
bundle-hash check will (rightly) fail. Untouched: item 8's keyboard flow, item 3's bundle-hash check will (rightly) fail. Untouched: item 8's keyboard flow, item 3's
incremental half.)* incremental half.)*
*(Eleventh session: item 8's keyboard triage done — see the subsection under item
8. **Item 8 is finished, and item 3's incremental surfacing is the only thing
left in the whole review.** Note this session started from a `main` that had
moved on past the tenth session's note: the settled-spans and Accept-all work is
merged and pushed, alongside three later commits that were not review items (the
zh learner direction, the es pair, the IME composition guards). Two things to
carry forward. First, **a keystroke feature has to be tested with real
keystrokes**: with the Chrome extension unconnected, CDP's
`Input.dispatchKeyEvent` against a headless Chrome turned out to be the right
tool rather than a fallback — trusted events, real layout, and
`visibilityState: visible`, so the tenth session's rAF freeze doesn't apply.
Second, **reset the database between browser runs**: a second run against the
first run's document showed two underlines missing, which was not a bug but
item 8's own dismissal persistence working exactly as it should.)*
**Migration 0015 on the live database.** It rebuilds the suggestions table, so **Migration 0015 on the live database.** It rebuilds the suggestions table, so
unlike 0014 it could have dropped her rows. Backed up first — and the backup unlike 0014 it could have dropped her rows. Backed up first — and the backup
had to be the whole WAL set (`petal.db`, `-wal`, `-shm` in had to be the whole WAL set (`petal.db`, `-wal`, `-shm` in
@@ -1085,13 +1239,31 @@ CHECK, and every existing row still carrying the label she has already read (the
seven `clarity` rows include the mislabelled Chinese one — by design, only new seven `clarity` rows include the mislabelled Chinese one — by design, only new
findings get the new type; if you want that card relabelled, edit the sentence). findings get the new type; if you want that card relabelled, edit the sentence).
**Suggested next (tenth session onward):** two things remain in the whole review. **Suggested next (eleventh session onward): item 3's incremental surfacing is all
**Keyboard triage** is the one to take: it is the last of item 8, and Accept All that is left of this review.** It is also the largest, and the only one that
just built half of what it needs — a category is now a thing the UI can act on in changes how the app *feels* rather than what it can do. What it needs hasn't
one step, so "triage without the mouse" is mostly about driving the anchored changed: a streaming `/check`, which the current response shape doesn't do. What
popover between spans. **Item 3's incremental surfacing** is the last item of any *has* changed is that the expensive prerequisite is long since built — item 2's
size, and still needs a streaming `/check`. It remains the only one left that chunking means the server already knows which sentences it is re-reading and
changes how the app *feels* rather than what it can do. already returns cached rows for the rest, so "deliver per-chunk results as each
sentence finishes" is a transport change rather than an analysis one. The status
bar's running count ("Found 3 so far…") is the cheap half and can ship with it;
`petalsToPolish` in the packs is already the line to reuse.
A caution before starting it: **the 250 ms rule pass already covers the felt
latency for the errors it knows** (item 3b), so the honest scope of what remains
is the LLM's own findings arriving one sentence at a time. Measure what she
actually waits for now before designing streaming for a wait that may be
noticeably shorter than the review's original 815 s.
*(Superseded, kept for the reading list: the tenth session's advice.)* Two things
remained. **Keyboard triage** was the one to take: the last of item 8, with
Accept All having built half of what it needed — a category was now a thing the
UI could act on in one step, so "triage without the mouse" was mostly about
driving the anchored popover between spans. That reading was right about the
surface and wrong about the effort: the popover was ready, but the item's own key
choices (bare Tab, n/p) can't be bound in a text editor, and picking the entry
chord was the design work.
*(Superseded, kept for the reading list: the ninth session's advice.)* Three *(Superseded, kept for the reading list: the ninth session's advice.)* Three
things remained. **Accept All per category** was the one with real value left — things remained. **Accept All per category** was the one with real value left —
+49
View File
@@ -589,6 +589,55 @@ CREATE INDEX idx_suggestions_resolved ON suggestions(status, resolved_at);
stmt: ` stmt: `
ALTER TABLE users ADD COLUMN direction TEXT NOT NULL DEFAULT 'learning_en' ALTER TABLE users ADD COLUMN direction TEXT NOT NULL DEFAULT 'learning_en'
CHECK(direction IN ('learning_en','learning_pair')); CHECK(direction IN ('learning_en','learning_pair'));
`,
},
{
// Which language this document is written in — 'en' or 'pair'.
//
// It is stored, rather than recomputed per pass and forgotten, for one
// reason: the verdict has hysteresis (see suggestions/doclang.go). A
// bilingual document sits between the two thresholds, and "whatever it
// was last time" is only an answer if last time was written down. Without
// the column a mixed paragraph would alternate its cards' language
// between passes.
//
// 'pair' rather than a language code, deliberately. Which language "pair"
// names is the owner's users.pair_lang, so changing her pair re-reads her
// documents instead of stranding a stale language name on every one of
// them.
//
// Empty is the backfill and means English: every document that exists
// today was written by a Mandarin native practising English, and English
// is what every surface assumed before this phase.
name: "0017_document_lang",
stmt: `
ALTER TABLE documents ADD COLUMN doc_lang TEXT NOT NULL DEFAULT ''
CHECK(doc_lang IN ('', 'en', 'pair'));
`,
},
{
// Which language a garden card is in — the same '' | 'en' | 'pair'
// vocabulary as documents.doc_lang, and set from it: a word is captured
// (or a phrase planted) out of a document, so the document's verdict is
// the card's language. A card with no document keeps '', which reads as
// English like every other empty here.
//
// The garden needed this the moment a document could be written in her
// own language. Before Phase 28 every card was English by construction;
// now a Portuguese lookup lands beside an English one with nothing to
// tell them apart, and two surfaces get it wrong without the tag — the
// review card's read-aloud (which would say a Portuguese word in a US
// English voice) and the panel, where a mixed garden is illegible.
//
// Every card is reviewed regardless. Filtering the queue to the half she
// is learning was the alternative and is wrong for the writer this is
// for: the words she met while writing Portuguese are still words she
// met, and a garden that quietly drops them is a garden that stops being
// a record of her reading.
name: "0018_vocab_lang",
stmt: `
ALTER TABLE vocab_words ADD COLUMN lang TEXT NOT NULL DEFAULT ''
CHECK(lang IN ('', 'en', 'pair'));
`, `,
}, },
} }
+27 -22
View File
@@ -34,15 +34,20 @@ type User struct {
// (source of truth for the editor); `ContentText` is the flattened plain text // (source of truth for the editor); `ContentText` is the flattened plain text
// kept in sync on every save and fed to the LLM. // kept in sync on every save and fed to the LLM.
type Document struct { type Document struct {
ID string `json:"id"` ID string `json:"id"`
UserID string `json:"user_id"` UserID string `json:"user_id"`
Title string `json:"title"` Title string `json:"title"`
Content string `json:"content"` // Tiptap JSON Content string `json:"content"` // Tiptap JSON
ContentText string `json:"content_text"` // plain text for the LLM ContentText string `json:"content_text"` // plain text for the LLM
Tone string `json:"tone"` // target writing tone; steers LLM advice Tone string `json:"tone"` // target writing tone; steers LLM advice
WordCount int `json:"word_count"` WordCount int `json:"word_count"`
CreatedAt time.Time `json:"created_at"` // DocLang is which language this document is written in — '' | 'en' | 'pair'
UpdatedAt time.Time `json:"updated_at"` // (migration 0017), written by the checkpoint pass and never by the client.
// It reaches the client read-only, for the one decision the client has to
// make on its own: which voice reads a selection aloud. '' means English.
DocLang string `json:"doc_lang"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
// PreserveHistory opts this document out of auto-snapshot pruning so its // PreserveHistory opts this document out of auto-snapshot pruning so its
// full writing trail survives as authorship evidence (see the passport). // full writing trail survives as authorship evidence (see the passport).
@@ -110,15 +115,15 @@ const (
// coordinates at render time (spec Note #6). `Replacement` is empty for `voice` // coordinates at render time (spec Note #6). `Replacement` is empty for `voice`
// flags — those are awareness-only, with no correction to apply. // flags — those are awareness-only, with no correction to apply.
type Suggestion struct { type Suggestion struct {
ID string `json:"id"` ID string `json:"id"`
DocID string `json:"doc_id"` DocID string `json:"doc_id"`
FromPos int `json:"from_pos"` FromPos int `json:"from_pos"`
ToPos int `json:"to_pos"` ToPos int `json:"to_pos"`
Original string `json:"original"` Original string `json:"original"`
Replacement string `json:"replacement"` Replacement string `json:"replacement"`
Explanation string `json:"explanation"` Explanation string `json:"explanation"`
Type string `json:"type"` // grammar | phrasing | idiom | clarity | translate | voice | collocation Type string `json:"type"` // grammar | phrasing | idiom | clarity | translate | voice | collocation
Status string `json:"status"` // pending | accepted | rejected Status string `json:"status"` // pending | accepted | rejected
// Source names the engine that proposed the edit, not its family: an offline // 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 // 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. // told which one spoke. It exists so each pass can replace its own rows.
@@ -128,10 +133,10 @@ type Suggestion struct {
// Suggestion type and status values, mirrored from the schema CHECK constraints. // Suggestion type and status values, mirrored from the schema CHECK constraints.
const ( const (
SuggestionTypeGrammar = "grammar" SuggestionTypeGrammar = "grammar"
SuggestionTypePhrasing = "phrasing" SuggestionTypePhrasing = "phrasing"
SuggestionTypeIdiom = "idiom" SuggestionTypeIdiom = "idiom"
SuggestionTypeClarity = "clarity" SuggestionTypeClarity = "clarity"
// A span she wrote in her own language, rendered into English. Not a // A span she wrote in her own language, rendered into English. Not a
// correction — nothing was wrong with it — which is why it is its own type // correction — nothing was wrong with it — which is why it is its own type
// rather than a clarity fix: the card is the pair model's flagship moment // rather than a clarity fix: the card is the pair model's flagship moment
+2 -1
View File
@@ -225,13 +225,14 @@ func (h *Handler) fetch(userID, id string) (db.Document, error) {
var doc db.Document var doc db.Document
err := h.DB.QueryRow( err := h.DB.QueryRow(
`SELECT id, user_id, title, content, content_text, tone, word_count, `SELECT id, user_id, title, content, content_text, tone, word_count,
created_at, updated_at, preserve_history created_at, updated_at, preserve_history, doc_lang
FROM documents FROM documents
WHERE id = ? AND user_id = ?`, WHERE id = ? AND user_id = ?`,
id, userID, id, userID,
).Scan( ).Scan(
&doc.ID, &doc.UserID, &doc.Title, &doc.Content, &doc.ContentText, &doc.ID, &doc.UserID, &doc.Title, &doc.Content, &doc.ContentText,
&doc.Tone, &doc.WordCount, &doc.CreatedAt, &doc.UpdatedAt, &doc.PreserveHistory, &doc.Tone, &doc.WordCount, &doc.CreatedAt, &doc.UpdatedAt, &doc.PreserveHistory,
&doc.DocLang,
) )
return doc, err return doc, err
} }
+2 -2
View File
@@ -41,9 +41,9 @@ type checkpointResponse struct {
// RunCheckpoint sends the grammar checkpoint and parses the JSON result. It // RunCheckpoint sends the grammar checkpoint and parses the JSON result. It
// applies the latency-guard truncation and the checkpoint sampling parameters // applies the latency-guard truncation and the checkpoint sampling parameters
// from the spec. // from the spec.
func RunCheckpoint(ctx context.Context, client LLMClient, contentText, tone string, _ Lang) ([]RawSuggestion, error) { func RunCheckpoint(ctx context.Context, client LLMClient, contentText, tone string, t Target) ([]RawSuggestion, error) {
raw, err := client.Complete(ctx, CompletionRequest{ raw, err := client.Complete(ctx, CompletionRequest{
Messages: CheckpointMessages(TruncateDoc(contentText), tone), Messages: CheckpointMessages(TruncateDoc(contentText), tone, t),
MaxTokens: checkpointMaxTokens, MaxTokens: checkpointMaxTokens,
Temperature: 0.3, Temperature: 0.3,
RepetitionPenalty: 1.15, RepetitionPenalty: 1.15,
+2 -2
View File
@@ -20,9 +20,9 @@ const CollocationInterval = 25 * time.Second
// The tone argument is accepted for a uniform pass signature and passed through // The tone argument is accepted for a uniform pass signature and passed through
// to the prompt so a hint can prefer a register-appropriate pairing. `lang` is // to the prompt so a hint can prefer a register-appropriate pairing. `lang` is
// the writer's pair language — the one each hint's short gloss is written in. // the writer's pair language — the one each hint's short gloss is written in.
func RunCollocation(ctx context.Context, client LLMClient, contentText, tone string, lang Lang) ([]RawSuggestion, error) { func RunCollocation(ctx context.Context, client LLMClient, contentText, tone string, t Target) ([]RawSuggestion, error) {
raw, err := client.Complete(ctx, CompletionRequest{ raw, err := client.Complete(ctx, CompletionRequest{
Messages: CollocationMessages(contentText, tone, lang), Messages: CollocationMessages(contentText, tone, t),
MaxTokens: 2048, MaxTokens: 2048,
Temperature: 0.3, Temperature: 0.3,
RepetitionPenalty: 1.15, RepetitionPenalty: 1.15,
+2 -2
View File
@@ -31,7 +31,7 @@ func TestLangForFallsBackToDefault(t *testing.T) {
func TestPromptsNameTheWritersLanguage(t *testing.T) { func TestPromptsNameTheWritersLanguage(t *testing.T) {
pt := LangFor("pt-PT") pt := LangFor("pt-PT")
collocation := CollocationMessages("The rain was strong.", "casual", pt)[0].Content collocation := CollocationMessages("The rain was strong.", "casual", EnglishTarget(pt))[0].Content
if !strings.Contains(collocation, "European Portuguese") { if !strings.Contains(collocation, "European Portuguese") {
t.Fatalf("collocation prompt doesn't ask for a pt-PT gloss:\n%s", collocation) t.Fatalf("collocation prompt doesn't ask for a pt-PT gloss:\n%s", collocation)
} }
@@ -73,7 +73,7 @@ func TestPromptsNameTheWritersLanguage(t *testing.T) {
func TestDefaultPairStillReadsAsBefore(t *testing.T) { func TestDefaultPairStillReadsAsBefore(t *testing.T) {
zh := LangFor("zh") zh := LangFor("zh")
if got := CollocationMessages("x", "", zh)[0].Content; !strings.Contains(got, "Simplified Chinese (Mandarin) gloss in parentheses") { if got := CollocationMessages("x", "", EnglishTarget(zh))[0].Content; !strings.Contains(got, "Simplified Chinese (Mandarin) gloss in parentheses") {
t.Fatalf("zh collocation gloss changed:\n%s", got) t.Fatalf("zh collocation gloss changed:\n%s", got)
} }
if got := TranslateMessages("x", zh)[0].Content; !strings.Contains(got, "natural, friendly Simplified Chinese (Mandarin)") { if got := TranslateMessages("x", zh)[0].Content; !strings.Contains(got, "natural, friendly Simplified Chinese (Mandarin)") {
+95 -8
View File
@@ -45,11 +45,55 @@ func toneGuidance(tone string) string {
"be improved, prefer suggestions that fit that tone, and gently flag wording that clashes with it." "be improved, prefer suggestions that fit that tone, and gently flag wording that clashes with it."
} }
// pairCheckpointSystemPrompt is the grammar checkpoint for a document written in
// the writer's own language rather than in English.
//
// It is a separate constant rather than a language clause appended to
// checkpointSystemPrompt, because that prompt opens by naming the reader as an
// ESL learner and asks for "common ESL patterns" — appending "and explain in
// Portuguese" would hand the model two contradictory framings. Only the framing
// differs; the JSON contract and the tone clause below it are the same
// instructions in the same order, so the two prompts stay comparable.
//
// The "never translate" line is the one the model most wants to disobey: asked
// to improve Portuguese while being an English writing assistant by training, it
// will happily hand back an English rendering, which is a translation card
// (Phase 25's `isTranslation`) and not a correction.
const pairCheckpointSystemPrompt = `You are a warm, encouraging writing assistant. The person you are helping is ` +
`writing in %[1]s, and the text below is %[1]s. ` +
`Analyze it and identify up to 5 issues: grammar errors, unnatural phrasing, ` +
`incorrect idiom usage, or unclear sentences.
Both "original" and "replacement" must be written in %[1]s. You are improving their %[1]s writing ` +
`never translate it into English, and never suggest they write in English instead.
Write every "explanation" in %[2]s.
Be specific, friendly, and explain WHY each suggestion improves the writing.%[3]s
Respond ONLY with valid JSON. No preamble, no markdown fences. Format:
{
"suggestions": [
{
"original": "exact text from the document that needs fixing",
"replacement": "corrected version",
"explanation": "friendly one-sentence explanation",
"type": "grammar|phrasing|idiom|clarity"
}
]
}
If the writing looks good, return: {"suggestions": []}`
// CheckpointMessages builds the message array for a grammar checkpoint over the // CheckpointMessages builds the message array for a grammar checkpoint over the
// given (already-truncated) document text, steered toward the document's tone. // given (already-truncated) document text, steered toward the document's tone
func CheckpointMessages(contentText, tone string) []Message { // and aimed at the language the document is actually written in.
func CheckpointMessages(contentText, tone string, t Target) []Message {
system := fmt.Sprintf(checkpointSystemPrompt, toneGuidance(tone))
if t.Flipped() {
system = fmt.Sprintf(pairCheckpointSystemPrompt, t.Correct.Name, t.Explain.Name, toneGuidance(tone))
}
return []Message{ return []Message{
{Role: "system", Content: fmt.Sprintf(checkpointSystemPrompt, toneGuidance(tone))}, {Role: "system", Content: system},
{Role: "user", Content: contentText}, {Role: "user", Content: contentText},
} }
} }
@@ -82,12 +126,48 @@ Respond ONLY with valid JSON. No preamble, no markdown fences. Format:
If the voice is consistent throughout, return: {"suggestions": []}` If the voice is consistent throughout, return: {"suggestions": []}`
// pairVoiceSystemPrompt is the voice pass for a document in the writer's own
// language. Voice consistency is the one pass that transfers across languages
// unchanged — a paragraph that reads as pasted from elsewhere reads that way in
// any language — so only the framing and the explanation language move.
const pairVoiceSystemPrompt = `You are a warm, encouraging writing assistant. The person you are helping is writing ` +
`in %[1]s. You are reviewing a COMPLETE %[1]s document for VOICE CONSISTENCY only not grammar.
Read the whole document to learn the writer's natural voice, then identify any passages (2 or more sentences) ` +
`that feel tonally inconsistent with the surrounding writing — unusually formal, unusually polished, or phrased ` +
`in a way that differs from the writer's established voice elsewhere in the document. These often signal text ` +
`that was paraphrased too closely from another source. Do not flag the first paragraph (there is no baseline yet). ` +
`Do not flag grammar or spelling mistakes only voice.
Quote each passage exactly as it appears, in %[1]s. Write every "explanation" in %[2]s.
Respond ONLY with valid JSON. No preamble, no markdown fences. Format:
{
"suggestions": [
{
"original": "exact passage from the document that feels inconsistent",
"replacement": null,
"explanation": "friendly one-sentence note about why this passage sounds unlike the rest",
"type": "voice"
}
]
}
If the voice is consistent throughout, return: {"suggestions": []}`
// VoiceMessages builds the message array for a voice-consistency pass. Unlike // VoiceMessages builds the message array for a voice-consistency pass. Unlike
// the checkpoint, the caller passes the WHOLE document (no truncation) — voice // the checkpoint, the caller passes the WHOLE document (no truncation) — voice
// consistency is judged against the established voice everywhere else. // consistency is judged against the established voice everywhere else.
func VoiceMessages(contentText string) []Message { //
// The pass had no language argument at all before Phase 28, which was the same
// English assumption the checkpoint made, just unstated.
func VoiceMessages(contentText string, t Target) []Message {
system := voiceSystemPrompt
if t.Flipped() {
system = fmt.Sprintf(pairVoiceSystemPrompt, t.Correct.Name, t.Explain.Name)
}
return []Message{ return []Message{
{Role: "system", Content: voiceSystemPrompt}, {Role: "system", Content: system},
{Role: "user", Content: contentText}, {Role: "user", Content: contentText},
} }
} }
@@ -133,10 +213,17 @@ If every pairing already sounds natural, return: {"suggestions": []}`
// CollocationMessages builds the message array for a collocation pass over the // CollocationMessages builds the message array for a collocation pass over the
// WHOLE document (no truncation), gently steered toward the document's tone so a // WHOLE document (no truncation), gently steered toward the document's tone so a
// hint can prefer a register-appropriate pairing. The parenthetical gloss is // hint can prefer a register-appropriate pairing. The parenthetical gloss is
// written in the writer's own language. // written in the writer's own language — `Pair`, not `Explain`: the gloss is
func CollocationMessages(contentText, tone string, lang Lang) []Message { // addressed to her rather than to the document.
//
// The coach itself remains English-only. Collocation lists are the one thing
// here that is genuinely per-language knowledge rather than framing, and
// "natives usually say" for Portuguese is a claim this prompt has no grounds to
// make yet; a flipped document simply gets the pass it always got. (Phase 28
// moved the checkpoint and the voice pass; this one waits for evidence.)
func CollocationMessages(contentText, tone string, t Target) []Message {
return []Message{ return []Message{
{Role: "system", Content: fmt.Sprintf(collocationSystemPrompt, toneGuidance(tone), lang.Name)}, {Role: "system", Content: fmt.Sprintf(collocationSystemPrompt, toneGuidance(tone), t.Pair.Name)},
{Role: "user", Content: contentText}, {Role: "user", Content: contentText},
} }
} }
+54
View File
@@ -0,0 +1,54 @@
package llm
// Which language a pass corrects, and which language it explains in.
//
// Until Phase 28 there was no question to answer: every prompt was written
// around English prose explained in English, and the pair language reached her
// only when she asked for it (Ask Petal, the explanation translator). That is
// the right default for a writer practising English and the wrong one for a
// document she wrote in her own language, where Petal would read Portuguese,
// say nothing about it, and file a mechanics note about the one English
// sentence at the end.
//
// The two fields are two different decisions reading two different pieces of
// state, and collapsing them would be the bug:
//
// - Correct follows the DOCUMENT. Portuguese prose gets Portuguese
// corrections; that is the whole point.
// - Explain follows the WRITER — the half of her pair she is *not* learning
// (users.direction), because an explanation is teaching, and teaching lands
// in the language she reads most easily.
//
// Today those two coincide for every account that exists: `learnerPairs` is
// {"zh"}, so fr, es and pt-PT writers are all `learning_en` and their
// non-learned half *is* the pair language. That equality is a fact about
// today's roster, not about the design — the same shape of assumption that had
// to be unpicked from `pair_lang` in migration 0016. Keep them apart.
type Target struct {
// Correct is the language the writing is in, and so the language both
// `original` and `replacement` must be written in.
Correct Lang
// Explain is the language each explanation is written in.
Explain Lang
// Pair is the writer's pair language regardless of what this document is
// written in. The collocation coach's parenthetical gloss is addressed to
// her rather than to the document, so it reads this and not Correct.
Pair Lang
}
// English as the prompts name it. Not in `langs`: that map answers "which
// language is the writer's half of the pair", and English is the constant on
// the other side of every pair Petal supports.
var English = Lang{Code: "en", Name: "English", Why: "why"}
// EnglishTarget is the pre-Phase-28 behaviour, made explicit: an English
// document, corrected and explained in English, for a writer whose pair
// language is `pair`. Every existing user is on this path and the prompt it
// produces is byte-identical to the one that shipped before this phase.
func EnglishTarget(pair Lang) Target {
return Target{Correct: English, Explain: English, Pair: pair}
}
// Flipped reports whether this document is in the pair language rather than in
// English — i.e. whether the pass is reading her own language.
func (t Target) Flipped() bool { return t.Correct.Code != English.Code }
+115
View File
@@ -0,0 +1,115 @@
package llm
import (
"strings"
"testing"
)
// The prompt every account is on today, written out in full.
//
// Phase 28 gave the checkpoint a second framing for documents in the writer's
// own language, and the risk of that change is not that the new prompt is wrong
// — it is that the old one moved by a word while nobody was looking. Every user
// who exists is a Mandarin native writing English, so this string is the one
// Petal actually sends, all day. It is duplicated here on purpose: a golden
// copied from the constant it guards guards nothing.
const goldenEnglishCheckpointPrompt = `You are a warm, encouraging writing assistant helping someone who speaks English as a second language. Analyze the text below and identify up to 5 issues: grammar errors, unnatural phrasing, incorrect idiom usage, or unclear sentences that are common ESL patterns.
Be specific, friendly, and explain WHY each suggestion improves the writing.
Respond ONLY with valid JSON. No preamble, no markdown fences. Format:
{
"suggestions": [
{
"original": "exact text from the document that needs fixing",
"replacement": "corrected version",
"explanation": "friendly one-sentence explanation",
"type": "grammar|phrasing|idiom|clarity"
}
]
}
If the writing looks good, return: {"suggestions": []}`
func TestEnglishDocumentPromptIsUnchanged(t *testing.T) {
msgs := CheckpointMessages("I has two apple.", "", EnglishTarget(LangFor("zh")))
if got := msgs[0].Content; got != goldenEnglishCheckpointPrompt {
t.Fatalf("the English checkpoint prompt moved:\n--- got ---\n%s\n--- want ---\n%s", got, goldenEnglishCheckpointPrompt)
}
if msgs[1].Content != "I has two apple." {
t.Fatalf("document text mangled: %q", msgs[1].Content)
}
// The tone clause still lands, in the same place it always did.
toned := CheckpointMessages("x", "academic", EnglishTarget(LangFor("zh")))[0].Content
if !strings.Contains(toned, "formal, academic, and objective") {
t.Fatalf("English checkpoint lost its tone guidance:\n%s", toned)
}
}
// A document in her own language gets a prompt that names that language, keeps
// the corrections inside it, and drops the framing that only makes sense when
// the thing being written is English.
func TestFlippedCheckpointPrompt(t *testing.T) {
pt := LangFor("pt-PT")
system := CheckpointMessages("Hoje foi um dia bom.", "casual", Target{Correct: pt, Explain: pt, Pair: pt})[0].Content
if !strings.Contains(system, "European Portuguese") {
t.Fatalf("flipped checkpoint doesn't name the language:\n%s", system)
}
if strings.Contains(system, "second language") || strings.Contains(system, "ESL") {
t.Fatalf("flipped checkpoint kept the ESL framing:\n%s", system)
}
if !strings.Contains(system, "never translate it into English") {
t.Fatalf("flipped checkpoint doesn't forbid translating:\n%s", system)
}
// The shared contract below the framing has to survive the split.
for _, want := range []string{`"suggestions"`, `"replacement"`, "grammar|phrasing|idiom|clarity", "relaxed, friendly, and conversational"} {
if !strings.Contains(system, want) {
t.Fatalf("flipped checkpoint dropped %q:\n%s", want, system)
}
}
if strings.Contains(system, "%!") {
t.Fatalf("flipped checkpoint has a formatting error:\n%s", system)
}
}
// The two decisions are separate arguments and must reach the prompt separately:
// corrections in the document's language, the explanation in the language she
// reads most easily. Only the zh pair can be travelled both ways today, so it is
// the only one that can prove they haven't been quietly collapsed into one.
func TestFlippedPromptsExplainInTheirOwnLanguage(t *testing.T) {
zh := LangFor("zh")
// Native Mandarin, practising English, writing Chinese: both halves Chinese.
both := CheckpointMessages("今天天气很好。", "", Target{Correct: zh, Explain: zh, Pair: zh})[0].Content
if strings.Count(both, "Simplified Chinese (Mandarin)") < 2 {
t.Fatalf("expected corrections and explanations both in Chinese:\n%s", both)
}
if strings.Contains(both, "explanation"+`" in English`) {
t.Fatalf("explanation language leaked to English:\n%s", both)
}
// Native English, learning Chinese, writing Chinese: Chinese corrections,
// English explanations.
split := CheckpointMessages("今天天气很好。", "", Target{Correct: zh, Explain: English, Pair: zh})[0].Content
if !strings.Contains(split, `Write every "explanation" in English.`) {
t.Fatalf("learner direction didn't get English explanations:\n%s", split)
}
if !strings.Contains(split, "writing in Simplified Chinese (Mandarin)") {
t.Fatalf("learner direction lost its Chinese corrections:\n%s", split)
}
// Same for the voice pass, which had no language at all before this phase.
voice := VoiceMessages("今天天气很好。", Target{Correct: zh, Explain: English, Pair: zh})[0].Content
if !strings.Contains(voice, `Write every "explanation" in English.`) || !strings.Contains(voice, "Simplified Chinese") {
t.Fatalf("flipped voice prompt got its languages wrong:\n%s", voice)
}
if strings.Contains(voice, "second language") {
t.Fatalf("flipped voice prompt kept the ESL framing:\n%s", voice)
}
// An English document still gets exactly the voice prompt it always got.
if got := VoiceMessages("x", EnglishTarget(zh))[0].Content; got != voiceSystemPrompt {
t.Fatalf("English voice prompt moved:\n%s", got)
}
}
+2 -2
View File
@@ -19,9 +19,9 @@ const VoiceInterval = 20 * time.Second
// The tone argument is accepted for a uniform pass signature but ignored: voice // The tone argument is accepted for a uniform pass signature but ignored: voice
// consistency is judged against the document's own established voice, not an // consistency is judged against the document's own established voice, not an
// externally-chosen register. // externally-chosen register.
func RunVoice(ctx context.Context, client LLMClient, contentText, _ string, _ Lang) ([]RawSuggestion, error) { func RunVoice(ctx context.Context, client LLMClient, contentText, _ string, t Target) ([]RawSuggestion, error) {
raw, err := client.Complete(ctx, CompletionRequest{ raw, err := client.Complete(ctx, CompletionRequest{
Messages: VoiceMessages(contentText), Messages: VoiceMessages(contentText, t),
MaxTokens: 2048, MaxTokens: 2048,
Temperature: 0.3, Temperature: 0.3,
RepetitionPenalty: 1.15, RepetitionPenalty: 1.15,
+179
View File
@@ -0,0 +1,179 @@
package suggestions
import "strings"
// What language is this DOCUMENT in — as opposed to this span.
//
// `readsAsPairLang` next door answers a span-level question for the purpose of
// labelling one card, and it is written to under-claim: two marker words, or
// nothing. A whole document needs the opposite temperament. A proportion, not a
// presence — one Portuguese quotation inside an English essay must not flip the
// entire pass into Portuguese, and one English sentence at the end of a
// Portuguese journal must not keep it in English.
//
// Three properties, in the order they bite:
//
// - Decided over the WHOLE document, never a chunk. The grammar checkpoint
// sends only the sentences that changed, so a verdict computed from what it
// asked about would put an English card in a Portuguese journal the moment
// she edits its one English line. The caller passes content_text, always.
//
// - Hysteresis. A bilingual paragraph sits near whatever single threshold we
// pick, and a document crossing it every few keystrokes would alternate card
// languages between passes — the same instability the mascot needed a band
// for. Flip to the pair at 70% and back only below 40%; in between, whatever
// it was last time stands. The band is the feature, not a rounding
// tolerance.
//
// - Plain code, no model call. The house rule is that the LLM is garnish,
// never a gatekeeper: a document must not become uncheckable because the
// inference box is down.
const (
docLangEnglish = "en"
docLangPair = "pair"
)
// The band. Deliberately wide: the cost of an unnecessary flip is every card in
// the document changing language, which is far more startling than a paragraph
// of mixed writing being read as whichever language it was a minute ago.
const (
flipToPairAt = 0.70
flipToEnglishBelow = 0.40
)
// documentLang returns the language verdict for a document, given the verdict it
// carried before. `prev` is "" for a document that has never been read.
//
// The result is one of docLangEnglish / docLangPair — not a language code. Which
// language "pair" means is the writer's `pair_lang`, and keeping the stored
// verdict relative to her pair means changing her pair doesn't strand a stale
// language name on every document she owns.
func documentLang(contentText, pairLang, prev string) string {
if prev != docLangPair {
prev = docLangEnglish
}
p := normalizePairLang(pairLang)
if !hasLangTest(p) {
// No test for this pair: say English, which is what every surface did
// before this phase. A wrong flip is louder than a missing one.
return docLangEnglish
}
var pair, english int
for _, c := range splitChunks(contentText, "") {
switch sentenceLang(c.text, p) {
case docLangPair:
pair++
case docLangEnglish:
english++
}
}
decided := pair + english
if decided == 0 {
// Nothing to go on — an empty document, a list of numbers, a title. Hold
// the previous verdict rather than resetting a Portuguese journal to
// English because she cleared it to start again.
return prev
}
ratio := float64(pair) / float64(decided)
switch {
case ratio >= flipToPairAt && corroborated(contentText, p):
return docLangPair
case ratio < flipToEnglishBelow:
return docLangEnglish
default:
return prev
}
}
// sentenceLang classifies one sentence as pair-language, English, or neither.
//
// Neither is a real answer and carries weight: a sentence with no evidence
// either way ("Bom dia.", "OK.", a heading) is left out of the ratio entirely
// rather than counted for the language it isn't. Counting the undecided as
// English is what would keep a Portuguese document in English forever, since
// short sentences carry no markers.
func sentenceLang(s, p string) string {
if p == "zh" {
han, latin := scriptCounts(s)
switch {
case han >= 2 && han > latin:
return docLangPair
case latin >= 3 && latin > han:
return docLangEnglish
}
return ""
}
// A Latin pair shares its alphabet with English, so both sides are counted
// the same way and the larger pile of evidence wins. A tie — including no
// evidence at all — is no answer, which is why the English list below is
// curated as carefully against the pair languages as theirs is against
// English.
pair := distinctMarkers(s, latinMarkers[p])
eng := distinctMarkers(s, englishMarkers)
switch {
case pair > eng:
return docLangPair
case eng > pair:
return docLangEnglish
}
return ""
}
// corroborated requires the document as a whole to carry real evidence of the
// pair language before the pass flips into it. A two-sentence document of
// "Sim." / "Não." would otherwise reach 100% on almost nothing; a flip changes
// every card in the document, so it has to be earned document-wide and not only
// in proportion.
func corroborated(contentText, p string) bool {
if p == "zh" {
han, _ := scriptCounts(contentText)
return han >= 8
}
return distinctMarkers(contentText, latinMarkers[p]) >= 3
}
// hasLangTest reports whether readsAsPairLang / sentenceLang know how to test
// this pair at all. Kept beside the tests it describes so a new pair that adds
// markers without adding itself here fails loudly in review rather than quietly
// at runtime.
func hasLangTest(p string) bool {
if p == "zh" {
return true
}
return len(latinMarkers[p]) > 0
}
// English function words, curated against the pair languages exactly as
// `latinMarkers` is curated against English.
//
// Every word here is one a Portuguese, French or Spanish sentence has no reason
// to contain. Deliberately absent, each a false English vote waiting to happen
// in someone's own language: "on" and "son" (French), "as", "a", "o", "e", "no",
// "os" (Portuguese), "no", "para", "sin" (Spanish), and "is"-alikes that are
// really other languages' words. The list is short on purpose — it does not need
// coverage, only a reliable vote in the sentences where the pair list is silent.
var englishMarkers = words(
"the", "and", "is", "are", "was", "were", "be", "been", "being",
"of", "to", "that", "this", "these", "those", "with", "from", "for",
"have", "has", "had", "they", "them", "their", "there", "then", "than",
"what", "which", "when", "where", "why", "how", "who",
"will", "would", "should", "could", "can", "about", "because",
"into", "some", "such", "only", "very", "much", "many", "other",
"our", "your", "its", "it", "he", "she", "we", "you", "but", "not",
"just", "like", "also", "most", "over", "after", "before", "between",
"through", "said", "says", "get", "got", "make", "made", "know", "think",
"thing", "things", "time", "people", "here", "always", "never", "something",
"want", "need", "feel", "day", "today", "good", "really", "still", "even",
)
// normalizeDocLang folds a stored verdict into the two values the rest of the
// code reasons about: the column holds "" for a document nothing has read yet,
// and that means English, which is what every surface did before this phase.
func normalizeDocLang(v string) string {
if strings.TrimSpace(v) == docLangPair {
return docLangPair
}
return docLangEnglish
}
+500
View File
@@ -0,0 +1,500 @@
package suggestions
import (
"encoding/json"
"net/http"
"path/filepath"
"strings"
"testing"
"github.com/go-chi/chi/v5"
"gitea.parodia.dev/drwily/petal/internal/auth"
"gitea.parodia.dev/drwily/petal/internal/db"
"gitea.parodia.dev/drwily/petal/internal/llm"
)
// A monolingual document in either language has to be read as that language, and
// the mixed cases in between are where the whole design lives: one quotation
// must not move a document, and one leftover English line must not hold a
// journal in English.
func TestDocumentLangReadsWholeDocuments(t *testing.T) {
const ptJournal = "Hoje foi um dia muito bom. Eu gosto de escrever aqui todas as noites. " +
"A minha irmã também quer aprender. Não sei porque isso é tão difícil para mim."
const enEssay = "The weather was very cold this morning. I think that the bus was late again. " +
"She told me about the meeting, but I could not hear what they said."
const zhJournal = "今天天气很好。我和妹妹一起去公园散步。我们看到很多花。"
tests := []struct {
name string
text string
pairLang string
prev string
want string
}{
{"portuguese journal", ptJournal, "pt-PT", "", docLangPair},
{"english essay", enEssay, "pt-PT", "", docLangEnglish},
{"chinese journal", zhJournal, "zh", "", docLangPair},
{"english essay, zh writer", enEssay, "zh", "", docLangEnglish},
// One English sentence at the end of a Portuguese journal is the case that
// motivated the whole phase: the pass must stay in Portuguese.
{
"portuguese with one english line",
ptJournal + " I will write more tomorrow.",
"pt-PT", "", docLangPair,
},
// And the mirror: an English essay quoting a line of Portuguese is still an
// English essay.
{
"english quoting portuguese",
enEssay + " She wrote: \"Eu não sei o que dizer.\"",
"pt-PT", "", docLangEnglish,
},
// A pair Petal has no test for cannot flip anything. Saying English is what
// every surface did before this phase.
{"untested pair", ptJournal, "de", "", docLangEnglish},
// Nothing to go on holds the previous answer rather than resetting a
// journal because she cleared it to start again.
{"emptied portuguese journal", "", "pt-PT", docLangPair, docLangPair},
{"emptied english essay", " \n ", "pt-PT", docLangEnglish, docLangEnglish},
// Proportion, not presence: a couple of Portuguese words are not a
// Portuguese document even though readsAsPairLang would label that span.
{
"english with a portuguese phrase",
enEssay + " The sign said pão com manteiga.",
"pt-PT", "", docLangEnglish,
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
if got := documentLang(tc.text, tc.pairLang, tc.prev); got != tc.want {
t.Fatalf("documentLang = %q, want %q", got, tc.want)
}
})
}
}
// The band, from both directions. A document sitting inside it keeps whatever it
// was, and that is the point: without it, a bilingual paragraph would alternate
// its cards' language every few keystrokes as she typed across the threshold.
func TestDocumentLangHysteresis(t *testing.T) {
// Half and half: two Portuguese sentences, two English ones. Inside the band
// from either side.
const mixed = "Eu gosto muito de escrever aqui. A minha irmã não sabe porque é difícil. " +
"The weather was very cold this morning. I think that they said the same thing."
if got := documentLang(mixed, "pt-PT", docLangEnglish); got != docLangEnglish {
t.Fatalf("mixed document from english = %q, want it to stay %q", got, docLangEnglish)
}
if got := documentLang(mixed, "pt-PT", docLangPair); got != docLangPair {
t.Fatalf("mixed document from pair = %q, want it to stay %q", got, docLangPair)
}
// Above the upper threshold it flips regardless of where it came from; below
// the lower one it flips back regardless.
const mostlyPT = "Eu gosto muito de escrever aqui. A minha irmã não sabe porque é difícil. " +
"Hoje foi um dia bom para mim. Amanhã também quero escrever mais uma coisa. " +
"I think so too."
if got := documentLang(mostlyPT, "pt-PT", docLangEnglish); got != docLangPair {
t.Fatalf("mostly-portuguese from english = %q, want %q", got, docLangPair)
}
const mostlyEN = "The weather was very cold this morning. I think that they said the same thing. " +
"She could not hear what the other people were saying about it. Eu não sei."
if got := documentLang(mostlyEN, "pt-PT", docLangPair); got != docLangEnglish {
t.Fatalf("mostly-english from pair = %q, want %q", got, docLangEnglish)
}
}
// Corroboration: a proportion computed over almost nothing is not evidence. Two
// bare words at 100% must not flip a document, because a flip rewrites every
// card in it.
func TestDocumentLangNeedsCorroboration(t *testing.T) {
if got := documentLang("Não. Eu.", "pt-PT", docLangEnglish); got != docLangEnglish {
t.Fatalf("two bare words flipped the document: %q", got)
}
if got := documentLang("我。", "zh", docLangEnglish); got != docLangEnglish {
t.Fatalf("two Han runes flipped the document: %q", got)
}
}
// The two language decisions are genuinely independent, and only the zh pair can
// prove it today — it is the one pair that can be travelled in both directions.
//
// A Mandarin native practising English who writes Chinese wants Chinese
// corrections explained in Chinese. An English native learning Chinese who writes
// Chinese wants the same Chinese corrections explained in English. Same document,
// same Correct, different Explain.
func TestTargetSeparatesCorrectedFromExplained(t *testing.T) {
learningEn := targetFor("zh", auth.DirectionLearningEn, docLangPair)
if learningEn.Correct.Code != "zh" || learningEn.Explain.Code != "zh" {
t.Fatalf("learning_en on a Chinese document: correct=%s explain=%s", learningEn.Correct.Code, learningEn.Explain.Code)
}
learningPair := targetFor("zh", auth.DirectionLearningPair, docLangPair)
if learningPair.Correct.Code != "zh" {
t.Fatalf("learner direction changed what gets corrected: %s", learningPair.Correct.Code)
}
if learningPair.Explain.Code != "en" {
t.Fatalf("learner direction explained in %s, want English", learningPair.Explain.Code)
}
// An English document is the path every account is on today, in either
// direction: English corrections, English explanations, her language still on
// the Ask Petal and translate taps.
for _, dir := range []string{auth.DirectionLearningEn, auth.DirectionLearningPair} {
got := targetFor("zh", dir, docLangEnglish)
if got.Flipped() || got.Explain.Code != "en" {
t.Fatalf("english document with direction %s: %+v", dir, got)
}
if got.Pair.Code != "zh" {
t.Fatalf("english document lost the writer's pair: %+v", got)
}
}
}
// newDirectedServer seeds one writer on a given pair and direction, with a
// document of her own. Like newPairServer, but the direction is the variable.
func newDirectedServer(t *testing.T, client llm.LLMClient, pairLang, direction, text string) (http.Handler, string, *Handler) {
t.Helper()
database, err := db.Open(filepath.Join(t.TempDir(), "doclang.db"))
if err != nil {
t.Fatalf("open db: %v", err)
}
t.Cleanup(func() { database.Close() })
const userID = "writer-directed"
if _, err := database.Exec(
`INSERT INTO users (id, email, display_name, pair_lang, direction) VALUES (?, ?, ?, ?, ?)`,
userID, "d@example.com", "Writer", pairLang, direction,
); err != nil {
t.Fatalf("seed user: %v", err)
}
var docID string
if err := database.QueryRow(
`INSERT INTO documents (user_id, content_text) VALUES (?, ?) RETURNING id`,
userID, text,
).Scan(&docID); err != nil {
t.Fatalf("seed doc: %v", err)
}
h := New(database, client)
h.Limit = llm.NewRateLimiter(0)
h.VoiceLimit = llm.NewRateLimiter(0)
r := chi.NewRouter()
r.Route("/docs", func(dr chi.Router) { h.RegisterDocRoutes(dr) })
r.Mount("/suggestions", h.Routes())
return auth.Middleware(auth.StaticResolver(userID))(r), docID, h
}
const ptDocument = "Hoje foi um dia muito bom. Eu gosto de escrever aqui todas as noites. " +
"A minha irmã também quer aprender comigo. Não sei porque isso é tão difícil para mim."
// End to end: a Portuguese document reaches the model as a Portuguese
// checkpoint. This is the observed bug from 2026-07-28 — two pt-PT sentences
// drew no cards at all, because Petal was reading them as bad English.
func TestCheckpointFollowsTheDocumentLanguage(t *testing.T) {
client := &stubClient{response: `{"suggestions":[]}`}
srv, docID, _ := newDirectedServer(t, client, "pt-PT", auth.DirectionLearningEn, ptDocument)
if rec := do(t, srv, http.MethodPost, "/docs/"+docID+"/check", ""); rec.Code != http.StatusOK {
t.Fatalf("check: code=%d body=%s", rec.Code, rec.Body)
}
if !strings.Contains(client.lastPrompt, "European Portuguese") {
t.Fatalf("checkpoint didn't follow the document into Portuguese:\n%s", client.lastPrompt)
}
if strings.Contains(client.lastPrompt, "second language") {
t.Fatalf("checkpoint kept the ESL framing on a Portuguese document:\n%s", client.lastPrompt)
}
// And the voice pass, which had no language argument at all before this phase.
if rec := do(t, srv, http.MethodPost, "/docs/"+docID+"/voice", ""); rec.Code != http.StatusOK {
t.Fatalf("voice: code=%d body=%s", rec.Code, rec.Body)
}
if !strings.Contains(client.lastPrompt, "European Portuguese") {
t.Fatalf("voice pass didn't follow the document:\n%s", client.lastPrompt)
}
}
// The verdict is persisted, because hysteresis needs a yesterday.
func TestDocumentLangIsRemembered(t *testing.T) {
client := &stubClient{response: `{"suggestions":[]}`}
srv, docID, h := newDirectedServer(t, client, "pt-PT", auth.DirectionLearningEn, ptDocument)
if rec := do(t, srv, http.MethodPost, "/docs/"+docID+"/check", ""); rec.Code != http.StatusOK {
t.Fatalf("check: code=%d body=%s", rec.Code, rec.Body)
}
var stored string
if err := h.DB.QueryRow(`SELECT doc_lang FROM documents WHERE id = ?`, docID).Scan(&stored); err != nil {
t.Fatalf("read doc_lang: %v", err)
}
if stored != docLangPair {
t.Fatalf("doc_lang = %q, want %q", stored, docLangPair)
}
}
// A document that changes language re-opens every sentence. Without the verdict
// in the chunk salt, the sentences she didn't touch would keep serving cards
// written in the language the document no longer speaks.
func TestLanguageFlipReopensCheckedSentences(t *testing.T) {
client := &stubClient{response: `{"suggestions":[]}`}
const enStart = "The weather was very cold this morning."
srv, docID, h := newDirectedServer(t, client, "pt-PT", auth.DirectionLearningEn, enStart)
if rec := do(t, srv, http.MethodPost, "/docs/"+docID+"/check", ""); rec.Code != http.StatusOK {
t.Fatalf("first check: code=%d body=%s", rec.Code, rec.Body)
}
first := client.calls
// She rewrites the document in Portuguese, keeping the first sentence.
setDocText(t, h, docID, enStart+" "+ptDocument)
if rec := do(t, srv, http.MethodPost, "/docs/"+docID+"/check", ""); rec.Code != http.StatusOK {
t.Fatalf("second check: code=%d body=%s", rec.Code, rec.Body)
}
if client.calls == first {
t.Fatal("the flipped document was never sent to the model")
}
if !strings.Contains(client.lastPrompt, "The weather was very cold") {
t.Fatalf("the already-checked sentence was not re-opened by the flip:\n%s", client.lastPrompt)
}
}
// The translate card, pointed the other way. She is writing her journal in
// Portuguese and drops in the one English sentence she knows; Petal renders it
// into Portuguese, and that card is a translation — not a correction to prose
// that was never wrong.
func TestEnglishSpanBecomesATranslateCardInAPortugueseDocument(t *testing.T) {
const english = "I want to say this but I don't know how to say it."
// The model volunteers "clarity", as it did for the zh case. Not consulted.
client := &stubClient{response: `{"suggestions":[
{"original":"` + english + `","replacement":"Eu quero dizer isto mas não sei como o dizer.","explanation":"Aqui está em português.","type":"clarity"}
]}`}
srv, docID, _ := newDirectedServer(t, client, "pt-PT", auth.DirectionLearningEn, ptDocument+" "+english)
var out []db.Suggestion
rec := do(t, srv, http.MethodPost, "/docs/"+docID+"/check", "")
if rec.Code != http.StatusOK {
t.Fatalf("check: code=%d body=%s", rec.Code, rec.Body)
}
if err := json.Unmarshal(rec.Body.Bytes(), &out); err != nil {
t.Fatalf("decode: %v", err)
}
if len(out) != 1 {
t.Fatalf("want 1 card, got %d: %+v", len(out), out)
}
if out[0].Type != db.SuggestionTypeTranslate {
t.Fatalf("card type = %q, want %q", out[0].Type, db.SuggestionTypeTranslate)
}
}
// And the half that keeps it honest: a genuine Portuguese correction in the same
// document stays a correction. Reading the English-document test backwards would
// have called this a translation, because every Portuguese sentence also "reads
// as English" by that test's deliberately low bar.
func TestPortugueseCorrectionKeepsItsTypeInAPortugueseDocument(t *testing.T) {
client := &stubClient{response: `{"suggestions":[
{"original":"Não sei porque isso é tão difícil para mim.","replacement":"Não sei porque isto é tão difícil para mim.","explanation":"Aqui usa-se isto.","type":"grammar"}
]}`}
srv, docID, _ := newDirectedServer(t, client, "pt-PT", auth.DirectionLearningEn, ptDocument)
var out []db.Suggestion
rec := do(t, srv, http.MethodPost, "/docs/"+docID+"/check", "")
if rec.Code != http.StatusOK {
t.Fatalf("check: code=%d body=%s", rec.Code, rec.Body)
}
if err := json.Unmarshal(rec.Body.Bytes(), &out); err != nil {
t.Fatalf("decode: %v", err)
}
if len(out) != 1 {
t.Fatalf("want 1 card, got %d: %+v", len(out), out)
}
if out[0].Type == db.SuggestionTypeTranslate {
t.Fatal("a Portuguese correction inside a Portuguese document was labelled a translation")
}
}
// setDocLang writes a document's language verdict directly, so a test of the
// tap-through doesn't have to run a checkpoint through the same stub client to
// get one.
func setDocLang(t *testing.T, h *Handler, docID, lang string) {
t.Helper()
if _, err := h.DB.Exec(`UPDATE documents SET doc_lang = ? WHERE id = ?`, lang, docID); err != nil {
t.Fatalf("set doc_lang: %v", err)
}
}
// seedExplanation files one card carrying a given explanation and returns its
// id — the shape the translate tap-through needs, where only the explanation and
// the document it hangs off matter.
func seedExplanation(t *testing.T, h *Handler, docID, explanation string) string {
t.Helper()
var sugID string
if err := h.DB.QueryRow(
`INSERT INTO suggestions (doc_id, original, replacement, explanation, type, from_pos, to_pos)
VALUES (?, ?, ?, ?, ?, 0, 5) RETURNING id`,
docID, "isso", "isto", explanation, "grammar",
).Scan(&sugID); err != nil {
t.Fatalf("seed suggestion: %v", err)
}
return sugID
}
// The tap-through has to read the same decision the card was written under. On a
// Portuguese document by a Portuguese writer the explanation already arrived in
// Portuguese, and the old endpoint would have sent it to the model to be
// rendered into Portuguese again.
func TestTranslateSkipsWhenTheExplanationIsAlreadyHers(t *testing.T) {
client := &stubClient{response: "Não devia ser chamado."}
srv, docID, h := newDirectedServer(t, client, "pt-PT", auth.DirectionLearningEn, ptDocument)
setDocLang(t, h, docID, docLangPair)
sugID := seedExplanation(t, h, docID, "Aqui usa-se isto.")
rec := do(t, srv, http.MethodPost, "/suggestions/"+sugID+"/translate", "")
if rec.Code != http.StatusOK {
t.Fatalf("translate: code=%d body=%s", rec.Code, rec.Body)
}
var out translateResponse
if err := json.Unmarshal(rec.Body.Bytes(), &out); err != nil {
t.Fatalf("decode: %v", err)
}
if out.Translation != "" {
t.Fatalf("translation = %q, want empty: the bubble seeds from the explanation itself", out.Translation)
}
if client.calls != 0 {
t.Fatal("the model was asked to render Portuguese into Portuguese")
}
}
// The learner travelling the other way is the case that proves the endpoint
// derives its destination rather than skipping whenever a document is flipped: a
// native English speaker learning Chinese, writing Chinese, gets her
// explanations in English — and the tap still has somewhere to go.
func TestTranslateStillRendersForALearnersEnglishExplanation(t *testing.T) {
const zhDocument = "今天天气很好。我早上去公园散步。下午我在家里写作业。晚上我和朋友一起吃饭。"
client := &stubClient{response: "这里应该用这个。"}
srv, docID, h := newDirectedServer(t, client, "zh", auth.DirectionLearningPair, zhDocument)
setDocLang(t, h, docID, docLangPair)
sugID := seedExplanation(t, h, docID, "This measure word doesn't fit here.")
rec := do(t, srv, http.MethodPost, "/suggestions/"+sugID+"/translate", "")
if rec.Code != http.StatusOK {
t.Fatalf("translate: code=%d body=%s", rec.Code, rec.Body)
}
var out translateResponse
if err := json.Unmarshal(rec.Body.Bytes(), &out); err != nil {
t.Fatalf("decode: %v", err)
}
if out.Translation == "" {
t.Fatal("a learner's English explanation was left untranslated")
}
if !strings.Contains(client.lastPrompt, "Simplified Chinese") {
t.Fatalf("translate didn't render into the pair language:\n%s", client.lastPrompt)
}
}
// TestPassAnnouncesItsVerdict pins the header the client reads. Storing the
// verdict on the document row is not enough on its own: the editor sees that row
// only when the document is opened or saved, and the pass that decides the
// verdict runs *after* a save — so the client would always be one save behind,
// and read-aloud is reached for exactly when she has stopped typing and no
// further save is coming. Caught in a browser: a Portuguese paragraph read in an
// American voice, twice, until another keystroke went in.
//
// Asserted on both endpoints that can flip it, and on the empty-document early
// return, which answers without ever reaching the model.
func TestPassAnnouncesItsVerdict(t *testing.T) {
client := &stubClient{response: `{"suggestions":[]}`}
srv, docID, h := newDirectedServer(t, client, "pt-PT", auth.DirectionLearningEn, ptDocument)
for _, path := range []string{"/check", "/voice"} {
rec := do(t, srv, http.MethodPost, "/docs/"+docID+path, "")
if rec.Code != http.StatusOK {
t.Fatalf("%s: code=%d body=%s", path, rec.Code, rec.Body)
}
if got := rec.Header().Get("X-Petal-Doc-Lang"); got != docLangPair {
t.Fatalf("%s: X-Petal-Doc-Lang = %q, want %q", path, got, docLangPair)
}
}
// An English document says so rather than saying nothing — the client has to
// be able to hear a flip back, not just a flip away.
if _, err := h.DB.Exec(
`UPDATE documents SET content_text = ?, doc_lang = '' WHERE id = ?`,
"The weather was very cold this morning. I walked to the shop and bought some bread.", docID,
); err != nil {
t.Fatalf("rewrite doc: %v", err)
}
rec := do(t, srv, http.MethodPost, "/docs/"+docID+"/check", "")
if rec.Code != http.StatusOK {
t.Fatalf("check: code=%d body=%s", rec.Code, rec.Body)
}
if got := rec.Header().Get("X-Petal-Doc-Lang"); got != docLangEnglish {
t.Fatalf("English document: X-Petal-Doc-Lang = %q, want %q", got, docLangEnglish)
}
// The empty-document path returns before the model call, and still answers.
if _, err := h.DB.Exec(`UPDATE documents SET content_text = '' WHERE id = ?`, docID); err != nil {
t.Fatalf("empty doc: %v", err)
}
rec = do(t, srv, http.MethodPost, "/docs/"+docID+"/check", "")
if rec.Code != http.StatusOK {
t.Fatalf("empty check: code=%d body=%s", rec.Code, rec.Body)
}
if got := rec.Header().Get("X-Petal-Doc-Lang"); got == "" {
t.Fatal("empty document answered with no verdict header at all")
}
}
// TestOrdinaryProseIsEnoughEvidence is the regression for what the marker lists
// were caught doing on 2026-07-29, live, in a browser: unremarkable Portuguese
// read as English, because the list was curated against English so tightly that
// it had also been curated against ordinary writing. The document below scored
// two pair markers and zero English ones, and two is below the corroboration
// floor — so a paragraph with no evidence of English in it at all came back
// English, and was corrected and read aloud as English.
//
// Every sample here is prose a person might actually write, not prose chosen to
// contain markers. That is the whole point of the test: the failure was invisible
// to a suite whose fixtures all argued their own case.
func TestOrdinaryProseIsEnoughEvidence(t *testing.T) {
samples := []struct{ name, text string }{
{"the one seen live", "Esta manhã acordei cedo e fui correr ao longo da marginal. O ar estava fresco e havia poucas pessoas na rua. Depois comprei um jornal e li-o sentado num banco ao sol."},
{"an afternoon out", "Hoje o céu estava limpo e fomos até ao jardim junto ao rio. A minha mãe trouxe uma manta velha e sentámos-nos debaixo de uma árvore."},
{"plans", "Amanhã vamos ao cinema depois do trabalho. Ontem estava demasiado cansada para sair de casa."},
}
for _, s := range samples {
if got := documentLang(s.text, "pt-PT", ""); got != docLangPair {
t.Errorf("%s: documentLang = %q, want %q — ordinary Portuguese must not read as English\n%s",
s.name, got, docLangPair, s.text)
}
}
}
// TestEnglishDidNotGetEasierToMistake is the other half, and the reason the
// additions were held to "a word an English sentence has no reason to contain".
// Widening a marker list is only safe if it widens in one direction: these are
// English documents, including ones about Portugal and ones quoting Portuguese,
// and every one of them must still come back English.
func TestEnglishDidNotGetEasierToMistake(t *testing.T) {
samples := []struct{ name, text string }{
{"plain English", "This morning I woke up early and went for a run along the seafront. The air was fresh and there were few people about. Afterwards I bought a newspaper and read it on a bench."},
{"English about Portugal", "We spent a week in Lisbon last summer. The trams were crowded but the food was wonderful, and we walked up to the castle every evening."},
{"English quoting her", "My mother always says \"até amanhã\" when she leaves, never goodbye. I asked her why once and she said it sounded less final to her."},
{"an English diary", "Today was long. I had two meetings before lunch and another one after, and by the time I got home I could not think straight. Tomorrow should be quieter."},
}
for _, s := range samples {
if got := documentLang(s.text, "pt-PT", ""); got != docLangEnglish {
t.Errorf("%s: documentLang = %q, want %q — the widened list must not pull English across\n%s",
s.name, got, docLangEnglish, s.text)
}
}
// And the same document must not flip once it is already sitting in English:
// the hysteresis band is only a safety net if the low side holds too.
for _, s := range samples {
if got := documentLang(s.text, "pt-PT", docLangEnglish); got != docLangEnglish {
t.Errorf("%s: held verdict flipped to %q", s.name, got)
}
}
}
+98 -21
View File
@@ -247,12 +247,45 @@ func (h *Handler) collocation(w http.ResponseWriter, r *http.Request) {
} }
// pass is the signature shared by the grammar checkpoint and the voice pass: // pass is the signature shared by the grammar checkpoint and the voice pass:
// given the document text, the document's tone and the writer's pair language it // given the document text, the document's tone and the languages this document
// returns the model's raw suggestions. The voice pass ignores both extras (see // is to be corrected and explained in, it returns the model's raw suggestions.
// llm.RunVoice) and the checkpoint ignores the language — only the collocation // The voice pass ignores the tone (see llm.RunVoice) and the collocation coach
// coach writes a word of it — but one signature keeps runPass free of special // reads only the writer's pair language, but one signature keeps runPass free of
// cases. // special cases.
type pass func(ctx context.Context, client llm.LLMClient, contentText, tone string, lang llm.Lang) ([]llm.RawSuggestion, error) type pass func(ctx context.Context, client llm.LLMClient, contentText, tone string, t llm.Target) ([]llm.RawSuggestion, error)
// targetFor resolves the two language decisions for one pass over one document.
//
// They read different state on purpose. What gets *corrected* follows the
// document, because Portuguese prose wants Portuguese corrections. What language
// the correction is *explained* in follows the writer — the half of her pair she
// is not learning — because an explanation is teaching, and teaching lands in the
// language she reads most easily. A native Portuguese speaker practising English
// gets Portuguese explained in Portuguese; a native English speaker learning
// French gets French explained in English. Neither is trapped: the other language
// stays one tap away, in both directions.
//
// An English document keeps the pre-Phase-28 behaviour exactly — explained in
// English, with her language on the Ask Petal / translate taps — which is the
// path every account today is on.
//
// The direction lookup costs nothing today: `learnerPairs` is {"zh"}, so fr, es
// and pt-PT accounts are all learning_en and their non-learned half *is* the pair
// language. This rule therefore produces "explain in the document's language" for
// every writer who currently exists. It is written out anyway to stop the
// coincidence being baked into the prompts, the way "English is the language
// being learned" was baked into pair_lang before migration 0016.
func targetFor(pairLang, direction, docLang string) llm.Target {
pair := llm.LangFor(pairLang)
if normalizeDocLang(docLang) != docLangPair {
return llm.EnglishTarget(pair)
}
explain := pair
if direction == auth.DirectionLearningPair {
explain = llm.English
}
return llm.Target{Correct: pair, Explain: explain, Pair: pair}
}
// runPass is the shared body for both LLM passes. It loads the document text, // runPass is the shared body for both LLM passes. It loads the document text,
// enforces the pass's per-document rate limit, runs the model, swaps in the // enforces the pass's per-document rate limit, runs the model, swaps in the
@@ -262,17 +295,19 @@ func (h *Handler) runPass(w http.ResponseWriter, r *http.Request, limiter *llm.R
docID := chi.URLParam(r, "id") docID := chi.URLParam(r, "id")
userID := auth.UserID(r.Context()) userID := auth.UserID(r.Context())
// The writer's pair language rides along with the document rather than in a // The writer's pair language and direction ride along with the document
// second query: it is read from the same row-scoped lookup that already // rather than in a second query: they are read from the same row-scoped
// proves she owns this document. // lookup that already proves she owns this document. `doc_lang` is the
var contentText, tone, pairLang string // previous language verdict, which the new one needs (hysteresis).
var contentText, tone, pairLang, direction, prevLang string
err := h.DB.QueryRow( err := h.DB.QueryRow(
`SELECT d.content_text, d.tone, COALESCE(u.pair_lang, '') `SELECT d.content_text, d.tone, d.doc_lang,
COALESCE(u.pair_lang, ''), COALESCE(u.direction, '')
FROM documents d FROM documents d
JOIN users u ON u.id = d.user_id JOIN users u ON u.id = d.user_id
WHERE d.id = ? AND d.user_id = ?`, WHERE d.id = ? AND d.user_id = ?`,
docID, userID, docID, userID,
).Scan(&contentText, &tone, &pairLang) ).Scan(&contentText, &tone, &prevLang, &pairLang, &direction)
if errors.Is(err, sql.ErrNoRows) { if errors.Is(err, sql.ErrNoRows) {
httputil.ErrorJSON(w, http.StatusNotFound, "document not found") httputil.ErrorJSON(w, http.StatusNotFound, "document not found")
return return
@@ -282,10 +317,35 @@ func (h *Handler) runPass(w http.ResponseWriter, r *http.Request, limiter *llm.R
return return
} }
// What language is this document in, and so what language should its cards be
// written in? Computed from the whole content_text — never from `askText`,
// which on a chunked pass is only the sentences that changed, and would put an
// English card in a Portuguese journal the moment she edits its one English
// line.
//
// Decided before the empty-document exit so every reconcile below is told the
// same verdict. An emptied document has nothing to go on and holds whatever it
// said last (see documentLang), which is what keeps a Portuguese journal
// Portuguese while she clears it to start the entry again.
docLang := documentLang(contentText, pairLang, prevLang)
// Announce the verdict on every answer this pass gives, including the early
// ones below. This pass is the only thing that decides the value, so it is
// the only moment the client can learn it promptly — and the client needs it
// promptly for read-aloud, which is reached for exactly when she has stopped
// typing and no further save is coming. Carrying it back on the document row
// alone means the editor is always one save behind the truth, and a paragraph
// of Portuguese read in an American voice is how that sounds.
//
// A header rather than a wider body: /check and /voice answer with a bare
// array of the unified pending set, and every caller of both endpoints reads
// it as one. A verdict is metadata about the pass, not another suggestion.
w.Header().Set("X-Petal-Doc-Lang", docLang)
// Nothing to analyze on an empty document — skip the LLM round-trip. The // Nothing to analyze on an empty document — skip the LLM round-trip. The
// family's rows go with the text they were about. // family's rows go with the text they were about.
if strings.TrimSpace(contentText) == "" { if strings.TrimSpace(contentText) == "" {
if err := h.reconcilePending(docID, contentText, pairLang, nil, scope, nil, nil, false); err != nil { if err := h.reconcilePending(docID, contentText, pairLang, docLang, nil, scope, nil, nil, false); err != nil {
httputil.ServerError(w, err) httputil.ServerError(w, err)
return return
} }
@@ -298,16 +358,30 @@ func (h *Handler) runPass(w http.ResponseWriter, r *http.Request, limiter *llm.R
return return
} }
if docLang != normalizeDocLang(prevLang) {
if _, err := h.DB.Exec(
`UPDATE documents SET doc_lang = ? WHERE id = ? AND user_id = ?`,
docLang, docID, userID,
); err != nil {
httputil.ServerError(w, err)
return
}
}
target := targetFor(pairLang, direction, docLang)
// Decide what to ask about before spending anything: a chunked pass asks only // Decide what to ask about before spending anything: a chunked pass asks only
// about the sentences that changed since it last read the document, and when // about the sentences that changed since it last read the document, and when
// none did it doesn't call the model at all — nor consume its rate-limit slot, // none did it doesn't call the model at all — nor consume its rate-limit slot,
// so the next real edit isn't throttled by a check that had nothing to do. // so the next real edit isn't throttled by a check that had nothing to do.
// //
// Only a chunked pass consults that record, so only it needs the tone folded // Only a chunked pass consults that record, so only it needs the tone folded
// into a sentence's identity. // into a sentence's identity — and, next to it, the language verdict. A
// document that flips language changes every sentence's identity, so its
// old-language cards are re-checked rather than left sitting there in a
// language the rest of the document no longer speaks.
salt := "" salt := ""
if scope.chunked { if scope.chunked {
salt = tone salt = tone + "\x00" + docLang
} }
chunks := splitChunks(contentText, salt) chunks := splitChunks(contentText, salt)
askText, fresh := contentText, chunks askText, fresh := contentText, chunks
@@ -321,7 +395,7 @@ func (h *Handler) runPass(w http.ResponseWriter, r *http.Request, limiter *llm.R
if len(changed) == 0 { if len(changed) == 0 {
// Every sentence has already been read. Drop the rows whose sentence is // Every sentence has already been read. Drop the rows whose sentence is
// gone, keep the rest exactly as they are, and answer immediately. // gone, keep the rest exactly as they are, and answer immediately.
if err := h.reconcilePending(docID, contentText, pairLang, nil, scope, chunks, nil, false); err != nil { if err := h.reconcilePending(docID, contentText, pairLang, docLang, nil, scope, chunks, nil, false); err != nil {
httputil.ServerError(w, err) httputil.ServerError(w, err)
return return
} }
@@ -354,7 +428,7 @@ func (h *Handler) runPass(w http.ResponseWriter, r *http.Request, limiter *llm.R
return return
} }
raw, err := run(r.Context(), h.Client, askText, tone, llm.LangFor(pairLang)) raw, err := run(r.Context(), h.Client, askText, tone, target)
if err != nil { if err != nil {
// Allow ran before the model call, so a failed pass would otherwise hold // Allow ran before the model call, so a failed pass would otherwise hold
// the per-document slot for the full interval — stranding the frontend's // the per-document slot for the full interval — stranding the frontend's
@@ -366,7 +440,7 @@ func (h *Handler) runPass(w http.ResponseWriter, r *http.Request, limiter *llm.R
// A whole-document pass re-read everything, so every one of its rows is up for // A whole-document pass re-read everything, so every one of its rows is up for
// re-proposal; a chunked pass only puts the sentences it asked about in play. // re-proposal; a chunked pass only puts the sentences it asked about in play.
if err := h.reconcilePending(docID, contentText, pairLang, raw, scope, chunks, fresh, !scope.chunked); err != nil { if err := h.reconcilePending(docID, contentText, pairLang, docLang, raw, scope, chunks, fresh, !scope.chunked); err != nil {
httputil.ServerError(w, err) httputil.ServerError(w, err)
return return
} }
@@ -764,13 +838,13 @@ func (h *Handler) setStatus(w http.ResponseWriter, r *http.Request, status strin
// hands over a reusable chunk, which is the only thing worth reviewing in a week. // hands over a reusable chunk, which is the only thing worth reviewing in a week.
func (h *Handler) plant(id, userID string) { func (h *Handler) plant(id, userID string) {
var s db.Suggestion var s db.Suggestion
var contentText string var contentText, docLang string
err := h.DB.QueryRow( err := h.DB.QueryRow(
`SELECT s.type, s.original, s.replacement, s.explanation, s.doc_id, d.content_text `SELECT s.type, s.original, s.replacement, s.explanation, s.doc_id, d.content_text, d.doc_lang
FROM suggestions s JOIN documents d ON d.id = s.doc_id FROM suggestions s JOIN documents d ON d.id = s.doc_id
WHERE s.id = ? AND d.user_id = ?`, WHERE s.id = ? AND d.user_id = ?`,
id, userID, id, userID,
).Scan(&s.Type, &s.Original, &s.Replacement, &s.Explanation, &s.DocID, &contentText) ).Scan(&s.Type, &s.Original, &s.Replacement, &s.Explanation, &s.DocID, &contentText, &docLang)
if err != nil { if err != nil {
if !errors.Is(err, sql.ErrNoRows) { if !errors.Is(err, sql.ErrNoRows) {
log.Printf("suggestions: could not read %s for planting: %v", id, err) log.Printf("suggestions: could not read %s for planting: %v", id, err)
@@ -789,6 +863,9 @@ func (h *Handler) plant(id, userID string) {
Meaning: s.Explanation, Meaning: s.Explanation,
Example: correctedSentence(contentText, s.Original, s.Replacement), Example: correctedSentence(contentText, s.Original, s.Replacement),
DocID: &docID, DocID: &docID,
// The chunk is her own sentence, corrected — so it is in the document's
// language, whatever the collocation pass was framed in.
Lang: docLang,
}); err != nil { }); err != nil {
log.Printf("suggestions: could not plant %s: %v", id, err) log.Printf("suggestions: could not plant %s: %v", id, err)
} }
+61 -7
View File
@@ -28,16 +28,35 @@ import (
// common words a sentence in that language can hardly avoid and an English // common words a sentence in that language can hardly avoid and an English
// sentence has no reason to contain. // sentence has no reason to contain.
// isTranslation reports whether this edit is her own language rendered into // isTranslation reports whether this edit is a rendering of one language into
// English, rather than a correction to her English. Both halves must hold: the // the other, rather than a correction. Both halves must hold: the quoted span
// quoted span reads as the pair language, and what Petal offers back reads as // reads as one language, and what Petal offers back reads as the other. The
// English. The second half matters — a Chinese span rewritten into different // second half matters — a Chinese span rewritten into different Chinese is
// Chinese is something else entirely, and Petal has no business calling it a // something else entirely, and Petal has no business calling it a translation.
// translation. //
func isTranslation(original, replacement, pairLang string) bool { // Which way it points follows the document (Phase 28). In an English document
// the translate card is her language rendered into English — she reached for a
// sentence she couldn't say yet, and Petal said it for her. In a document she
// wrote in her own language the useful card is the mirror image: an English
// sentence she dropped into her Portuguese, rendered into Portuguese. Asking the
// English-document question there would label nothing, and the card would file
// as a correction to prose that was never wrong.
//
// The flipped direction cannot be the same test read backwards. `readsAsEnglish`
// is a low bar on purpose — Latin letters, not swamped by another script — which
// every Portuguese sentence also clears, so using it on the *original* would
// call every genuine Portuguese correction a translation. The flipped test
// instead uses the sentence-level vote from doclang.go, where English has its
// own marker list and has to out-evidence the pair language to win.
func isTranslation(original, replacement, pairLang, docLang string) bool {
if strings.TrimSpace(original) == "" || strings.TrimSpace(replacement) == "" { if strings.TrimSpace(original) == "" || strings.TrimSpace(replacement) == "" {
return false return false
} }
if normalizeDocLang(docLang) == docLangPair {
p := normalizePairLang(pairLang)
return sentenceLang(original, p) == docLangEnglish &&
sentenceLang(replacement, p) == docLangPair
}
return readsAsPairLang(original, pairLang) && readsAsEnglish(replacement) return readsAsPairLang(original, pairLang) && readsAsEnglish(replacement)
} }
@@ -136,6 +155,19 @@ func distinctMarkers(s string, markers map[string]bool) int {
// //
// A single marker is not enough (see readsAsPairLang), so these lists are read // A single marker is not enough (see readsAsPairLang), so these lists are read
// as evidence to be corroborated rather than as a decision. // as evidence to be corroborated rather than as a decision.
//
// **Curated against English is not the same as curated thinly**, and the first
// version of these lists confused the two. Seen live 2026-07-29: "Esta manhã
// acordei cedo e fui correr ao longo da marginal. O ar estava fresco e havia
// poucas pessoas na rua." — unremarkable Portuguese, two marker hits, *zero*
// English hits, and a verdict of English, because the document-level floor wants
// three. The list was missing the ordinary machinery of the language: the
// contractions (ao, à, num), the past tenses a diary is written in (estava,
// havia, fomos), and the words that join two clauses (até, depois, então,
// onde). Every one of them clears the bar above — an English sentence has no
// reason to contain them — so their absence bought nothing and cost the verdict.
// The floor stays at three; what changed is that three is now reachable by
// prose rather than only by a paragraph that happens to argue with itself.
var latinMarkers = map[string]map[string]bool{ var latinMarkers = map[string]map[string]bool{
"fr": words( "fr": words(
"je", "tu", "il", "elle", "ils", "elles", "nous", "vous", "est", "sont", "je", "tu", "il", "elle", "ils", "elles", "nous", "vous", "est", "sont",
@@ -145,6 +177,10 @@ var latinMarkers = map[string]map[string]bool{
"beaucoup", "toujours", "jamais", "quand", "bien", "chose", "temps", "beaucoup", "toujours", "jamais", "quand", "bien", "chose", "temps",
"moi", "toi", "lui", "peux", "veux", "sais", "faire", "dit", "aujourd", "moi", "toi", "lui", "peux", "veux", "sais", "faire", "dit", "aujourd",
"hui", "quelque", "chez", "tout", "tous", "rien", "déjà", "encore", "hui", "quelque", "chez", "tout", "tous", "rien", "déjà", "encore",
// The same gap the pt-PT list was caught with, closed by analogy rather
// than by observation — no fr account exists yet to catch it live.
"aux", "après", "où", "avait", "étaient", "depuis", "jusqu", "chaque",
"autre", "même", "hier", "demain", "matin", "soir", "nôtre", "leurs",
), ),
"pt-PT": words( "pt-PT": words(
"eu", "você", "ele", "ela", "eles", "elas", "nós", "são", "uma", "os", "eu", "você", "ele", "ela", "eles", "elas", "nós", "são", "uma", "os",
@@ -154,6 +190,18 @@ var latinMarkers = map[string]map[string]bool{
"nunca", "bem", "obrigado", "obrigada", "gosto", "tenho", "tem", "foi", "nunca", "bem", "obrigado", "obrigada", "gosto", "tenho", "tem", "foi",
"ser", "ter", "mais", "já", "ainda", "aqui", "ali", "nada", "tudo", "ser", "ter", "mais", "já", "ainda", "aqui", "ali", "nada", "tudo",
"todos", "para", "pela", "pelo", "sobre", "assim", "todos", "para", "pela", "pelo", "sobre", "assim",
// The contractions, which no English sentence has any use for.
"ao", "aos", "à", "às", "num", "numa", "dum", "duma", "pelos", "pelas",
"neste", "nesta", "disso", "deste", "desta",
// The tenses a journal is actually written in.
"estava", "estavam", "estão", "estamos", "havia", "houve", "era", "eram",
"fui", "fomos", "foram", "vai", "vamos", "tinha", "tinham",
// The joins between two clauses.
"até", "depois", "antes", "onde", "então", "enquanto", "embora",
"sem", "quem", "entre",
// And the everyday determiners and time words a diary can hardly avoid.
"nosso", "nossa", "outro", "outra", "mesmo", "mesma", "tão",
"muitos", "muitas", "poucos", "poucas", "hoje", "ontem", "amanhã",
), ),
"es": words( "es": words(
"yo", "él", "ella", "ellos", "ellas", "nosotros", "una", "los", "las", "yo", "él", "ella", "ellos", "ellas", "nosotros", "una", "los", "las",
@@ -162,6 +210,12 @@ var latinMarkers = map[string]map[string]bool{
"hacer", "siempre", "nunca", "bien", "gracias", "tengo", "tiene", "fue", "hacer", "siempre", "nunca", "bien", "gracias", "tengo", "tiene", "fue",
"ser", "tener", "más", "aquí", "allí", "nada", "todos", "ser", "tener", "más", "aquí", "allí", "nada", "todos",
"para", "sobre", "así", "hola", "señor", "usted", "muchas", "para", "sobre", "así", "hola", "señor", "usted", "muchas",
// Likewise by analogy: no es account exists yet either. "sin" and "tan"
// stay out — both are English words, which is the one disqualification.
"al", "después", "antes", "donde", "entonces", "mientras", "aunque",
"estaba", "estaban", "están", "había", "hubo", "fuimos", "fueron",
"nuestro", "nuestra", "otro", "otra", "mismo", "misma", "quién", "quien",
"muchos", "pocas", "pocos", "hoy", "ayer", "mañana",
), ),
} }
+93 -4
View File
@@ -3,6 +3,10 @@ package suggestions
import "testing" import "testing"
// The flagship case, and the ones next to it that must NOT become translations. // The flagship case, and the ones next to it that must NOT become translations.
//
// Every case here is an ENGLISH document — the path every account was on before
// Phase 28 — so `docLang` is left at "". The mirror image lives in
// TestIsTranslationInAPairLanguageDocument below.
func TestIsTranslation(t *testing.T) { func TestIsTranslation(t *testing.T) {
cases := []struct { cases := []struct {
name string name string
@@ -134,8 +138,93 @@ func TestIsTranslation(t *testing.T) {
for _, c := range cases { for _, c := range cases {
t.Run(c.name, func(t *testing.T) { t.Run(c.name, func(t *testing.T) {
if got := isTranslation(c.original, c.replacement, c.pairLang); got != c.want { if got := isTranslation(c.original, c.replacement, c.pairLang, ""); got != c.want {
t.Errorf("isTranslation(%q, %q, %q) = %v, want %v", t.Errorf("isTranslation(%q, %q, %q, en) = %v, want %v",
c.original, c.replacement, c.pairLang, got, c.want)
}
})
}
}
// The mirror image (Phase 28): in a document she wrote in her own language, the
// translate card is the English sentence rendered into her language — and the
// English-document question, asked here, would label nothing.
//
// The case this file exists to pin is the third one: a genuine Portuguese
// correction inside a Portuguese document. Reading the English-document test
// backwards would call it a translation, because `readsAsEnglish` is a low bar
// that Portuguese clears too. It has to stay a correction.
func TestIsTranslationInAPairLanguageDocument(t *testing.T) {
cases := []struct {
name string
original string
replacement string
pairLang string
want bool
}{
{
name: "English sentence rendered into Portuguese",
original: "I want to say this but I don't know how to say it.",
replacement: "Eu quero dizer isso mas não sei como o dizer.",
pairLang: "pt-PT",
want: true,
},
{
name: "English sentence rendered into Chinese",
original: "I don't know how to say this in Chinese.",
replacement: "我不知道这句话用中文怎么说。",
pairLang: "zh",
want: true,
},
{
// The one that matters. Portuguese in, Portuguese out, inside a
// Portuguese document: a correction, and nothing else.
name: "Portuguese corrected as Portuguese",
original: "Eu quero dizer isso mas não sei como.",
replacement: "Eu quero dizer isto mas não sei como.",
pairLang: "pt-PT",
want: false,
},
{
// And its Chinese twin, which the script test already caught.
name: "Chinese corrected as Chinese",
original: "我想说这句话",
replacement: "我要说这句话",
pairLang: "zh",
want: false,
},
{
// The old direction, asked in the new document. She quoted English in
// her Portuguese and Petal rendered it into Portuguese — which IS a
// translation, and is the case above. This is its reverse: Portuguese
// out of an English document that isn't one. No label.
name: "Portuguese rendered into English is not this document's translation",
original: "Eu quero dizer isso mas não sei como.",
replacement: "I want to say this but I don't know how.",
pairLang: "pt-PT",
want: false,
},
{
// English prose without enough evidence to vote. Silence, not a guess.
name: "too short to read as English",
original: "OK",
replacement: "Está bem, muito obrigado.",
pairLang: "pt-PT",
want: false,
},
{
name: "unknown pair language declines in both directions",
original: "I don't know how to say that.",
replacement: "Ich weiß nicht wie man das sagt.",
pairLang: "de",
want: false,
},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
if got := isTranslation(c.original, c.replacement, c.pairLang, docLangPair); got != c.want {
t.Errorf("isTranslation(%q, %q, %q, pair) = %v, want %v",
c.original, c.replacement, c.pairLang, got, c.want) c.original, c.replacement, c.pairLang, got, c.want)
} }
}) })
@@ -151,8 +240,8 @@ func TestNormalizePairLang(t *testing.T) {
"fr": "fr", "fr-FR": "fr", "fr": "fr", "fr-FR": "fr",
"es": "es", "es-ES": "es", "es": "es", "es-ES": "es",
" zh ": "zh", " zh ": "zh",
"": "", "": "",
"de": "de", "de": "de",
} { } {
if got := normalizePairLang(in); got != want { if got := normalizePairLang(in); got != want {
t.Errorf("normalizePairLang(%q) = %q, want %q", in, got, want) t.Errorf("normalizePairLang(%q) = %q, want %q", in, got, want)
+57
View File
@@ -184,3 +184,60 @@ func TestCorrectedSentence(t *testing.T) {
t.Errorf("unterminated doc: got %q", got) t.Errorf("unterminated doc: got %q", got)
} }
} }
// TestPlantedPhraseCarriesTheDocumentLanguage: a chunk planted out of a document
// written in her own language is a card in that language. The collocation pass
// itself deliberately did not flip in Phase 28 — its prompt is per-language
// knowledge, not framing — but the phrase it hands over is still lifted from her
// prose, so the card has to know what language that prose was in or the garden
// will read it aloud in the wrong voice.
func TestPlantedPhraseCarriesTheDocumentLanguage(t *testing.T) {
srv, docID, h := newTestServer(t, &stubClient{})
if _, err := h.DB.Exec(`UPDATE documents SET doc_lang = 'pair' WHERE id = ?`, docID); err != nil {
t.Fatalf("set doc_lang: %v", err)
}
id := seedSuggestion(t, h, docID,
"Ontem foi difícil. Tive de tomar uma decisão sobre o trabalho.",
db.SuggestionTypeCollocation, "tomar uma decisão", "tomar uma decisão",
"Em português diz-se “tomar” uma decisão.")
if rec := do(t, srv, http.MethodPost, "/suggestions/"+id+"/accept", ""); rec.Code != http.StatusNoContent {
t.Fatalf("accept: got %d, want 204", rec.Code)
}
var lang string
if err := h.DB.QueryRow(
`SELECT lang FROM vocab_words WHERE user_id = ? AND word = ?`,
db.LocalUserID, "tomar uma decisão",
).Scan(&lang); err != nil {
t.Fatalf("read planted card: %v", err)
}
if lang != "pair" {
t.Fatalf("planted card lang = %q, want pair", lang)
}
}
// TestPlantedPhraseOnAnEnglishDocumentIsUnchanged is the other half, and the one
// every account is on today: an English document plants an English card, and the
// empty backfill and 'en' both mean that.
func TestPlantedPhraseOnAnEnglishDocumentIsUnchanged(t *testing.T) {
srv, docID, h := newTestServer(t, &stubClient{})
id := seedSuggestion(t, h, docID, "I had to do a decision about the job.",
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)
}
var lang string
if err := h.DB.QueryRow(
`SELECT lang FROM vocab_words WHERE user_id = ? AND word = ?`,
db.LocalUserID, "make a decision",
).Scan(&lang); err != nil {
t.Fatalf("read planted card: %v", err)
}
if lang == "pair" {
t.Fatalf("planted card lang = %q on an English document, want en or empty", lang)
}
}
+6 -4
View File
@@ -131,10 +131,12 @@ func reposition(tx *sql.Tx, row pendingRow, from, to int, chunkHash string) erro
// collocation coach — where every row is up for re-proposal because the model // collocation coach — where every row is up for re-proposal because the model
// just re-read everything. // just re-read everything.
// //
// `pairLang` is the writer's own language, needed only to type a finding that // `pairLang` is the writer's own language and `docLang` this document's language
// turns out to be her language rendered into English (see language.go). // verdict; between them they type a finding that turns out to be one language
// rendered into the other, in whichever direction this document makes useful
// (see language.go).
func (h *Handler) reconcilePending( func (h *Handler) reconcilePending(
docID, contentText, pairLang string, docID, contentText, pairLang, docLang string,
raw []llm.RawSuggestion, raw []llm.RawSuggestion,
scope pendingScope, scope pendingScope,
chunks, fresh []chunk, chunks, fresh []chunk,
@@ -235,7 +237,7 @@ func (h *Handler) reconcilePending(
typ := scope.forceType typ := scope.forceType
if typ == "" { if typ == "" {
typ = normalizeType(s.Type) typ = normalizeType(s.Type)
if isTranslation(s.Original, s.Replacement, pairLang) { if isTranslation(s.Original, s.Replacement, pairLang, docLang) {
typ = db.SuggestionTypeTranslate typ = db.SuggestionTypeTranslate
} }
} }
+37 -9
View File
@@ -17,23 +17,37 @@ type translateResponse struct {
Translation string `json:"translation"` Translation string `json:"translation"`
} }
// translate renders a suggestion's English explanation into Simplified Chinese // translate renders a suggestion's explanation into the other half of the
// for the Ask Petal bubble, so the ESL reader sees the "why" in her first // writer's pair for the Ask Petal bubble, so she sees the "why" in the language
// language instead of a second copy of the same English text. The explanation is // she reads most easily instead of a second copy of the same text. The
// loaded server-side from the suggestion id (scoped to the local user) and never // explanation is loaded server-side from the suggestion id (scoped to the
// trusted from the client, mirroring chat (spec Note #10). // caller) and never trusted from the client, mirroring chat (spec Note #10).
//
// Which language it renders into cannot be assumed (Phase 28). Before that phase
// every explanation was English and every rendering went into her language, so
// "the pair language" was a safe constant. Now the explanation's language is a
// decision — `targetFor`, from the document's verdict and her direction — and
// this endpoint has to read the same decision back, or it round-trips Portuguese
// into Portuguese and calls it a translation.
//
// So: render into whichever half the explanation is NOT already in, and when the
// explanation already arrived in the language this bubble exists to reach her
// in, skip the model call and answer "". The client seeds the bubble with the
// explanation itself when the translation comes back empty, which is exactly
// right — there is nothing to add.
func (h *Handler) translate(w http.ResponseWriter, r *http.Request) { func (h *Handler) translate(w http.ResponseWriter, r *http.Request) {
sugID := chi.URLParam(r, "id") sugID := chi.URLParam(r, "id")
var explanation, pairLang string var explanation, pairLang, direction, docLang string
err := h.DB.QueryRow( err := h.DB.QueryRow(
`SELECT s.explanation, COALESCE(u.pair_lang, '') `SELECT s.explanation, COALESCE(u.pair_lang, ''),
COALESCE(u.direction, ''), d.doc_lang
FROM suggestions s FROM suggestions s
JOIN documents d ON d.id = s.doc_id JOIN documents d ON d.id = s.doc_id
JOIN users u ON u.id = d.user_id JOIN users u ON u.id = d.user_id
WHERE s.id = ? AND d.user_id = ?`, WHERE s.id = ? AND d.user_id = ?`,
sugID, auth.UserID(r.Context()), sugID, auth.UserID(r.Context()),
).Scan(&explanation, &pairLang) ).Scan(&explanation, &pairLang, &direction, &docLang)
if errors.Is(err, sql.ErrNoRows) { if errors.Is(err, sql.ErrNoRows) {
httputil.ErrorJSON(w, http.StatusNotFound, "suggestion not found") httputil.ErrorJSON(w, http.StatusNotFound, "suggestion not found")
return return
@@ -49,7 +63,21 @@ func (h *Handler) translate(w http.ResponseWriter, r *http.Request) {
return return
} }
out, err := llm.RunTranslate(r.Context(), h.Client, explanation, llm.LangFor(pairLang)) // The explanation's own language, recovered from the same rule that chose it
// when the card was written. A card written before this phase — or on a
// document whose verdict has since flipped — is read as whatever the rule says
// today; the alternative is a language column on every suggestion row, and the
// cost of being wrong is one bubble seeded in the language it was already in.
target := targetFor(pairLang, direction, docLang)
if target.Explain.Code == target.Pair.Code {
// Already in her language. The other half is English — the language she is
// practising — and an unasked-for English rendering of an explanation she
// can already read is not a seed, it's noise.
httputil.WriteJSON(w, http.StatusOK, translateResponse{Translation: ""})
return
}
out, err := llm.RunTranslate(r.Context(), h.Client, explanation, target.Pair)
if err != nil { if err != nil {
httputil.UpstreamError(w, "translate", err) httputil.UpstreamError(w, "translate", err)
return return
+32 -16
View File
@@ -19,13 +19,18 @@ import (
// Word is one entry in the vocabulary garden: the looked-up word with its gloss, // Word is one entry in the vocabulary garden: the looked-up word with its gloss,
// phonetic, and the sentence it was met in, plus its spaced-repetition state. // phonetic, and the sentence it was met in, plus its spaced-repetition state.
type Word struct { type Word struct {
ID string `json:"id"` ID string `json:"id"`
Word string `json:"word"` Word string `json:"word"`
Gloss string `json:"gloss"` Gloss string `json:"gloss"`
Definition string `json:"definition"` // English fallback meaning when there's no Chinese gloss Definition string `json:"definition"` // English fallback meaning when there's no Chinese gloss
Phonetic string `json:"phonetic"` Phonetic string `json:"phonetic"`
Example string `json:"example"` Example string `json:"example"`
DocID *string `json:"doc_id"` DocID *string `json:"doc_id"`
// Lang is '' | 'en' | 'pair' — the language of the document the word was met
// in (see migration 0018). '' reads as English, like everywhere else this
// vocabulary appears. The client needs it to pick a read-aloud voice: "comum"
// is unguessable from its letters, so the card has to carry the answer.
Lang string `json:"lang"`
DueAt time.Time `json:"due_at"` DueAt time.Time `json:"due_at"`
IntervalDays int `json:"interval_days"` IntervalDays int `json:"interval_days"`
Ease float64 `json:"ease"` Ease float64 `json:"ease"`
@@ -54,7 +59,7 @@ func (h *Handler) Routes() chi.Router {
return r return r
} }
const vocabColumns = `id, word, gloss, definition, phonetic, example, doc_id, const vocabColumns = `id, word, gloss, definition, phonetic, example, doc_id, lang,
due_at, interval_days, ease, reps, lapses, last_reviewed, created_at` due_at, interval_days, ease, reps, lapses, last_reviewed, created_at`
func scanWord(s interface { func scanWord(s interface {
@@ -62,7 +67,7 @@ func scanWord(s interface {
}) (Word, error) { }) (Word, error) {
var w Word var w Word
err := s.Scan( err := s.Scan(
&w.ID, &w.Word, &w.Gloss, &w.Definition, &w.Phonetic, &w.Example, &w.DocID, &w.ID, &w.Word, &w.Gloss, &w.Definition, &w.Phonetic, &w.Example, &w.DocID, &w.Lang,
&w.DueAt, &w.IntervalDays, &w.Ease, &w.Reps, &w.Lapses, &w.LastReviewed, &w.CreatedAt, &w.DueAt, &w.IntervalDays, &w.Ease, &w.Reps, &w.Lapses, &w.LastReviewed, &w.CreatedAt,
) )
return w, err return w, err
@@ -165,15 +170,22 @@ func (h *Handler) capture(w http.ResponseWriter, r *http.Request) {
// would hit the foreign key and leak a raw "FOREIGN KEY constraint" 500 // would hit the foreign key and leak a raw "FOREIGN KEY constraint" 500
// instead of a clean 400 (and, once auth lands, would let a word be attached // instead of a clean 400 (and, once auth lands, would let a word be attached
// to another user's document). // to another user's document).
//
// The same row-scoped lookup answers what language the card is in: a word is
// met inside a document, so the document's verdict is the word's language.
// Asking the document rather than trusting a `lang` in the request body is
// the same choice `runPass` makes — the client never gets to name a language
// the server can already read. A word with no document is '', which reads as
// English.
lang := ""
if req.DocID != nil { if req.DocID != nil {
if strings.TrimSpace(*req.DocID) == "" { if strings.TrimSpace(*req.DocID) == "" {
req.DocID = nil req.DocID = nil
} else { } else {
var ok int
err := h.DB.QueryRow( err := h.DB.QueryRow(
`SELECT 1 FROM documents WHERE id = ? AND user_id = ?`, `SELECT doc_lang FROM documents WHERE id = ? AND user_id = ?`,
*req.DocID, userID, *req.DocID, userID,
).Scan(&ok) ).Scan(&lang)
if errors.Is(err, sql.ErrNoRows) { if errors.Is(err, sql.ErrNoRows) {
httputil.ErrorJSON(w, http.StatusBadRequest, "unknown doc_id") httputil.ErrorJSON(w, http.StatusBadRequest, "unknown doc_id")
return return
@@ -189,15 +201,19 @@ func (h *Handler) capture(w http.ResponseWriter, r *http.Request) {
// schedule (due_at/reps/interval/ease) alone so re-looking-up a word never // schedule (due_at/reps/interval/ease) alone so re-looking-up a word never
// resets its progress. // resets its progress.
_, err := h.DB.Exec( _, err := h.DB.Exec(
`INSERT INTO vocab_words (user_id, word, gloss, definition, phonetic, example, doc_id, due_at, interval_days) `INSERT INTO vocab_words (user_id, word, gloss, definition, phonetic, example, doc_id, lang, due_at, interval_days)
VALUES (?, ?, ?, ?, ?, ?, ?, datetime('now', '+1 day'), 1) VALUES (?, ?, ?, ?, ?, ?, ?, ?, datetime('now', '+1 day'), 1)
ON CONFLICT(user_id, word) DO UPDATE SET ON CONFLICT(user_id, word) DO UPDATE SET
gloss = excluded.gloss, gloss = excluded.gloss,
definition = excluded.definition, definition = excluded.definition,
phonetic = excluded.phonetic, phonetic = excluded.phonetic,
example = CASE WHEN excluded.example != '' THEN excluded.example ELSE vocab_words.example END, example = CASE WHEN excluded.example != '' THEN excluded.example ELSE vocab_words.example END,
doc_id = COALESCE(excluded.doc_id, vocab_words.doc_id)`, doc_id = COALESCE(excluded.doc_id, vocab_words.doc_id),
userID, word, req.Gloss, req.Definition, req.Phonetic, req.Example, req.DocID, -- lang travels with doc_id, and for the same reason: it is the new
-- context or it is nothing. A lookup made outside any document must
-- not relabel a card that was captured inside one.
lang = CASE WHEN excluded.doc_id IS NOT NULL THEN excluded.lang ELSE vocab_words.lang END`,
userID, word, req.Gloss, req.Definition, req.Phonetic, req.Example, req.DocID, lang,
) )
if err != nil { if err != nil {
httputil.ServerError(w, err) httputil.ServerError(w, err)
+86
View File
@@ -253,3 +253,89 @@ func TestDocLinkSurvivesDocDelete(t *testing.T) {
t.Fatalf("doc_id should be nulled after doc delete, got %v", *all[0].DocID) t.Fatalf("doc_id should be nulled after doc delete, got %v", *all[0].DocID)
} }
} }
// TestCaptureTakesLanguageFromItsDocument is the garden's half of Phase 28: a
// word met inside a document written in her own language is a card in that
// language, and the server reads that off the document rather than being told.
//
// The three cases are the three the client can actually produce: a lookup inside
// a flipped document, a lookup inside an English one, and a lookup with no
// document at all (the search box) — the last of which is '', which reads as
// English everywhere this value is used.
func TestCaptureTakesLanguageFromItsDocument(t *testing.T) {
srv, database := newTestServer(t)
seed := func(lang string) string {
t.Helper()
var id string
if err := database.QueryRow(
`INSERT INTO documents (user_id, content_text, doc_lang) VALUES (?, 'hi', ?) RETURNING id`,
db.LocalUserID, lang,
).Scan(&id); err != nil {
t.Fatalf("seed doc: %v", err)
}
return id
}
pairDoc, enDoc := seed("pair"), seed("en")
capture := func(word, body string) Word {
t.Helper()
rec := do(t, srv, http.MethodPost, "/vocab", body)
if rec.Code != http.StatusCreated {
t.Fatalf("capture %s: code=%d body=%s", word, rec.Code, rec.Body)
}
var w Word
if err := json.Unmarshal(rec.Body.Bytes(), &w); err != nil {
t.Fatalf("decode %s: %v", word, err)
}
return w
}
if got := capture("comum", `{"word":"comum","doc_id":"`+pairDoc+`"}`); got.Lang != "pair" {
t.Fatalf("word from a flipped document: lang=%q, want pair", got.Lang)
}
if got := capture("reception", `{"word":"reception","doc_id":"`+enDoc+`"}`); got.Lang != "en" {
t.Fatalf("word from an English document: lang=%q, want en", got.Lang)
}
if got := capture("orphan", `{"word":"orphan"}`); got.Lang != "" {
t.Fatalf("word with no document: lang=%q, want empty", got.Lang)
}
// A client that names a language is ignored: the document is the authority,
// the same way runPass never lets the request pick its own target.
if got := capture("comum", `{"word":"comum","lang":"en","doc_id":"`+pairDoc+`"}`); got.Lang != "pair" {
t.Fatalf("client-supplied lang should not win: lang=%q, want pair", got.Lang)
}
}
// TestRecaptureOutsideADocumentKeepsItsLanguage pins the one asymmetry in the
// upsert. Re-looking-up a word refreshes its context, but a lookup made with no
// document carries no verdict — and relabelling a Portuguese card English
// because she checked the word again from the search box would silently move it
// to the wrong voice. lang travels with doc_id, or it doesn't travel.
func TestRecaptureOutsideADocumentKeepsItsLanguage(t *testing.T) {
srv, database := newTestServer(t)
var docID string
if err := database.QueryRow(
`INSERT INTO documents (user_id, content_text, doc_lang) VALUES (?, 'olá', 'pair') RETURNING id`,
db.LocalUserID,
).Scan(&docID); err != nil {
t.Fatalf("seed doc: %v", err)
}
if rec := do(t, srv, http.MethodPost, "/vocab",
`{"word":"saudade","gloss":"","doc_id":"`+docID+`"}`); rec.Code != http.StatusCreated {
t.Fatalf("capture: code=%d body=%s", rec.Code, rec.Body)
}
rec := do(t, srv, http.MethodPost, "/vocab", `{"word":"saudade","gloss":"longing"}`)
if rec.Code != http.StatusCreated {
t.Fatalf("recapture: code=%d body=%s", rec.Code, rec.Body)
}
var w Word
_ = json.Unmarshal(rec.Body.Bytes(), &w)
if w.Lang != "pair" {
t.Fatalf("recapture outside a document: lang=%q, want pair held", w.Lang)
}
if w.Gloss != "longing" {
t.Fatalf("recapture should still refresh the gloss, got %q", w.Gloss)
}
}
+7 -2
View File
@@ -28,6 +28,10 @@ type Phrase struct {
Meaning string // why it's better — the suggestion's explanation Meaning string // why it's better — the suggestion's explanation
Example string // the sentence she met it in, already corrected Example string // the sentence she met it in, already corrected
DocID *string // where, so "where did I see this?" stays one tap DocID *string // where, so "where did I see this?" stays one tap
// Lang is the document's verdict ('' | 'en' | 'pair'), because the chunk is
// lifted out of her own prose and is therefore in whatever language that
// prose is. See migration 0018.
Lang string
} }
// Phrase-card caps. A collocation is a short chunk; anything longer is a // Phrase-card caps. A collocation is a short chunk; anything longer is a
@@ -84,13 +88,14 @@ func Plant(ex Execer, userID string, p Phrase) (bool, error) {
return false, nil return false, nil
} }
res, err := ex.Exec( res, err := ex.Exec(
`INSERT INTO vocab_words (user_id, word, gloss, definition, phonetic, example, doc_id, due_at, interval_days) `INSERT INTO vocab_words (user_id, word, gloss, definition, phonetic, example, doc_id, lang, due_at, interval_days)
VALUES (?, ?, '', ?, '', ?, ?, datetime('now', '+1 day'), 1) VALUES (?, ?, '', ?, '', ?, ?, ?, datetime('now', '+1 day'), 1)
ON CONFLICT(user_id, word) DO NOTHING`, ON CONFLICT(user_id, word) DO NOTHING`,
userID, key, userID, key,
clamp(strings.TrimSpace(p.Meaning), maxDefinitionLen), clamp(strings.TrimSpace(p.Meaning), maxDefinitionLen),
clamp(strings.TrimSpace(p.Example), maxExampleLen), clamp(strings.TrimSpace(p.Example), maxExampleLen),
p.DocID, p.DocID,
p.Lang,
) )
if err != nil { if err != nil {
return false, err return false, err
+34 -2
View File
@@ -1,5 +1,14 @@
import { useCallback, useEffect, useRef, useState } from 'react' import { useCallback, useEffect, useRef, useState } from 'react'
import { api, type DocSummary, type DocUpdate, type Document, type Suggestion, type Tag, type TagColor } from './api/client' import {
api,
onDocLang,
type DocSummary,
type DocUpdate,
type Document,
type Suggestion,
type Tag,
type TagColor,
} from './api/client'
import { useAutoSave } from './hooks/useAutoSave' import { useAutoSave } from './hooks/useAutoSave'
import { findingKey, useCheckpoint } from './hooks/useCheckpoint' import { findingKey, useCheckpoint } from './hooks/useCheckpoint'
import { useSpellChecker } from './hooks/useSpellChecker' import { useSpellChecker } from './hooks/useSpellChecker'
@@ -81,7 +90,29 @@ export default function App() {
return wordCountRef.current === 0 && (t === '' || t === 'Untitled') return wordCountRef.current === 0 && (t === '' || t === 'Untitled')
}, []) }, [])
const { status, schedule, saveNow } = useAutoSave(currentDoc?.id ?? null) // The pass announces its verdict the moment it decides one, which is the only
// moment that is prompt enough: read-aloud is reached for when she has stopped
// typing, so waiting for the next save means waiting for a save that isn't
// coming. Registered once, and it updates the same one field the save path
// does — whichever arrives first wins, and they agree.
useEffect(() => {
onDocLang((docId, lang) =>
setCurrentDoc((prev) => (prev && prev.id === docId && prev.doc_lang !== lang ? { ...prev, doc_lang: lang } : prev)),
)
}, [])
// Only `doc_lang` is lifted out of the save response, and only when it moved.
// It is the one field the server decides on its own — the checkpoint pass reads
// the whole document and writes back whether it is English or hers — so it is
// the one field that would otherwise go stale under her while she writes. Read
// -aloud is what notices: a Portuguese paragraph read in an American voice.
// Everything else in the response is what the client just sent, and copying it
// back mid-keystroke would be a way to lose a character, not to gain one.
const { status, schedule, saveNow } = useAutoSave(currentDoc?.id ?? null, (saved) =>
setCurrentDoc((prev) =>
prev && prev.id === saved.id && prev.doc_lang !== saved.doc_lang ? { ...prev, doc_lang: saved.doc_lang } : prev,
),
)
// The Chinese word list, for a writer going the other way through the zh pair. // The Chinese word list, for a writer going the other way through the zh pair.
// Gated on the account's own setting rather than on anything in the text: a // Gated on the account's own setting rather than on anything in the text: a
// Mandarin native drafting English quotes Chinese constantly, and none of that // Mandarin native drafting English quotes Chinese constantly, and none of that
@@ -615,6 +646,7 @@ export default function App() {
<EditorCore <EditorCore
key={`${currentDoc.id}:${editorEpoch}`} key={`${currentDoc.id}:${editorEpoch}`}
docId={currentDoc.id} docId={currentDoc.id}
docLang={currentDoc.doc_lang}
initialContent={currentDoc.content} initialContent={currentDoc.content}
onChange={handleEditorChange} onChange={handleEditorChange}
segmenter={segmenter} segmenter={segmenter}
+40 -4
View File
@@ -44,8 +44,18 @@ export interface Document {
// When true, this document's automatic snapshots are never pruned, so its // When true, this document's automatic snapshots are never pruned, so its
// full writing trail survives as authorship evidence (see the passport). // full writing trail survives as authorship evidence (see the passport).
preserve_history: boolean preserve_history: boolean
// Which language this document is written in, as decided server-side by the
// checkpoint pass: '' | 'en' | 'pair' ('' reads as English). Read-only — the
// editor never sends it. It is here so read-aloud can use the right voice on a
// document written in her own language.
doc_lang: DocLang
} }
// The document-language verdict, shared by documents and garden cards. 'pair'
// names the writer's own language rather than a language code, so changing her
// pair re-reads her documents instead of stranding a stale name on them.
export type DocLang = '' | 'en' | 'pair'
// Fields the editor sends on auto-save. All optional so a rename can send title // Fields the editor sends on auto-save. All optional so a rename can send title
// alone; the editor sends the full set. // alone; the editor sends the full set.
export interface DocUpdate { export interface DocUpdate {
@@ -125,6 +135,9 @@ export interface VocabWord {
phonetic: string phonetic: string
example: string example: string
doc_id: string | null doc_id: string | null
// The language of the document this word was met in — the card's own language
// (migration 0018). Read-aloud needs it: "comum" is unguessable from letters.
lang: DocLang
due_at: string due_at: string
interval_days: number interval_days: number
ease: number ease: number
@@ -263,11 +276,34 @@ function signedOut(): UnauthorizedError {
return new UnauthorizedError() return new UnauthorizedError()
} }
async function req<T>(path: string, init?: RequestInit): Promise<T> { // The document-language verdict is decided by the checkpoint pass, so the pass's
// own response is the first moment the client can know it. It rides on a header
// (the pass answers with a bare array of suggestions, and every caller reads it
// as one), and reaches the app through a handler registered here — the same
// shape onUnauthorized already uses, for the same reason: it is one fact from
// deep inside a request that a component several layers up needs.
//
// Without it the editor learns the verdict only from a document save, which is
// always one save behind the pass — and read-aloud is reached for precisely when
// she has stopped typing and no further save is coming.
let docLangHandler: ((docId: string, lang: DocLang) => void) | null = null
export function onDocLang(handler: (docId: string, lang: DocLang) => void) {
docLangHandler = handler
}
// `verdictFor` names the document whose language this response may announce.
// Only the three pass endpoints pass it; everything else has no verdict to carry
// and never touches the handler.
async function req<T>(path: string, init?: RequestInit, verdictFor?: string): Promise<T> {
const res = await fetch(`/api${path}`, { const res = await fetch(`/api${path}`, {
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
...init, ...init,
}) })
if (verdictFor && res.ok) {
const lang = res.headers.get('X-Petal-Doc-Lang')
if (lang === '' || lang === 'en' || lang === 'pair') docLangHandler?.(verdictFor, lang)
}
if (res.status === 401) throw signedOut() if (res.status === 401) throw signedOut()
if (!res.ok) { if (!res.ok) {
const detail = await res.text().catch(() => '') const detail = await res.text().catch(() => '')
@@ -309,14 +345,14 @@ export const api = {
// Rate-limited per document server-side (returns the existing set if too soon). // Rate-limited per document server-side (returns the existing set if too soon).
// Both passes return the UNIFIED pending set (grammar + voice), so the client // Both passes return the UNIFIED pending set (grammar + voice), so the client
// never drops one family's highlights when the other refreshes. // never drops one family's highlights when the other refreshes.
checkDoc: (id: string) => req<Suggestion[]>(`/docs/${id}/check`, { method: 'POST' }), checkDoc: (id: string) => req<Suggestion[]>(`/docs/${id}/check`, { method: 'POST' }, id),
// Voice-consistency pass: whole-document, explicit-action, slower. Returns the // Voice-consistency pass: whole-document, explicit-action, slower. Returns the
// unified pending set too. Rate-limited per document server-side. // unified pending set too. Rate-limited per document server-side.
voiceDoc: (id: string) => req<Suggestion[]>(`/docs/${id}/voice`, { method: 'POST' }), voiceDoc: (id: string) => req<Suggestion[]>(`/docs/${id}/voice`, { method: 'POST' }, id),
// Collocation coach: whole-document, explicit-action pass flagging non-native // Collocation coach: whole-document, explicit-action pass flagging non-native
// word pairings ("do a decision" → "make a decision"). Returns the unified // word pairings ("do a decision" → "make a decision"). Returns the unified
// pending set too. Rate-limited per document server-side. // pending set too. Rate-limited per document server-side.
collocationDoc: (id: string) => req<Suggestion[]>(`/docs/${id}/collocation`, { method: 'POST' }), collocationDoc: (id: string) => req<Suggestion[]>(`/docs/${id}/collocation`, { method: 'POST' }, id),
// Mechanics pass: persist the client-detected deterministic fixes as the // Mechanics pass: persist the client-detected deterministic fixes as the
// 'mechanics' family and return the unified pending set. Not rate-limited (it's // 'mechanics' family and return the unified pending set. Not rate-limited (it's
// free, local detection); runs alongside the grammar checkpoint. // free, local detection); runs alongside the grammar checkpoint.
+31 -1
View File
@@ -1,5 +1,5 @@
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest' import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
import { nativeLang, speak, stopSpeech } from './speech' import { docLang, nativeLang, speak, stopSpeech } from './speech'
import { resetPackForTests, setPackLang } from '../i18n' import { resetPackForTests, setPackLang } from '../i18n'
// Read-aloud has two jobs beyond "make a sound": ask for the right pace, and ask // Read-aloud has two jobs beyond "make a sound": ask for the right pace, and ask
@@ -85,3 +85,33 @@ describe('nativeLang', () => {
expect(bodies.at(-1)).toMatchObject({ text: 'chat', lang: 'fr-FR' }) expect(bodies.at(-1)).toMatchObject({ text: 'chat', lang: 'fr-FR' })
}) })
}) })
describe('docLang', () => {
// The document's verdict is the answer for a passage lifted out of it, because
// for a Latin pair there is no other answer available: an English sentence and
// a Portuguese one are the same letters.
it('reads a flipped document in her own language', () => {
setPackLang('pt-PT')
expect(docLang('Ontem foi difícil.', 'pair')).toBe('pt-PT')
})
it('reads an English document in English, and treats the backfill as English', () => {
setPackLang('pt-PT')
expect(docLang('Yesterday was hard.', 'en')).toBe('en-US')
expect(docLang('Yesterday was hard.', '')).toBe('en-US')
})
it('lets the script win over the verdict, so quoted Chinese is never spelled out', () => {
// An English document quoting Chinese is 'en' by verdict, and the English
// voice reads Han characters one "Chinese letter" at a time — the one
// failure worse than silence.
setPackLang('zh')
expect(docLang('你好世界', 'en')).toBe('zh-CN')
})
it('is what the editor selection and the garden card ask for', () => {
setPackLang('fr')
speak('Le chat dort.', docLang('Le chat dort.', 'pair'))
expect(bodies.at(-1)).toMatchObject({ text: 'Le chat dort.', lang: 'fr-FR' })
})
})
+18
View File
@@ -90,6 +90,24 @@ export function nativeLang(): string {
return pack().locale return pack().locale
} }
// docLang turns a document-language verdict ('' | 'en' | 'pair', decided
// server-side — see internal/suggestions/doclang.go) into a locale for a passage
// taken out of that document. It is what the editor's read-aloud and the garden's
// review card ask instead of guessing.
//
// The script test still wins, and that is not redundant with the verdict. A
// Chinese sentence quoted inside an English document is 'en' by verdict and
// still has to be read by the Chinese voice: the English voice spells Han
// characters out one "Chinese letter" at a time, which is the one failure loud
// enough to be worse than no audio. In the other direction there is nothing to
// test — an English sentence inside Portuguese prose looks exactly like the
// Portuguese around it — so the document's verdict is the only answer available,
// and it is the answer this phase decided on.
export function docLang(text: string, verdict: string): string {
if (CJK.test(text)) return 'zh-CN'
return verdict === 'pair' ? pack().locale : 'en-US'
}
// speak reads `text` aloud, cancelling anything already in flight so rapid taps // speak reads `text` aloud, cancelling anything already in flight so rapid taps
// don't queue up. `lang` defaults to a guess from the text (Chinese vs English) // don't queue up. `lang` defaults to a guess from the text (Chinese vs English)
// so callers can just pass the selection; pass an explicit locale to override. // so callers can just pass the selection; pass an explicit locale to override.
@@ -88,13 +88,18 @@ export function PetalCompanion({
const [pickerOpen, setPickerOpen] = useState(false) const [pickerOpen, setPickerOpen] = useState(false)
const rootRef = useRef<HTMLDivElement>(null) const rootRef = useRef<HTMLDivElement>(null)
const badgeRef = useRef<HTMLButtonElement>(null) const badgeRef = useRef<HTMLButtonElement>(null)
// Stand-in for the badge's corner, used only for measuring — see
// useCardOverlap. It sits exactly where the badge sits but never bobs, shrinks
// or hovers, so what the mascot yields to can't depend on whether it is
// currently yielding.
const probeRef = useRef<HTMLSpanElement>(null)
// When suggestion cards stack down into the corner, the kitten fades to // When suggestion cards stack down into the corner, the kitten fades to
// translucent and shrinks a step so the card stays readable and clickable. // translucent and shrinks a step so the card stays readable and clickable.
// It wakes back up whenever it has something to say (bubble) or is being // It wakes back up whenever it has something to say (bubble) or is being
// interacted with (picker open) — except under an open History or Garden // interacted with (picker open) — except under an open History or Garden
// panel, where even a cheer would cover the controls she just reached for. // panel, where even a cheer would cover the controls she just reached for.
const crowded = useCardOverlap(badgeRef) const crowded = useCardOverlap(probeRef)
const faded = crowded.modal || (crowded.cards && !pickerOpen && !bubble) const faded = crowded.modal || (crowded.cards && !pickerOpen && !bubble)
// Awake companions (no sleeping clip) don't visibly nap — when the engine // Awake companions (no sleeping clip) don't visibly nap — when the engine
@@ -135,7 +140,7 @@ export function PetalCompanion({
return ( return (
<div <div
ref={rootRef} ref={rootRef}
className="pointer-events-none fixed bottom-4 right-4 z-40 flex flex-col items-end gap-2" className="petal-corner pointer-events-none fixed bottom-4 right-4 z-40 flex flex-col items-end gap-2"
> >
{pickerOpen && ( {pickerOpen && (
<div <div
@@ -251,6 +256,16 @@ export function PetalCompanion({
</div> </div>
)} )}
<span
ref={probeRef}
aria-hidden
className="pointer-events-none absolute bottom-0 right-0"
style={{
width: 'var(--petal-companion-size, 9rem)',
height: 'var(--petal-companion-size, 9rem)',
}}
/>
<button <button
ref={badgeRef} ref={badgeRef}
type="button" type="button"
+31 -4
View File
@@ -1,4 +1,4 @@
import { useEffect, useState, type RefObject } from 'react' import { useEffect, useRef, useState, type RefObject } from 'react'
// How often to re-measure outside of scroll/resize events. Cards re-pack when // How often to re-measure outside of scroll/resize events. Cards re-pack when
// suggestions arrive, expand, or get accepted — none of which fire an event we // suggestions arrive, expand, or get accepted — none of which fire an event we
@@ -11,6 +11,11 @@ const POLL_MS = 500
// means a future drawer is covered the day it's written, without a list to keep // means a future drawer is covered the day it's written, without a list to keep
// in sync. Anything that doesn't actually reach the corner still won't trip the // in sync. Anything that doesn't actually reach the corner still won't trip the
// rect test below. // rect test below.
// How far a card must retreat before an already-yielded mascot comes back. Wide
// enough to cover a re-pack or a browser bar appearing, small enough that a card
// genuinely scrolled away still wakes it.
const HOLD_PX = 24
const CARD = '.petal-rail-card' const CARD = '.petal-rail-card'
const MODAL = '[role="dialog"][aria-modal="true"]' const MODAL = '[role="dialog"][aria-modal="true"]'
@@ -25,11 +30,21 @@ export interface CardOverlap {
} }
// Reports what, if anything, the mascot should yield to at the given element. // Reports what, if anything, the mascot should yield to at the given element.
//
// Pass the probe span, never the badge itself. The badge shrinks when it yields
// (.petal-companion-faded) and bobs continuously (petal-bob), and
// getBoundingClientRect reports the *transformed* box — so measuring the badge
// lets it shrink out of its own overlap test, wake up, overlap again, and pulse
// forever against a card resting at its edge. The probe holds the corner box
// still whatever the mascot is doing, which is what makes the test settle.
// Used to fade the corner mascot out of the way when a card or panel reaches // Used to fade the corner mascot out of the way when a card or panel reaches
// into its corner, so nothing is ever hidden (or made unclickable) by the // into its corner, so nothing is ever hidden (or made unclickable) by the
// kitten. // kitten.
export function useCardOverlap(ref: RefObject<HTMLElement | null>): CardOverlap { export function useCardOverlap(ref: RefObject<HTMLElement | null>): CardOverlap {
const [overlap, setOverlap] = useState<CardOverlap>({ cards: false, modal: false }) const [overlap, setOverlap] = useState<CardOverlap>({ cards: false, modal: false })
// check() runs on a timer and reads the previous answer, which state alone
// wouldn't hand it without re-subscribing every render.
const current = useRef(overlap)
useEffect(() => { useEffect(() => {
let raf = 0 let raf = 0
@@ -38,16 +53,28 @@ export function useCardOverlap(ref: RefObject<HTMLElement | null>): CardOverlap
const el = ref.current const el = ref.current
if (!el) return if (!el) return
const r = el.getBoundingClientRect() const r = el.getBoundingClientRect()
const hits = (selector: string) => { // Cards settle a pixel or two from the corner all the time — the rail
// re-packs, the window resizes, a browser bar appears. Yielding is a
// visible move, so once yielded, hold it until the card has clearly gone:
// decide on a box grown by HOLD_PX rather than flipping on the exact edge.
const hits = (selector: string, held: boolean) => {
const pad = held ? HOLD_PX : 0
for (const other of document.querySelectorAll(selector)) { for (const other of document.querySelectorAll(selector)) {
const b = other.getBoundingClientRect() const b = other.getBoundingClientRect()
if (b.left < r.right && b.right > r.left && b.top < r.bottom && b.bottom > r.top) { if (
b.left < r.right + pad &&
b.right > r.left - pad &&
b.top < r.bottom + pad &&
b.bottom > r.top - pad
) {
return true return true
} }
} }
return false return false
} }
const next = { cards: hits(CARD), modal: hits(MODAL) } const prev = current.current
const next = { cards: hits(CARD, prev.cards), modal: hits(MODAL, prev.modal) }
current.current = next
// Same-value object identity would re-render on every poll tick. // Same-value object identity would re-render on every poll tick.
setOverlap((prev) => setOverlap((prev) =>
prev.cards === next.cards && prev.modal === next.modal ? prev : next, prev.cards === next.cards && prev.modal === next.modal ? prev : next,
+201 -13
View File
@@ -32,9 +32,11 @@ import { Typography } from './Typography'
import { Composition } from './Composition' import { Composition } from './Composition'
import { RewritePreview, type RewriteStatus } from './RewritePreview' import { RewritePreview, type RewriteStatus } from './RewritePreview'
import { planBatch } from './acceptBatch' import { planBatch } from './acceptBatch'
import { api, type Suggestion, type SuggestionType, type WordInfo } from '../../api/client' import { entryId, idAfterRemoval, stepId, type Direction, type Span } from './triage'
import { speak, speechSupported } from '../../audio/speech' import { api, type DocLang, type Suggestion, type SuggestionType, type WordInfo } from '../../api/client'
import { docLang as docLocale, speak, speechSupported } from '../../audio/speech'
import type { SpellChecker } from '../../hooks/useSpellChecker' import type { SpellChecker } from '../../hooks/useSpellChecker'
import { fromIME } from '../../lib/ime'
import type { Segmenter } from '../../lib/segment' import type { Segmenter } from '../../lib/segment'
import { hanziWordAt, hanziToWordInfo, hanziPinyin } from './hanziWord' import { hanziWordAt, hanziToWordInfo, hanziPinyin } from './hanziWord'
import { usePack } from '../../i18n' import { usePack } from '../../i18n'
@@ -63,6 +65,13 @@ export interface EditorChange {
interface Props { interface Props {
// Changing docId reloads the editor with that document's content. // Changing docId reloads the editor with that document's content.
docId: string docId: string
// Which language this document is written in, as the server decided it
// ('' | 'en' | 'pair'). Read-aloud is the only thing here that reads it: a
// Portuguese selection has to be read by the Portuguese voice, and nothing in
// the letters says so. Updated by the server on save, so it trails a language
// flip by one auto-save — a passage read in the old voice once is the whole
// cost of not blocking the editor on a check.
docLang: DocLang
initialContent: string initialContent: string
onChange: (change: EditorChange) => void onChange: (change: EditorChange) => void
// LLM suggestions to highlight; accept/dismiss notify the parent for the API // LLM suggestions to highlight; accept/dismiss notify the parent for the API
@@ -204,6 +213,9 @@ interface HoverState {
suggestion: Suggestion suggestion: Suggestion
top: number top: number
left: number left: number
// Opened by a keystroke rather than a pointer: the card takes focus and
// answers the triage keys itself (item 8).
keyboard?: boolean
} }
// The inline hover gloss: the word under the resting pointer, its Chinese // The inline hover gloss: the word under the resting pointer, its Chinese
@@ -250,6 +262,7 @@ interface RewriteState {
// decoration layer. Hovering a highlight opens its SuggestionCard. // decoration layer. Hovering a highlight opens its SuggestionCard.
export function EditorCore({ export function EditorCore({
docId, docId,
docLang,
initialContent, initialContent,
onChange, onChange,
suggestions, suggestions,
@@ -646,7 +659,7 @@ export function EditorCore({
}, []) }, [])
const openCardFor = useCallback( const openCardFor = useCallback(
(id: string, el: HTMLElement) => { (id: string, el: HTMLElement, keyboard = false) => {
const wrapper = wrapperRef.current const wrapper = wrapperRef.current
if (!wrapper) return if (!wrapper) return
const suggestion = suggestions.find((s) => s.id === id) const suggestion = suggestions.find((s) => s.id === id)
@@ -664,7 +677,7 @@ export function EditorCore({
setHover((prev) => { setHover((prev) => {
// Moving to a different highlight resets any Ask Petal pin. // Moving to a different highlight resets any Ask Petal pin.
if (prev && prev.suggestion.id !== suggestion.id) setPinned(false) if (prev && prev.suggestion.id !== suggestion.id) setPinned(false)
return { suggestion, top, left } return { suggestion, top, left, keyboard }
}) })
}, },
[suggestions], [suggestions],
@@ -721,6 +734,140 @@ export function EditorCore({
const keepOpen = useCallback(() => clearTimeout(closeTimer.current), []) const keepOpen = useCallback(() => clearTimeout(closeTimer.current), [])
// ——— Keyboard triage (item 8) ———
//
// The whole queue can be walked, answered and left without a pointer:
// Ctrl/Cmd+. and Ctrl/Cmd+, step through the underlines from anywhere in the
// editor, and the card that opens takes focus and answers Tab / Enter / Del /
// Esc itself (SuggestionCard). It is the *anchored* card in both layouts,
// rail or no rail — item 7's finding, that the popover at the word is the
// primary surface, is what lets one keyboard flow cover both.
//
// Why a chord and not the item's bare Tab or n/p: this is a text editor, and
// an unmodified letter is a letter. Tab is available only once the card holds
// focus, which is exactly where the item asks for it, and getting there needs
// a key that is safe to press mid-sentence — in the middle of a Chinese
// composition, even, which is why the IME guard is here too.
// Where triage lands once the open card is answered. Recorded before the
// action, because the queue has to be read while the answered card is still in
// it; consumed when the new suggestion list arrives. A `null` id means the
// queue is empty and triage is over.
const triageNextRef = useRef<{ id: string | null } | null>(null)
// Every underline on screen, in document order, with its document position.
// The queue is the decorations rather than the suggestion list: a suggestion
// the editor couldn't anchor has no underline, and a triage stop she cannot
// see is worse than one she never visits.
const orderedSpans = useCallback((): Span[] => {
const wrapper = wrapperRef.current
if (!wrapper || !editor) return []
const spans: Span[] = []
const seen = new Set<string>()
wrapper.querySelectorAll<HTMLElement>('.petal-suggestion[data-suggestion-id]').forEach((el) => {
const id = el.getAttribute('data-suggestion-id')
if (!id || seen.has(id)) return
seen.add(id)
let pos = 0
try {
pos = editor.view.posAtDOM(el, 0)
} catch {
// A node the view no longer owns. Only the caret-relative entry point
// reads `pos`; document order is what walking uses, and that still holds.
}
spans.push({ id, pos })
})
return spans
}, [editor])
// Open a suggestion as a triage stop: scrolled to, emphasized, and focused.
// False means its underline is gone, which is triage's cue to stop.
const openTriageAt = useCallback(
(id: string): boolean => {
const el = wrapperRef.current?.querySelector(
`.petal-suggestion[data-suggestion-id="${CSS.escape(id)}"]`,
) as HTMLElement | null
if (!el) return false
openCardFor(id, el, true)
setActiveId(id)
// The anchored card carries everything the rail card does, so expanding a
// second copy of it in the margin would be the redundancy item 7 refused.
setRailExpandedId(null)
el.scrollIntoView({ block: 'center', behavior: 'smooth' })
return true
},
[openCardFor],
)
// Leave triage and hand the keyboard back to the text, with the caret just
// past the span she was reading about — so the next thing she types continues
// the sentence she was looking at rather than wherever she last clicked.
const exitTriage = useCallback(() => {
const open = hover
closeCard()
setActiveId(null)
if (!editor) return
const range = open ? findRange(editor.state.doc, open.suggestion.original) : null
if (range) editor.chain().focus().setTextSelection(range.to).run()
else editor.commands.focus()
}, [editor, hover, closeCard])
const stepTriage = useCallback(
(dir: Direction) => {
const spans = orderedSpans()
if (spans.length === 0) return
const next = hover
? stepId(
spans.map((s) => s.id),
hover.suggestion.id,
dir,
)
: entryId(spans, editor?.state.selection.from ?? 0, dir)
if (next) openTriageAt(next)
},
[orderedSpans, openTriageAt, hover, editor],
)
// Note the stop after the one being answered, while its underline is still in
// the queue. `answered[0]` is the card she is on; an Accept-all settles a whole
// category at once, and triage resumes after *her* card, not after whichever
// of the batch happened to be last.
const queueTriageAfter = useCallback(
(answered: string[]) => {
const gone = new Set(answered)
const remaining = new Set(suggestions.filter((s) => !gone.has(s.id)).map((s) => s.id))
triageNextRef.current = {
id: idAfterRemoval(
orderedSpans().map((s) => s.id),
answered[0],
remaining,
),
}
},
[orderedSpans, suggestions],
)
// Open the next stop once the answered suggestion has actually left the list
// and the decorations have repainted around the edit. An empty queue ends
// triage the same way Escape does, which is the point at which the document is
// fully triaged and she is back in her text.
useEffect(() => {
const pending = triageNextRef.current
if (!pending) return
triageNextRef.current = null
const { id } = pending
if (!id) {
exitTriage()
return
}
requestAnimationFrame(() => {
if (!openTriageAt(id)) exitTriage()
})
// Driven by the suggestion list alone: the callbacks are rebuilt in the same
// render, so this closure is never the stale one.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [suggestions])
// Accept applies the replacement to the document, plays a little confetti // Accept applies the replacement to the document, plays a little confetti
// burst over the flagged text, then notifies the parent. The confetti is // burst over the flagged text, then notifies the parent. The confetti is
// anchored to the highlight itself (captured before the replacement removes it), // anchored to the highlight itself (captured before the replacement removes it),
@@ -748,6 +895,7 @@ export function EditorCore({
const handleAccept = useCallback( const handleAccept = useCallback(
(s: Suggestion) => { (s: Suggestion) => {
let burst = burstAt(s.id) let burst = burstAt(s.id)
if (hover?.keyboard) queueTriageAfter([s.id])
if (editor && s.replacement.trim() !== '') { if (editor && s.replacement.trim() !== '') {
const range = findRange(editor.state.doc, s.original) const range = findRange(editor.state.doc, s.original)
if (range) { if (range) {
@@ -761,7 +909,7 @@ export function EditorCore({
setRailExpandedId(null) setRailExpandedId(null)
onAccept(s) onAccept(s)
}, },
[editor, onAccept, closeCard, hover, burstAt, showConfetti], [editor, onAccept, closeCard, hover, burstAt, showConfetti, queueTriageAfter],
) )
// How many pending cards of each type could be accepted in one go. A card // How many pending cards of each type could be accepted in one go. A card
@@ -794,6 +942,14 @@ export function EditorCore({
const first = plan.steps[plan.steps.length - 1] const first = plan.steps[plan.steps.length - 1]
const burst = first ? burstAt(first.suggestion.id) : null const burst = first ? burstAt(first.suggestion.id) : null
// Resume after the card she pressed it on, not after the batch's last
// member — the queue she is walking is in document order, and the rest of
// the category may sit anywhere in it.
if (hover?.keyboard) {
const answered = settled.map((s) => s.id)
queueTriageAfter([hover.suggestion.id, ...answered.filter((id) => id !== hover.suggestion.id)])
}
if (plan.steps.length > 0) { if (plan.steps.length > 0) {
let chain = editor.chain().focus() let chain = editor.chain().focus()
for (const step of plan.steps) { for (const step of plan.steps) {
@@ -806,15 +962,16 @@ export function EditorCore({
setRailExpandedId(null) setRailExpandedId(null)
onAcceptMany(settled) onAcceptMany(settled)
}, },
[editor, suggestions, onAcceptMany, closeCard, burstAt, showConfetti], [editor, suggestions, onAcceptMany, closeCard, burstAt, showConfetti, hover, queueTriageAfter],
) )
const handleDismiss = useCallback( const handleDismiss = useCallback(
(s: Suggestion) => { (s: Suggestion) => {
if (hover?.keyboard) queueTriageAfter([s.id])
closeCard() closeCard()
onDismiss(s) onDismiss(s)
}, },
[onDismiss, closeCard], [onDismiss, closeCard, hover, queueTriageAfter],
) )
// openMisspellAt resolves the word at a document position and, if the checker // openMisspellAt resolves the word at a document position and, if the checker
@@ -949,7 +1106,17 @@ export function EditorCore({
// actually knows (a real gloss or definition), so accidental lookups of // actually knows (a real gloss or definition), so accidental lookups of
// typos or proper nouns don't clutter the garden. Looking words up IS // typos or proper nouns don't clutter the garden. Looking words up IS
// the data source; this costs the writer nothing. // the data source; this costs the writer nothing.
const known = !!info.gloss || info.definitions.length > 0 //
// "Knows" has to include the reverse reading, or a word of her own
// language can never be captured at all: the forward lookup of "carro"
// answers with nothing, and everything the card shows her comes out of
// `reverse`. Before Phase 28 that was academic, because every document
// was English; on a Portuguese document it silently emptied the garden
// of every word she actually met. Caught in a browser 2026-07-29 —
// right-clicking "carro" showed a full card and stored nothing.
const rev = info.reverse
const known =
!!info.gloss || info.definitions.length > 0 || !!rev?.gloss || (rev?.definitions?.length ?? 0) > 0
// Reflect the saved state optimistically so the heart shows 💚 the // Reflect the saved state optimistically so the heart shows 💚 the
// moment a known word loads, rather than flashing 🤍 until the capture // moment a known word loads, rather than flashing 🤍 until the capture
// round-trips. vocabId is filled in when recordVocab returns. // round-trips. vocabId is filled in when recordVocab returns.
@@ -959,12 +1126,18 @@ export function EditorCore({
.recordVocab({ .recordVocab({
word: range.word, word: range.word,
gloss: info.gloss, gloss: info.gloss,
definition: info.definitions[0]?.definition ?? '', // `definition` is the English sense the review card falls back to
// when there is no gloss in her language — and for a word that *is*
// her language, the reverse gloss is exactly that: "carro" →
// "car; automobile; machine". The Portuguese monolingual definition
// underneath it explains the word in the language she already knows
// it in, which is not what a flashcard is for.
definition: info.definitions[0]?.definition ?? rev?.gloss ?? '',
// The garden's pronunciation field holds whichever this word has: // The garden's pronunciation field holds whichever this word has:
// IPA for an English word, pinyin for a Chinese one. Both answer // IPA for an English word, pinyin for a Chinese one. Both answer
// the same question on a review card — how do I say this — and a // the same question on a review card — how do I say this — and a
// second column would only be a second thing to keep in sync. // second column would only be a second thing to keep in sync.
phonetic: pinyin || info.phonetic, phonetic: pinyin || info.phonetic || rev?.phonetic || '',
example, example,
doc_id: docId, doc_id: docId,
}) })
@@ -1265,11 +1438,21 @@ export function EditorCore({
// into the editor's content anyway) // into the editor's content anyway)
// Ctrl/Cmd+D — look up the word at the caret (the keyboard "right-click") // Ctrl/Cmd+D — look up the word at the caret (the keyboard "right-click")
// Ctrl/Cmd+J — rewrite the current selection more naturally (✨更自然) // Ctrl/Cmd+J — rewrite the current selection more naturally (✨更自然)
// Ctrl/Cmd+. — walk to the next suggestion (Ctrl/Cmd+, for the previous),
// which is how keyboard triage is entered from the text
useEffect(() => { useEffect(() => {
if (!editor) return if (!editor) return
const onKey = (e: KeyboardEvent) => { const onKey = (e: KeyboardEvent) => {
if (!(e.ctrlKey || e.metaKey) || e.altKey) return if (!(e.ctrlKey || e.metaKey) || e.altKey) return
const k = e.key.toLowerCase() const k = e.key.toLowerCase()
if (k === '.' || k === ',') {
// Chinese IMEs use , and . to page their candidate window. While one is
// open the keystroke belongs to the composition, not to Petal.
if (fromIME(e)) return
e.preventDefault()
stepTriage(k === '.' ? 1 : -1)
return
}
if (k === 'f') { if (k === 'f') {
e.preventDefault() e.preventDefault()
setFindOpen(true) setFindOpen(true)
@@ -1285,7 +1468,7 @@ export function EditorCore({
} }
window.addEventListener('keydown', onKey) window.addEventListener('keydown', onKey)
return () => window.removeEventListener('keydown', onKey) return () => window.removeEventListener('keydown', onKey)
}, [editor, openWordLookup, handleRewrite]) }, [editor, openWordLookup, handleRewrite, stepTriage])
useEffect(() => () => { useEffect(() => () => {
clearTimeout(closeTimer.current) clearTimeout(closeTimer.current)
@@ -1373,8 +1556,10 @@ export function EditorCore({
<SelectionBubble <SelectionBubble
style={{ top: selection.top, left: selection.left, transform: 'translateY(calc(-100% - 8px))' }} style={{ top: selection.top, left: selection.left, transform: 'translateY(calc(-100% - 8px))' }}
onRewrite={handleRewrite} onRewrite={handleRewrite}
onSpeak={speechSupported() ? () => speak(selection.text) : null} onSpeak={speechSupported() ? () => speak(selection.text, docLocale(selection.text, docLang)) : null}
onSpeakSlow={speechSupported() ? () => speak(selection.text, undefined, true) : null} onSpeakSlow={
speechSupported() ? () => speak(selection.text, docLocale(selection.text, docLang), true) : null
}
/> />
)} )}
{rewrite && ( {rewrite && (
@@ -1422,6 +1607,9 @@ export function EditorCore({
onPointerLeave={scheduleClose} onPointerLeave={scheduleClose}
onExpandChange={setPinned} onExpandChange={setPinned}
onExtent={setCardExtent} onExtent={setCardExtent}
keyboard={hover.keyboard}
onStep={stepTriage}
onExit={exitTriage}
/> />
)} )}
{railEnabled && railItems.length > 0 && ( {railEnabled && railItems.length > 0 && (
+83 -2
View File
@@ -1,8 +1,10 @@
import { useEffect, useRef, useState } from 'react' import { useEffect, useRef, useState } from 'react'
import type { Suggestion, SuggestionType } from '../../api/client' import type { Suggestion, SuggestionType } from '../../api/client'
import { usePack } from '../../i18n' import { usePack } from '../../i18n'
import { fromIME } from '../../lib/ime'
import { AskPetal } from './AskPetal' import { AskPetal } from './AskPetal'
import { TYPE_META, batchLabel, typeLabel } from './suggestionMeta' import { TYPE_META, batchLabel, typeLabel } from './suggestionMeta'
import type { Direction } from './triage'
interface Props { interface Props {
suggestion: Suggestion suggestion: Suggestion
@@ -25,6 +27,13 @@ interface Props {
// under it and its Accept button simply can't be reached. 0 means "nothing to // under it and its Accept button simply can't be reached. 0 means "nothing to
// cover", which is what an unmounted card reports on its way out. // cover", which is what an unmounted card reports on its way out.
onExtent?: (bottom: number) => void onExtent?: (bottom: number) => void
// Keyboard triage (item 8). The card was opened by a keystroke rather than a
// pointer, so it takes focus and answers the keys itself: the writer is
// walking the underlines and never touches the mouse.
keyboard?: boolean
// Move to the next/previous underline, and leave triage entirely.
onStep?: (dir: Direction) => void
onExit?: () => void
} }
// SuggestionCard is the hover panel for a single suggestion: a colored type tag, // SuggestionCard is the hover panel for a single suggestion: a colored type tag,
@@ -42,6 +51,9 @@ export function SuggestionCard({
onPointerLeave, onPointerLeave,
onExpandChange, onExpandChange,
onExtent, onExtent,
keyboard = false,
onStep,
onExit,
}: Props) { }: Props) {
const pack = usePack() const pack = usePack()
const meta = TYPE_META[suggestion.type] const meta = TYPE_META[suggestion.type]
@@ -67,6 +79,60 @@ export function SuggestionCard({
} }
}, [onExtent]) }, [onExtent])
// In triage the card is where the keys land, so it has to hold focus — and it
// has to re-take it on every step, because stepping keeps this same component
// mounted and only swaps the suggestion inside it. `preventScroll` for the
// reason AskPetal's input gives: the card is an absolutely-positioned overlay,
// and letting the browser "reveal" it would jump the document out from under
// the sentence she is reading. The span is scrolled to deliberately elsewhere.
useEffect(() => {
if (keyboard) cardRef.current?.focus({ preventScroll: true })
}, [keyboard, suggestion.id])
// The triage keys. Only bound in keyboard mode: a card opened by the pointer
// never holds focus, and stealing Tab from one that somehow did would break
// ordinary focus movement for no gain.
function handleKeyDown(e: React.KeyboardEvent) {
if (!keyboard || fromIME(e)) return
const target = e.target as HTMLElement
// Ask Petal's question field is a text input inside this card. While it has
// focus it owns every key it can use — Tab, Enter and the letters are hers
// to type — and only Escape is taken, to step back out to the card.
const typing = target instanceof HTMLInputElement || target instanceof HTMLTextAreaElement
if (e.key === 'Escape') {
e.preventDefault()
// Escape also leaves distraction-free mode (App's window listener), which
// would restore the sidebar and pull the rail out from under her mid-
// triage. In triage this key means "this card", or "triage" — never "the
// writing mode".
e.stopPropagation()
if (typing) cardRef.current?.focus({ preventScroll: true })
else onExit?.()
return
}
if (typing) return
if (e.key === 'Tab') {
e.preventDefault()
onStep?.(e.shiftKey ? -1 : 1)
} else if (e.key === 'Enter') {
// An awareness-only card has nothing to accept; Enter on it does nothing
// rather than quietly meaning something else.
if (!hasReplacement) return
e.preventDefault()
onAccept(suggestion)
} else if (e.key === 'Delete' || e.key === 'Backspace') {
e.preventDefault()
onDismiss(suggestion)
} else if (e.key === '?') {
e.preventDefault()
// Opening hands focus to the panel's own input; Escape there comes back
// here, and ? then closes it again.
toggleAsking()
}
}
function toggleAsking() { function toggleAsking() {
setAsking((prev) => { setAsking((prev) => {
const next = !prev const next = !prev
@@ -80,13 +146,18 @@ export function SuggestionCard({
ref={cardRef} ref={cardRef}
role="dialog" role="dialog"
aria-label={`${label} suggestion`} aria-label={`${label} suggestion`}
tabIndex={keyboard ? -1 : undefined}
onKeyDown={handleKeyDown}
onMouseEnter={onPointerEnter} onMouseEnter={onPointerEnter}
onMouseLeave={onPointerLeave} onMouseLeave={onPointerLeave}
className="petal-suggestion-card absolute z-20 p-3.5 text-sm" className="petal-suggestion-card absolute z-20 p-3.5 text-sm focus:outline-none"
style={{ style={{
width: asking ? 340 : 300, width: asking ? 340 : 300,
background: 'var(--color-surface)', background: 'var(--color-surface)',
border: '1px solid var(--color-border)', // In triage the card is the only thing holding focus, and the writer has
// no pointer under it to say so. The accent border is that answer — the
// browser's own focus ring on a 300px panel reads as an error state.
border: `1px solid ${keyboard ? 'var(--color-accent)' : 'var(--color-border)'}`,
borderRadius: 'var(--radius-card)', borderRadius: 'var(--radius-card)',
boxShadow: 'var(--shadow-soft)', boxShadow: 'var(--shadow-soft)',
...style, ...style,
@@ -161,6 +232,16 @@ export function SuggestionCard({
{batchLabel(suggestion.type, batchCount)} {batchLabel(suggestion.type, batchCount)}
</button> </button>
)} )}
{keyboard && (
<div
className="mt-2.5 border-t pt-2 text-[0.65rem] leading-tight"
style={{ borderColor: 'var(--color-border)', color: 'var(--color-muted)' }}
>
<div>{pack.editor.triageHint.native}</div>
<div>{pack.editor.triageHint.en}</div>
</div>
)}
</div> </div>
) )
} }
+98
View File
@@ -0,0 +1,98 @@
import { describe, expect, it } from 'vitest'
import { entryId, idAfterRemoval, stepId, type Span } from './triage'
const spans = (...pairs: [string, number][]): Span[] => pairs.map(([id, pos]) => ({ id, pos }))
describe('stepId — walking the queue', () => {
const order = ['a', 'b', 'c']
it('moves forward and backward', () => {
expect(stepId(order, 'a', 1)).toBe('b')
expect(stepId(order, 'c', -1)).toBe('b')
})
it('wraps at both ends, so the queue is a ring and never a dead end', () => {
expect(stepId(order, 'c', 1)).toBe('a')
expect(stepId(order, 'a', -1)).toBe('c')
})
it('enters at the near end when there is no current card', () => {
expect(stepId(order, null, 1)).toBe('a')
expect(stepId(order, null, -1)).toBe('c')
})
it('treats a card that has left the queue as no card at all', () => {
// She accepted from the rail while a triage card was open, or an edit
// dissolved the span. Stepping should still land somewhere real.
expect(stepId(order, 'gone', 1)).toBe('a')
})
it('has nowhere to go in an empty queue', () => {
expect(stepId([], null, 1)).toBeNull()
expect(stepId([], 'a', -1)).toBeNull()
})
it('stays put on a queue of one', () => {
expect(stepId(['only'], 'only', 1)).toBe('only')
expect(stepId(['only'], 'only', -1)).toBe('only')
})
})
describe('entryId — where triage starts from the caret', () => {
const order = spans(['a', 10], ['b', 40], ['c', 90])
it('goes forward to the first underline at or after the caret', () => {
expect(entryId(order, 0, 1)).toBe('a')
expect(entryId(order, 11, 1)).toBe('b')
expect(entryId(order, 40, 1)).toBe('b') // caret sitting on the span itself
})
it('goes back to the last underline at or before the caret', () => {
expect(entryId(order, 100, -1)).toBe('c')
expect(entryId(order, 39, -1)).toBe('a')
expect(entryId(order, 40, -1)).toBe('b')
})
it('wraps rather than refusing when the caret is past every span', () => {
expect(entryId(order, 500, 1)).toBe('a')
expect(entryId(order, 0, -1)).toBe('c')
})
it('has nowhere to enter in an empty document', () => {
expect(entryId([], 0, 1)).toBeNull()
})
})
describe('idAfterRemoval — where the answered card hands over to', () => {
const order = ['a', 'b', 'c', 'd']
it('carries on with the next one still standing', () => {
expect(idAfterRemoval(order, 'b', new Set(['a', 'c', 'd']))).toBe('c')
})
it('skips everything an Accept-all took with it', () => {
// Accept all of a category: b, c and d go together, so triage resumes at
// the only survivor rather than at a card that no longer exists.
expect(idAfterRemoval(order, 'b', new Set(['a']))).toBe('a')
})
it('wraps to the front when the answered card was last', () => {
expect(idAfterRemoval(order, 'd', new Set(['a', 'b', 'c']))).toBe('a')
})
it('ends triage when nothing is left', () => {
expect(idAfterRemoval(order, 'b', new Set())).toBeNull()
})
it('never hands back the card that was just answered', () => {
// The server may still be reporting it for a moment; the writer has already
// said what she thinks of it.
expect(idAfterRemoval(order, 'b', new Set(['b']))).toBeNull()
})
it('falls back to the whole queue when the answered card was never in it', () => {
// A provisional rule-pack card can be answered before its underline has been
// painted (item 3b's 250ms pass). There is still a queue to carry on with.
expect(idAfterRemoval(order, 'unlisted', new Set(['c', 'd']))).toBe('c')
})
})
+62
View File
@@ -0,0 +1,62 @@
// Keyboard triage: walking the underlines without a mouse.
//
// The queue is the underlines themselves, in document order — not the
// suggestion list. A suggestion whose span the editor couldn't anchor has no
// underline, and a triage stop she cannot see is worse than one she never
// visits. Reading the order off the decoration DOM also means the queue is
// exactly what is on screen, which is the thing she is being asked to walk.
//
// Everything here is pure and takes the order as an argument, so the arithmetic
// (wrap-around, entry from the caret, where to land after a card is answered)
// can be tested without a ProseMirror document or a layout.
export type Direction = 1 | -1
// A span in the queue: its suggestion id and where it sits in the document.
export interface Span {
id: string
pos: number
}
// The next stop from `current`, wrapping at both ends. A `current` that is no
// longer in the queue (or absent) enters at whichever end the direction implies,
// so the first press of "next" lands on the first underline and "previous" on
// the last.
export function stepId(order: readonly string[], current: string | null, dir: Direction): string | null {
if (order.length === 0) return null
const at = current === null ? -1 : order.indexOf(current)
if (at === -1) return dir === 1 ? order[0] : order[order.length - 1]
return order[(at + dir + order.length) % order.length]
}
// Where triage starts when it is entered from the editor rather than continued:
// the nearest underline in the direction she asked for, measured from the caret,
// so she picks up from where she is reading rather than being thrown to the top
// of a document she has scrolled halfway down. Wraps around when the caret is
// past them all, which is the same wrap `stepId` gives once she is walking.
export function entryId(order: readonly Span[], caret: number, dir: Direction): string | null {
if (order.length === 0) return null
if (dir === 1) {
for (const span of order) if (span.pos >= caret) return span.id
return order[0].id
}
for (let i = order.length - 1; i >= 0; i--) if (order[i].pos <= caret) return order[i].id
return order[order.length - 1].id
}
// Where to land after the card she was on is answered (accepted, dismissed, or
// swept up by an Accept-all). The order is the one read *before* the action, so
// "the next one" means the next in the queue she was walking; `remaining` is
// what actually survived. Falls back to scanning forward and then round to the
// front, and returns null when nothing is left — which is triage finishing, not
// an error.
export function idAfterRemoval(
order: readonly string[],
answered: string,
remaining: ReadonlySet<string>,
): string | null {
const at = order.indexOf(answered)
const rest = at === -1 ? order : [...order.slice(at + 1), ...order.slice(0, at)]
for (const id of rest) if (id !== answered && remaining.has(id)) return id
return null
}
+19 -5
View File
@@ -1,6 +1,6 @@
import { useCallback, useEffect, useMemo, useState } from 'react' import { useCallback, useEffect, useMemo, useState } from 'react'
import { api, type VocabGrade, type VocabWord } from '../../api/client' import { api, type VocabGrade, type VocabWord } from '../../api/client'
import { speak, speechSupported, stopSpeech } from '../../audio/speech' import { docLang as docLocale, speak, speechSupported, stopSpeech } from '../../audio/speech'
import { useFocusTrap } from '../../hooks/useFocusTrap' import { useFocusTrap } from '../../hooks/useFocusTrap'
import { usePack, type Line } from '../../i18n' import { usePack, type Line } from '../../i18n'
import { JournalView } from './JournalView' import { JournalView } from './JournalView'
@@ -314,7 +314,21 @@ function GardenView({
{blossom(w.reps)} {blossom(w.reps)}
</span> </span>
<span className="flex min-w-0 flex-1 flex-col"> <span className="flex min-w-0 flex-1 flex-col">
<span className="truncate text-sm font-bold text-plum">{w.word}</span> <span className="flex min-w-0 items-center gap-1.5">
<span className="truncate text-sm font-bold text-plum">{w.word}</span>
{/* Only a card in her own language is marked. An English
garden with a badge on every blossom would be a
garden with no badges at all; the marker exists so
the rare Portuguese word is legible among them. */}
{w.lang === 'pair' && (
<span
className="shrink-0 rounded-full px-1.5 py-0.5 text-[9px] font-bold lowercase"
style={{ background: 'var(--color-surface-alt)', color: 'var(--color-muted)' }}
>
{t.nativeName}
</span>
)}
</span>
{(w.gloss || w.definition) && ( {(w.gloss || w.definition) && (
<span <span
className="truncate text-xs" className="truncate text-xs"
@@ -354,7 +368,7 @@ function GardenView({
{speechSupported() && ( {speechSupported() && (
<button <button
type="button" type="button"
onClick={() => speak(w.word)} onClick={() => speak(w.word, docLocale(w.word, w.lang))}
className="rounded-full px-2.5 py-1 text-xs font-semibold" className="rounded-full px-2.5 py-1 text-xs font-semibold"
style={{ background: 'var(--color-surface-alt)' }} style={{ background: 'var(--color-surface-alt)' }}
> >
@@ -477,7 +491,7 @@ function ReviewSession({
<> <>
<button <button
type="button" type="button"
onClick={() => speak(card.word)} onClick={() => speak(card.word, docLocale(card.word, card.lang))}
aria-label={`Pronounce ${card.word}`} aria-label={`Pronounce ${card.word}`}
className="flex h-6 w-6 items-center justify-center rounded-full text-xs" className="flex h-6 w-6 items-center justify-center rounded-full text-xs"
style={{ background: 'var(--color-surface)' }} style={{ background: 'var(--color-surface)' }}
@@ -488,7 +502,7 @@ function ReviewSession({
worth hearing stretched out. */} worth hearing stretched out. */}
<button <button
type="button" type="button"
onClick={() => speak(card.word, undefined, true)} onClick={() => speak(card.word, docLocale(card.word, card.lang), true)}
aria-label={`Pronounce ${card.word} slowly`} aria-label={`Pronounce ${card.word} slowly`}
title={t.garden.readSlowly} title={t.garden.readSlowly}
className="flex h-6 w-6 items-center justify-center rounded-full text-xs" className="flex h-6 w-6 items-center justify-center rounded-full text-xs"
+16 -3
View File
@@ -1,5 +1,5 @@
import { useCallback, useEffect, useRef, useState } from 'react' import { useCallback, useEffect, useRef, useState } from 'react'
import { api, UnauthorizedError, type DocUpdate } from '../api/client' import { api, UnauthorizedError, type Document, type DocUpdate } from '../api/client'
import { clearDraft, stashDraft } from '../lib/drafts' import { clearDraft, stashDraft } from '../lib/drafts'
export type SaveStatus = 'idle' | 'pending' | 'saving' | 'saved' | 'error' | 'signed-out' export type SaveStatus = 'idle' | 'pending' | 'saving' | 'saved' | 'error' | 'signed-out'
@@ -10,7 +10,14 @@ const SAVED_FADE_MS = 3000
// useAutoSave debounces document saves. Call schedule() on every edit; it fires // useAutoSave debounces document saves. Call schedule() on every edit; it fires
// PUT /api/docs/:id 1.5s after the last change. status drives the StatusBar: // PUT /api/docs/:id 1.5s after the last change. status drives the StatusBar:
// pending → saving → saved (fades to idle after 3s). // pending → saving → saved (fades to idle after 3s).
export function useAutoSave(docId: string | null) { //
// `onSaved` receives the row the server wrote back. The save response used to be
// discarded, which was fine while every field in it was one the client had just
// sent — and stopped being fine when `doc_lang` arrived, a field only the server
// can decide. Without this the verdict reached the editor on open and never
// again, so a document that turned Portuguese while she typed went on being read
// aloud in English until the next reload.
export function useAutoSave(docId: string | null, onSaved?: (doc: Document) => void) {
const [status, setStatus] = useState<SaveStatus>('idle') const [status, setStatus] = useState<SaveStatus>('idle')
const debounceRef = useRef<ReturnType<typeof setTimeout>>(undefined) const debounceRef = useRef<ReturnType<typeof setTimeout>>(undefined)
@@ -25,6 +32,11 @@ export function useAutoSave(docId: string | null) {
// the loop stops here and the writing waits in localStorage instead. // the loop stops here and the writing waits in localStorage instead.
const signedOutRef = useRef(false) const signedOutRef = useRef(false)
// Read through a ref so a caller passing an inline arrow doesn't have to
// memoize it to keep flush stable.
const onSavedRef = useRef(onSaved)
onSavedRef.current = onSaved
const flush = useCallback(async () => { const flush = useCallback(async () => {
const id = docIdRef.current const id = docIdRef.current
const body = pendingRef.current const body = pendingRef.current
@@ -39,7 +51,8 @@ export function useAutoSave(docId: string | null) {
setStatus('saving') setStatus('saving')
try { try {
await api.updateDoc(id, body) const saved = await api.updateDoc(id, body)
onSavedRef.current?.(saved)
clearDraft(id) // it's on the server now; the rescue copy is redundant clearDraft(id) // it's on the server now; the rescue copy is redundant
setStatus('saved') setStatus('saved')
clearTimeout(fadeRef.current) clearTimeout(fadeRef.current)
+21
View File
@@ -203,6 +203,22 @@ describe('the zh pack', () => {
expect(p.status.petalsToPolish(2).en).toContain('petals to polish') expect(p.status.petalsToPolish(2).en).toContain('petals to polish')
}) })
// The triage legend is the only place a Petal binding is written down, so a
// pack that drops a key drops the feature for that pair: nothing else on
// screen says Tab moves to the next underline. The key caps themselves stay
// as they are printed on the keyboard, which is why the English half is not
// the interesting one — a pack may well translate "Entrée" and be right to.
it.each(PACKS)('names every triage key in both halves ($code)', (p) => {
const { native, en } = p.editor.triageHint
expect(native, `${p.code} has no pair-language triage legend`).toBeTruthy()
expect(en, `${p.code} has no English triage legend`).toBeTruthy()
expect(en).toBe('Tab next · Enter accept · Del dismiss · ? Ask Petal · Esc exit')
// Five bindings, five entries — in whatever the pack calls the keys.
expect(native.split('·'), `${p.code} lists the wrong number of keys`).toHaveLength(5)
expect(native, `${p.code} loses the Tab key`).toContain('Tab')
expect(native, `${p.code} loses the Ask Petal key`).toContain('?')
})
it.each(PACKS)('labels every companion, tone and style ($code)', async (p) => { it.each(PACKS)('labels every companion, tone and style ($code)', async (p) => {
const { COMPANIONS } = await import('../components/Companion/companions') const { COMPANIONS } = await import('../components/Companion/companions')
for (const c of COMPANIONS) { for (const c of COMPANIONS) {
@@ -414,7 +430,12 @@ describe('the es pack', () => {
} }
walk(es, '') walk(es, '')
expect(lines.length).toBeGreaterThan(40) expect(lines.length).toBeGreaterThan(40)
// The one exemption, and it is not a question: `triageHint` is a legend of
// key caps, and its "?" is the key she presses to ask Petal — the same
// literal printed on the keyboard, no more Spanish punctuation than "Esc".
const keyCaps = new Set(['editor.triageHint'])
for (const { native, where } of lines) { for (const { native, where } of lines) {
if (keyCaps.has(where)) continue
if (native.includes('?')) expect(native, `${where} closes ? without ¿`).toContain('¿') if (native.includes('?')) expect(native, `${where} closes ? without ¿`).toContain('¿')
if (native.includes('!')) expect(native, `${where} closes ! without ¡`).toContain('¡') if (native.includes('!')) expect(native, `${where} closes ! without ¡`).toContain('¡')
} }
+5
View File
@@ -374,6 +374,11 @@ export const es: Pack = {
replace: 'Reemplazar', replace: 'Reemplazar',
replaceAll: 'Todo', replaceAll: 'Todo',
translateLabel: 'Traducción · Translate', translateLabel: 'Traducción · Translate',
// Intro, Supr, Esc — as they are printed on a Spanish keyboard.
triageHint: {
native: 'Tab siguiente · Intro aceptar · Supr descartar · ? preguntar a Petal · Esc salir',
en: 'Tab next · Enter accept · Del dismiss · ? Ask Petal · Esc exit',
},
spelling: 'Ortografía · Spelling', spelling: 'Ortografía · Spelling',
noSuggestions: 'Sin sugerencias · No suggestions', noSuggestions: 'Sin sugerencias · No suggestions',
addToDictionary: 'Agregar al diccionario · Add to dictionary', addToDictionary: 'Agregar al diccionario · Add to dictionary',
+7
View File
@@ -316,6 +316,13 @@ export const fr: Pack = {
replace: 'Remplacer', replace: 'Remplacer',
replaceAll: 'Tout', replaceAll: 'Tout',
translateLabel: 'Traduction · Translate', translateLabel: 'Traduction · Translate',
// The key names are the ones printed on a French keyboard — Entrée, Suppr,
// Échap — not their English equivalents. A legend she has to translate back
// to find the key is not a legend.
triageHint: {
native: 'Tab suivant · Entrée accepter · Suppr ignorer · ? demander à Petal · Échap quitter',
en: 'Tab next · Enter accept · Del dismiss · ? Ask Petal · Esc exit',
},
spelling: 'Orthographe · Spelling', spelling: 'Orthographe · Spelling',
noSuggestions: 'Aucune suggestion · No suggestions', noSuggestions: 'Aucune suggestion · No suggestions',
addToDictionary: 'Ajouter au dictionnaire · Add to dictionary', addToDictionary: 'Ajouter au dictionnaire · Add to dictionary',
+4
View File
@@ -294,6 +294,10 @@ export const ptPT: Pack = {
replace: 'Substituir', replace: 'Substituir',
replaceAll: 'Tudo', replaceAll: 'Tudo',
translateLabel: 'Tradução · Translate', translateLabel: 'Tradução · Translate',
triageHint: {
native: 'Tab seguinte · Enter aceitar · Del ignorar · ? perguntar à Petal · Esc sair',
en: 'Tab next · Enter accept · Del dismiss · ? Ask Petal · Esc exit',
},
spelling: 'Ortografia · Spelling', spelling: 'Ortografia · Spelling',
noSuggestions: 'Sem sugestões · No suggestions', noSuggestions: 'Sem sugestões · No suggestions',
addToDictionary: 'Adicionar ao dicionário · Add to dictionary', addToDictionary: 'Adicionar ao dicionário · Add to dictionary',
+4
View File
@@ -205,6 +205,10 @@ export const zh: Pack = {
replace: '替换', replace: '替换',
replaceAll: '全部', replaceAll: '全部',
translateLabel: '翻译 · Translate', translateLabel: '翻译 · Translate',
triageHint: {
native: 'Tab 下一处 · Enter 采纳 · Del 忽略 · ? 问问 Petal · Esc 退出',
en: 'Tab next · Enter accept · Del dismiss · ? Ask Petal · Esc exit',
},
spelling: '拼写 · Spelling', spelling: '拼写 · Spelling',
noSuggestions: '没有建议 · No suggestions', noSuggestions: '没有建议 · No suggestions',
addToDictionary: '添加到词典 · Add to dictionary', addToDictionary: '添加到词典 · Add to dictionary',
+7
View File
@@ -195,6 +195,13 @@ export interface Pack {
// opposite case — its whole subject is her own language — so it says so in // opposite case — its whole subject is her own language — so it says so in
// her language first. The pack holds the rendered string, separator and all. // her language first. The pack holds the rendered string, separator and all.
translateLabel: string translateLabel: string
// The key map shown along the bottom of a card opened by keyboard triage.
// Unlike the card's buttons — Accept, Dismiss, Ask Petal, which stay English
// because they name the thing she is learning to talk about — this is an
// instruction for using Petal, so it is bilingual like the status bar. The
// key names themselves (Tab, Enter, Esc) are what is printed on her
// keyboard, so they don't translate.
triageHint: Line
spelling: string spelling: string
noSuggestions: string noSuggestions: string
addToDictionary: string addToDictionary: string
+6 -1
View File
@@ -422,10 +422,15 @@ button, a, input {
/* --- Companion kitten ------------------------------------------------------- /* --- Companion kitten -------------------------------------------------------
The cozy corner mascot. Gently bobs while awake, settles and sways slowly The cozy corner mascot. Gently bobs while awake, settles and sways slowly
while napping; its speech bubble pops in; little zzz drift up when asleep. */ while napping; its speech bubble pops in; little zzz drift up when asleep. */
.petal-companion { /* The whole corner, badge and bubble and measuring probe alike. The size lives
here rather than on the badge so the probe a sibling, not a child is sized
from the same number the mascot is. */
.petal-corner {
/* Mascot size scales with the viewport width: ~original on a laptop, up to /* Mascot size scales with the viewport width: ~original on a laptop, up to
~2× on a large desktop. Tune the middle (vw) term to taste. */ ~2× on a large desktop. Tune the middle (vw) term to taste. */
--petal-companion-size: clamp(9rem, 15.3vw, 18rem); --petal-companion-size: clamp(9rem, 15.3vw, 18rem);
}
.petal-companion {
animation: petal-bob 3.2s ease-in-out infinite; animation: petal-bob 3.2s ease-in-out infinite;
/* Shrink toward its corner when fading out of a card's way. `scale` is a /* Shrink toward its corner when fading out of a card's way. `scale` is a
separate property from `transform` so it composes with the bob keyframes. */ separate property from `transform` so it composes with the bob keyframes. */