The pass announces the verdict it just decided

Storing doc_lang on the document row is not enough on its own. The
editor sees that row when the document is opened or saved, and the pass
that decides the verdict runs after a save — so the client was always
one save behind, and read-aloud is reached for precisely when she has
stopped typing and no further save is coming. Heard in a browser: a
Portuguese paragraph read in an American voice, twice, until another
keystroke went in.

/check, /voice and /collocation now answer with X-Petal-Doc-Lang. A
header rather than a wider body: all three answer with a bare array of
the unified pending set and every caller reads it as one, and a verdict
is metadata about the pass rather than another suggestion. It reaches
the app through the same handler shape onUnauthorized already uses.

Claude-Session: https://claude.ai/code/session_01GJHNvirh7Hzhc9RL3HAvz7
This commit is contained in:
prosolis
2026-07-29 00:21:15 -07:00
parent c719effe1d
commit e67f77eb05
4 changed files with 114 additions and 5 deletions
+53
View File
@@ -393,3 +393,56 @@ func TestTranslateStillRendersForALearnersEnglishExplanation(t *testing.T) {
t.Fatalf("translate didn't render into the pair language:\n%s", client.lastPrompt)
}
}
// TestPassAnnouncesItsVerdict pins the header the client reads. Storing the
// verdict on the document row is not enough on its own: the editor sees that row
// only when the document is opened or saved, and the pass that decides the
// verdict runs *after* a save — so the client would always be one save behind,
// and read-aloud is reached for exactly when she has stopped typing and no
// further save is coming. Caught in a browser: a Portuguese paragraph read in an
// American voice, twice, until another keystroke went in.
//
// Asserted on both endpoints that can flip it, and on the empty-document early
// return, which answers without ever reaching the model.
func TestPassAnnouncesItsVerdict(t *testing.T) {
client := &stubClient{response: `{"suggestions":[]}`}
srv, docID, h := newDirectedServer(t, client, "pt-PT", auth.DirectionLearningEn, ptDocument)
for _, path := range []string{"/check", "/voice"} {
rec := do(t, srv, http.MethodPost, "/docs/"+docID+path, "")
if rec.Code != http.StatusOK {
t.Fatalf("%s: code=%d body=%s", path, rec.Code, rec.Body)
}
if got := rec.Header().Get("X-Petal-Doc-Lang"); got != docLangPair {
t.Fatalf("%s: X-Petal-Doc-Lang = %q, want %q", path, got, docLangPair)
}
}
// An English document says so rather than saying nothing — the client has to
// be able to hear a flip back, not just a flip away.
if _, err := h.DB.Exec(
`UPDATE documents SET content_text = ?, doc_lang = '' WHERE id = ?`,
"The weather was very cold this morning. I walked to the shop and bought some bread.", docID,
); err != nil {
t.Fatalf("rewrite doc: %v", err)
}
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 got := rec.Header().Get("X-Petal-Doc-Lang"); got != docLangEnglish {
t.Fatalf("English document: X-Petal-Doc-Lang = %q, want %q", got, docLangEnglish)
}
// The empty-document path returns before the model call, and still answers.
if _, err := h.DB.Exec(`UPDATE documents SET content_text = '' WHERE id = ?`, docID); err != nil {
t.Fatalf("empty doc: %v", err)
}
rec = do(t, srv, http.MethodPost, "/docs/"+docID+"/check", "")
if rec.Code != http.StatusOK {
t.Fatalf("empty check: code=%d body=%s", rec.Code, rec.Body)
}
if got := rec.Header().Get("X-Petal-Doc-Lang"); got == "" {
t.Fatal("empty document answered with no verdict header at all")
}
}
+13
View File
@@ -329,6 +329,19 @@ func (h *Handler) runPass(w http.ResponseWriter, r *http.Request, limiter *llm.R
// Portuguese while she clears it to start the entry again.
docLang := documentLang(contentText, pairLang, prevLang)
// Announce the verdict on every answer this pass gives, including the early
// ones below. This pass is the only thing that decides the value, so it is
// the only moment the client can learn it promptly — and the client needs it
// promptly for read-aloud, which is reached for exactly when she has stopped
// typing and no further save is coming. Carrying it back on the document row
// alone means the editor is always one save behind the truth, and a paragraph
// of Portuguese read in an American voice is how that sounds.
//
// A header rather than a wider body: /check and /voice answer with a bare
// array of the unified pending set, and every caller of both endpoints reads
// it as one. A verdict is metadata about the pass, not another suggestion.
w.Header().Set("X-Petal-Doc-Lang", docLang)
// Nothing to analyze on an empty document — skip the LLM round-trip. The
// family's rows go with the text they were about.
if strings.TrimSpace(contentText) == "" {