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
This commit is contained in:
prosolis
2026-07-28 23:29:50 -07:00
parent 76dede8856
commit 29eb2fe1fc
7 changed files with 318 additions and 36 deletions
+7 -3
View File
File diff suppressed because one or more lines are too long
+134
View File
@@ -1,6 +1,7 @@
package suggestions package suggestions
import ( import (
"encoding/json"
"net/http" "net/http"
"path/filepath" "path/filepath"
"strings" "strings"
@@ -259,3 +260,136 @@ func TestLanguageFlipReopensCheckedSentences(t *testing.T) {
t.Fatalf("the already-checked sentence was not re-opened by the flip:\n%s", client.lastPrompt) 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)
}
}
+15 -9
View File
@@ -317,10 +317,22 @@ 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)
// 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
} }
@@ -333,12 +345,6 @@ 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.
docLang := documentLang(contentText, pairLang, prevLang)
if docLang != normalizeDocLang(prevLang) { if docLang != normalizeDocLang(prevLang) {
if _, err := h.DB.Exec( if _, err := h.DB.Exec(
`UPDATE documents SET doc_lang = ? WHERE id = ? AND user_id = ?`, `UPDATE documents SET doc_lang = ? WHERE id = ? AND user_id = ?`,
@@ -376,7 +382,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
} }
@@ -421,7 +427,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
} }
+26 -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)
} }
+91 -2
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)
} }
}) })
+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