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
+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