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}) }