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 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, 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) 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 } httputil.WriteJSON(w, http.StatusOK, translateResponse{Translation: out}) }