Reported from the phone: "I have my main language set as Portuguese and I say
I'm learning English but yet Petal presents the Ask Petal advice in both
sections as Portuguese."
Nothing was wrong with targetFor again. A Portuguese document by a Portuguese
writer is explained in Portuguese, which is the whole point of Phase 28. The
card was right. What was wrong was the tap underneath it: /suggestions/{id}
/translate answered "" for exactly that case, on the reasoning that an
unasked-for English rendering of an explanation she can already read is not a
seed but noise. That reasoning had the writer facing the wrong way. She is
learning English. The half she is *practising* is the half worth a tap, and the
bubble sits directly beneath the explanation inside the same card, so answering
"" left her with Portuguese, the same Portuguese again, and no English anywhere
on the card. targetFor's own comment promised the other language stays one tap
away in both directions; only one direction had ever been built.
So the endpoint keeps the one rule it always claimed: render into whichever
half the explanation is not already in. English explanation into her language,
as before; her language into the English she is learning, which is new. Both
ends of that are now parameters — TranslateMessages took the source language
for granted as English because until Phase 28 it always was. The zh prompt is
unchanged byte for byte, which its test still pins.
The client fallback was the same symptom from a different cause and would have
survived the server fix: an empty answer, or an unreachable model, seeded the
bubble with the explanation itself — a verbatim repeat of the line two above
it, which reads as Petal replying in the language the tap was pressed to
escape. With the endpoint always having somewhere to go, empty now means only
that the model didn't answer, so the panel opens with no bubble at all and the
input where she can ask. AskPetal no longer takes the explanation as a prop; it
never needed anything but the id.
The test that pinned the refusal now pins the rendering, and carries the report.
Claude-Session: https://claude.ai/code/session_01KGACAtTPjvZ2PipDZ5qD99
94 lines
3.6 KiB
Go
94 lines
3.6 KiB
Go
package suggestions
|
|
|
|
import (
|
|
"database/sql"
|
|
"errors"
|
|
"net/http"
|
|
"strings"
|
|
|
|
"github.com/go-chi/chi/v5"
|
|
|
|
"gitea.parodia.dev/drwily/petal/internal/auth"
|
|
"gitea.parodia.dev/drwily/petal/internal/httputil"
|
|
"gitea.parodia.dev/drwily/petal/internal/llm"
|
|
)
|
|
|
|
type translateResponse struct {
|
|
Translation string `json:"translation"`
|
|
}
|
|
|
|
// 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 that
|
|
// is the whole rule, in both directions. When it first shipped this endpoint
|
|
// answered "" for a Portuguese explanation on the reasoning that an English
|
|
// rendering she hadn't asked for was noise. It was reported as the opposite: a
|
|
// writer whose pair is Portuguese and English, learning English, met a
|
|
// Portuguese card with a Portuguese bubble under it and no English anywhere on
|
|
// the card. The tap is the one place the other language was promised, and the
|
|
// half she is *practising* is exactly the half worth a tap.
|
|
//
|
|
// The bubble sits directly beneath the explanation inside the card, so between
|
|
// the two the writer always has both languages, whichever way round the document
|
|
// put them.
|
|
func (h *Handler) translate(w http.ResponseWriter, r *http.Request) {
|
|
sugID := chi.URLParam(r, "id")
|
|
|
|
var explanation, pairLang, direction, docLang string
|
|
err := h.DB.QueryRow(
|
|
`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, &direction, &docLang)
|
|
if errors.Is(err, sql.ErrNoRows) {
|
|
httputil.ErrorJSON(w, http.StatusNotFound, "suggestion not found")
|
|
return
|
|
}
|
|
if err != nil {
|
|
httputil.ServerError(w, err)
|
|
return
|
|
}
|
|
|
|
explanation = strings.TrimSpace(explanation)
|
|
if explanation == "" {
|
|
httputil.WriteJSON(w, http.StatusOK, translateResponse{Translation: ""})
|
|
return
|
|
}
|
|
|
|
// 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)
|
|
from, to := target.Explain, target.Pair
|
|
if from.Code == to.Code {
|
|
// The explanation is already in her language, so the half this tap has to
|
|
// reach is the other one: the English she is practising.
|
|
to = llm.English
|
|
}
|
|
|
|
out, err := llm.RunTranslate(r.Context(), h.Client, explanation, from, to)
|
|
if err != nil {
|
|
httputil.UpstreamError(w, "translate", err)
|
|
return
|
|
}
|
|
|
|
httputil.WriteJSON(w, http.StatusOK, translateResponse{Translation: out})
|
|
}
|