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:
@@ -17,23 +17,37 @@ type translateResponse struct {
|
||||
Translation string `json:"translation"`
|
||||
}
|
||||
|
||||
// translate renders a suggestion's English explanation into Simplified Chinese
|
||||
// for the Ask Petal bubble, so the ESL reader sees the "why" in her first
|
||||
// language instead of a second copy of the same English text. The explanation is
|
||||
// loaded server-side from the suggestion id (scoped to the local user) and never
|
||||
// trusted from the client, mirroring chat (spec Note #10).
|
||||
// translate renders a suggestion's explanation into the other half of the
|
||||
// writer's pair for the Ask Petal bubble, so she sees the "why" in the language
|
||||
// she reads most easily instead of a second copy of the same text. The
|
||||
// explanation is loaded server-side from the suggestion id (scoped to the
|
||||
// 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) {
|
||||
sugID := chi.URLParam(r, "id")
|
||||
|
||||
var explanation, pairLang string
|
||||
var explanation, pairLang, direction, docLang string
|
||||
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
|
||||
JOIN documents d ON d.id = s.doc_id
|
||||
JOIN users u ON u.id = d.user_id
|
||||
WHERE s.id = ? AND d.user_id = ?`,
|
||||
sugID, auth.UserID(r.Context()),
|
||||
).Scan(&explanation, &pairLang)
|
||||
).Scan(&explanation, &pairLang, &direction, &docLang)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
httputil.ErrorJSON(w, http.StatusNotFound, "suggestion not found")
|
||||
return
|
||||
@@ -49,7 +63,21 @@ func (h *Handler) translate(w http.ResponseWriter, r *http.Request) {
|
||||
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 {
|
||||
httputil.UpstreamError(w, "translate", err)
|
||||
return
|
||||
|
||||
Reference in New Issue
Block a user