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
This commit is contained in:
prosolis
2026-07-28 23:20:53 -07:00
parent db9cfb7abf
commit 76dede8856
12 changed files with 836 additions and 31 deletions
+30
View File
@@ -423,6 +423,36 @@ Phase 26's own outstanding item, and the thing it named as most likely to be wro
- ⚠️ **What this still does not prove is the counterfactual** — that the bug would have bitten *here* without the guard. Chrome's own composition handling is robust, and a clean run with the guard in place cannot distinguish "held correctly" from "would have been fine anyway". Settling it means building with the guard reverted and asking her to type once more; offered, not assumed.
- **Not deployed.** No migration; a rebuild whenever the user wants it, along with Phases 2426.
### Phase 28 — following the writing into her own language (planned 2026-07-28; step (a) built 2026-07-28)
Today every correction and every explanation comes back in English, whatever she wrote. `CheckpointMessages` (`internal/llm/prompts.go:49`) takes the text and the tone and **nothing else** — there is no language parameter to pass, so there has never been a language decision to get wrong. The pair language reaches her only on demand: tapping Ask Petal fetches a translation of the English explanation (`AskPetal.tsx:93`), and that panel answers in her language because `AskPetalSystemPrompt` is given one. Right default for a writer practising English; wrong default for a document written in Portuguese, where Petal reads the Portuguese prose, says nothing about it, and files a mechanics note about the one English sentence at the end. **Observed on the live build 2026-07-28**: two pt-PT sentences drew no cards at all.
1. [x] **The rule is two decisions, not one, and they read different state.** What gets *corrected* follows the **document** — Portuguese prose gets Portuguese corrections, which is the whole point. What language the explanation is *written in* follows the **writer**: the half of her pair she is not learning, from the `direction` column (`internal/auth/users.go:96`), because an explanation is teaching and teaching lands in the language she reads most easily. So a native Portuguese speaker practising English, writing Portuguese, gets Portuguese corrections explained in Portuguese; a native English speaker learning French, writing French, gets French corrections explained in English. Neither is trapped — the other language stays one tap away, in both directions.
- **What the direction lookup costs today is nothing.** `learnerPairs` is `{"zh"}` (`users.go:119`), so fr, es and pt-PT accounts are all `learning_en` and their non-learned half *is* the pair language: the rule produces exactly "explain in the document's language" for every writer who exists right now. It is there to stop that from being baked into the prompts, the way "English is the language being learned" was baked into `pair_lang` before migration 0016 — the same mistake this plan's own Phase 26 had to unpick.
2. [x] **A document-level verdict, beside the span-level one already in `internal/suggestions/language.go`.** `readsAsPairLang` answers "is this quoted span her language" for labelling one card; it under-claims on purpose and trips at two marker words. A whole document needs a **proportion, not a presence**, or one Portuguese quotation inside an English essay flips the entire pass. Three properties, in the order they bite:
- **Decided over the whole document, never a chunk.** A chunked pass sends only the sentences that changed (`splitChunks`/`changedChunks`); computing the verdict from `askText` would put an English card in a Portuguese journal the moment she edits its one English line. Compute from `contentText`, always.
- **Hysteresis, for the same reason the mascot needed it** (see the session log below, 2026-07-28). A bilingual paragraph sits near whatever threshold we pick, and a document crossing it every few keystrokes would alternate card languages between passes. Flip to the pair at ≥70% pair-language sentences, back only below 40%. The band is the feature.
- **Plain code, no model call** — the house rule that the LLM is garnish, never a gatekeeper. zh is a rune-script count (already written); pt-PT, fr and es reuse `latinMarkers`, aggregated per sentence rather than per span.
3. [x] **`checkpointSystemPrompt` cannot simply take a language.** It opens with *"helping someone who speaks English as a second language"* and asks for ESL patterns — appending "explain in Portuguese" hands the model two contradictory instructions. Split it: shared JSON contract and tone clause, framing sentence filled per direction. `CheckpointMessages` and `VoiceMessages` (which has no language at all today) take **two** language arguments — corrected and explained-in — and **resist collapsing them into one `Lang` while every current account has them equal**: that equality is a fact about today's `learnerPairs`, not about the design.
4. [x] **The verdict rides with `pairLang` in the row-scoped lookup and folds into the chunk salt** next to `tone` (`handlers.go`). That is the cheap correct answer to stale cards: when a document's language flips, every sentence's identity changes, so old-language cards are re-checked rather than left sitting there in the wrong language.
5. [ ] **`isTranslation` learns which way it points.** It currently means "her language rendered into English"; in a flipped document the useful translate card is the mirror image, so the test takes the document verdict and checks the direction that matches.
6. [ ] **The tap-through has to stop assuming its direction, and this is not optional dressing** — it is what makes rule 1 safe for a learner reading explanations in English. `/suggestions/{id}/translate` always renders into the pair language today; it should render into whichever half the explanation is *not* already in, and skip the seed entirely when those coincide rather than round-tripping Portuguese into Portuguese.
- **`internal/llm/lang.go` already carries the precision the model needs** (`"European Portuguese (pt-PT, never Brazilian Portuguese)"`). Spanish wants the same care pointed the other way: the shipped dictionary deliberately accepts the whole Spanish-speaking world (Phase 25), so the prompt must not quietly impose peninsular usage.
- **Spellcheck must not change.** "A word is a misspelling only when *both* dictionaries reject it" (`useSpellChecker.ts:13`) is a deliberate refusal to detect document language, and it is right: it makes English quotations inside Portuguese prose free, in both directions. Teaching it a document verdict buys nothing and costs a false positive on every borrowed word.
- Tests worth writing first: doc-level detection per pair (monolingual, 80/20, 50/50, quotation-heavy English), asserting the hysteresis band **from both directions**; the flipped prompt names the target language and drops the ESL framing while the English-document prompt stays **byte-identical** to today's (the path every existing user is on); the two language arguments proven independent by the only pair that can exercise it — a `learning_pair` zh account writing Chinese wants Chinese corrections explained in English, a `learning_en` zh account writing Chinese wants both in Chinese; a handler test in the shape of `pairlang_test.go`; and a language flip invalidating checked chunks.
- **Open, and needs a decision before the last step.** The **vocabulary garden** harvests phrases from documents, so a Portuguese document would seed it with Portuguese — tag entries by language and filter by the half being learned, or gate harvesting to English documents? Tagging looks right and touches stored rows. **Read-aloud** should follow the document too (all five voices now run on the VPS), which is probably small and lands where the verdict lands, but has not been traced.
- **Order:** (a) detection + salt + checkpoint/voice prompts — the whole visible win, independently shippable; (b) translate-card direction, Ask Petal seed, `/translate` direction; (c) garden and read-aloud, once the two questions above are answered.
**Step (a) as built, 2026-07-28** — items 14. Step (b) and (c) are untouched, and the two open questions under (c) are still open.
- `internal/llm/target.go``Target{Correct, Explain, Pair}` + `English` (a `Lang` the `langs` map has no business holding: that map answers "which half is hers"). `EnglishTarget(pair)` is the pre-phase behaviour named, and `Flipped()` is the one question the prompts ask. **Three fields, not two**: the collocation coach's parenthetical gloss is addressed to *her* and not to the document, so it reads `Pair` — collapsing it into `Explain` would have silently moved that gloss into English on every English document, which is every document today.
- `internal/suggestions/doclang.go``documentLang(contentText, pairLang, prev)``"en"`/`"pair"`. Per **sentence** (reusing `splitChunks`, salt `""`), three-way: pair / English / **no answer**. The third value is the load-bearing one — a sentence with no evidence ("Bom dia.", a heading) is left out of the ratio rather than counted for the language it isn't, and counting the undecided as English is exactly what would hold a Portuguese journal of short sentences in English forever. So the Latin pairs needed an `englishMarkers` list curated against pt/fr/es with the same care `latinMarkers` was curated against English (no "on"/"son"/"as"/"no"/"para" — each a false English vote inside someone's own language). Band as specified, ≥70% / <40%, plus a **corroboration floor** the plan didn't call for: 3 distinct pair markers (8 Han runes) document-wide, because a ratio computed over "Não. Eu." is 100% of nothing and a flip rewrites every card in the document.
- **Migration `0017_document_lang`** — `documents.doc_lang` (`'' | 'en' | 'pair'`). Stored, not recomputed, because hysteresis needs a yesterday; `'pair'` rather than a language code, so changing her pair re-reads her documents instead of stranding a stale language name on all of them. Backfill is the default `''`, which reads as English — true of every document that exists.
- `runPass` reads `doc_lang` + `direction` in the row-scoped lookup that already proves ownership, writes the verdict back only when it changed, folds it into the chunk salt beside `tone`, and hands `targetFor(pairLang, direction, docLang)` to the pass. `pass` now takes an `llm.Target`.
- Prompts: `pairCheckpointSystemPrompt` / `pairVoiceSystemPrompt` beside the originals — separate constants, not a clause, since the English ones open by naming the reader an ESL learner. Both carry a **"never translate it into English"** line, which is the instruction the model most wants to disobey: asked to improve Portuguese by an assistant that is English-shaped by training, it hands back an English rendering, and that is a translation card and not a correction. The English prompts are untouched, byte for byte, and a golden in `internal/llm/target_test.go` says so out loud (duplicated on purpose — a golden copied from the constant it guards guards nothing).
- **Collocation deliberately did not move.** Its prompt is per-language *knowledge*, not framing: "natives usually say" for Portuguese is a claim Petal has no grounds to make yet. A flipped document gets the pass it always got.
- Tests: `doclang_test.go` (monolingual pt/zh/en, one-English-line-in-a-Portuguese-journal, English-quoting-Portuguese, untested pair, emptied document holds its verdict, the band **from both directions**, corroboration floor, the two Target decisions proven independent by the only pair that can — a `learning_pair` zh account writing Chinese gets Chinese corrections explained in English — plus handler tests in the shape of `pairlang_test.go`: a pt-PT document reaches the model as a Portuguese checkpoint *and* a Portuguese voice pass, the verdict persists, and a language flip re-opens the already-checked English sentence). `target_test.go` for the prompts. go build/vet/test clean.
- **Not deployed, and no live smoke.** The handler tests drive the real router and a real DB, which is what a smoke would have shown; what neither proves is how Qwen behaves on a Portuguese document — in particular whether the "never translate" line holds. That wants the deploy Phases 2427 are also waiting on, and a pt-PT reader.
- **Left standing, deliberately, and visible on a flipped document:** the offline mechanics rule pack is English (it is the one that filed a note about the stray English sentence), `isTranslation` still points one way (item 5), and the Ask Petal seed still round-trips (item 6).
### Later / explicitly not now
- Learner-facing Chinese writing (the zh pair's second direction) — own phase with its own spec (SUGGESTIONS §4); only after Phases 1921 prove the pair model
- ~~Spanish pair — gated on DreamDict growing an es dataset~~ **ungated 2026-07-26**, **shipped (code) 2026-07-28** — see Phase 25. What it still owes: a deploy, a native reader, and a writer who actually uses it.
+24
View File
@@ -589,6 +589,30 @@ CREATE INDEX idx_suggestions_resolved ON suggestions(status, resolved_at);
stmt: `
ALTER TABLE users ADD COLUMN direction TEXT NOT NULL DEFAULT 'learning_en'
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'));
`,
},
}
+2 -2
View File
@@ -41,9 +41,9 @@ type checkpointResponse struct {
// RunCheckpoint sends the grammar checkpoint and parses the JSON result. It
// applies the latency-guard truncation and the checkpoint sampling parameters
// 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{
Messages: CheckpointMessages(TruncateDoc(contentText), tone),
Messages: CheckpointMessages(TruncateDoc(contentText), tone, t),
MaxTokens: checkpointMaxTokens,
Temperature: 0.3,
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
// 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.
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{
Messages: CollocationMessages(contentText, tone, lang),
Messages: CollocationMessages(contentText, tone, t),
MaxTokens: 2048,
Temperature: 0.3,
RepetitionPenalty: 1.15,
+2 -2
View File
@@ -31,7 +31,7 @@ func TestLangForFallsBackToDefault(t *testing.T) {
func TestPromptsNameTheWritersLanguage(t *testing.T) {
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") {
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) {
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)
}
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."
}
// 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
// given (already-truncated) document text, steered toward the document's tone.
func CheckpointMessages(contentText, tone string) []Message {
// given (already-truncated) document text, steered toward the document's tone
// 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{
{Role: "system", Content: fmt.Sprintf(checkpointSystemPrompt, toneGuidance(tone))},
{Role: "system", Content: system},
{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": []}`
// 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
// the checkpoint, the caller passes the WHOLE document (no truncation) — voice
// 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{
{Role: "system", Content: voiceSystemPrompt},
{Role: "system", Content: system},
{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
// WHOLE document (no truncation), gently steered toward the document's tone so a
// hint can prefer a register-appropriate pairing. The parenthetical gloss is
// written in the writer's own language.
func CollocationMessages(contentText, tone string, lang Lang) []Message {
// written in the writer's own language — `Pair`, not `Explain`: the gloss is
// 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{
{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},
}
}
+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
// consistency is judged against the document's own established voice, not an
// 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{
Messages: VoiceMessages(contentText),
Messages: VoiceMessages(contentText, t),
MaxTokens: 2048,
Temperature: 0.3,
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
}
+261
View File
@@ -0,0 +1,261 @@
package suggestions
import (
"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)
}
}
+70 -15
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:
// given the document text, the document's tone and the writer's pair language it
// returns the model's raw suggestions. The voice pass ignores both extras (see
// llm.RunVoice) and the checkpoint ignores the language — only the collocation
// coach writes a word of it — but one signature keeps runPass free of special
// cases.
type pass func(ctx context.Context, client llm.LLMClient, contentText, tone string, lang llm.Lang) ([]llm.RawSuggestion, error)
// given the document text, the document's tone and the languages this document
// is to be corrected and explained in, it returns the model's raw suggestions.
// The voice pass ignores the tone (see llm.RunVoice) and the collocation coach
// reads only the writer's pair language, but one signature keeps runPass free of
// special cases.
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,
// 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")
userID := auth.UserID(r.Context())
// The writer's pair language rides along with the document rather than in a
// second query: it is read from the same row-scoped lookup that already
// proves she owns this document.
var contentText, tone, pairLang string
// The writer's pair language and direction ride along with the document
// rather than in a second query: they are read from the same row-scoped
// lookup that already proves she owns this document. `doc_lang` is the
// previous language verdict, which the new one needs (hysteresis).
var contentText, tone, pairLang, direction, prevLang string
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
JOIN users u ON u.id = d.user_id
WHERE d.id = ? AND d.user_id = ?`,
docID, userID,
).Scan(&contentText, &tone, &pairLang)
).Scan(&contentText, &tone, &prevLang, &pairLang, &direction)
if errors.Is(err, sql.ErrNoRows) {
httputil.ErrorJSON(w, http.StatusNotFound, "document not found")
return
@@ -298,16 +333,36 @@ func (h *Handler) runPass(w http.ResponseWriter, r *http.Request, limiter *llm.R
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.
docLang := documentLang(contentText, pairLang, prevLang)
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
// 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,
// 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
// 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 := ""
if scope.chunked {
salt = tone
salt = tone + "\x00" + docLang
}
chunks := splitChunks(contentText, salt)
askText, fresh := contentText, chunks
@@ -354,7 +409,7 @@ func (h *Handler) runPass(w http.ResponseWriter, r *http.Request, limiter *llm.R
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 {
// 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