From e67f77eb05fd29249e93335dc8b513abe5db9f1c Mon Sep 17 00:00:00 2001 From: prosolis <5590409+prosolis@users.noreply.github.com> Date: Wed, 29 Jul 2026 00:21:15 -0700 Subject: [PATCH] The pass announces the verdict it just decided MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- internal/suggestions/doclang_test.go | 53 ++++++++++++++++++++++++++++ internal/suggestions/handlers.go | 13 +++++++ web/src/App.tsx | 22 +++++++++++- web/src/api/client.ts | 31 +++++++++++++--- 4 files changed, 114 insertions(+), 5 deletions(-) diff --git a/internal/suggestions/doclang_test.go b/internal/suggestions/doclang_test.go index d40075d..0fdb567 100644 --- a/internal/suggestions/doclang_test.go +++ b/internal/suggestions/doclang_test.go @@ -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") + } +} diff --git a/internal/suggestions/handlers.go b/internal/suggestions/handlers.go index a57e371..c4565cb 100644 --- a/internal/suggestions/handlers.go +++ b/internal/suggestions/handlers.go @@ -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) == "" { diff --git a/web/src/App.tsx b/web/src/App.tsx index 1917177..9326d38 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -1,5 +1,14 @@ import { useCallback, useEffect, useRef, useState } from 'react' -import { api, type DocSummary, type DocUpdate, type Document, type Suggestion, type Tag, type TagColor } from './api/client' +import { + api, + onDocLang, + type DocSummary, + type DocUpdate, + type Document, + type Suggestion, + type Tag, + type TagColor, +} from './api/client' import { useAutoSave } from './hooks/useAutoSave' import { findingKey, useCheckpoint } from './hooks/useCheckpoint' import { useSpellChecker } from './hooks/useSpellChecker' @@ -81,6 +90,17 @@ export default function App() { return wordCountRef.current === 0 && (t === '' || t === 'Untitled') }, []) + // The pass announces its verdict the moment it decides one, which is the only + // moment that is prompt enough: read-aloud is reached for when she has stopped + // typing, so waiting for the next save means waiting for a save that isn't + // coming. Registered once, and it updates the same one field the save path + // does — whichever arrives first wins, and they agree. + useEffect(() => { + onDocLang((docId, lang) => + setCurrentDoc((prev) => (prev && prev.id === docId && prev.doc_lang !== lang ? { ...prev, doc_lang: lang } : prev)), + ) + }, []) + // Only `doc_lang` is lifted out of the save response, and only when it moved. // It is the one field the server decides on its own — the checkpoint pass reads // the whole document and writes back whether it is English or hers — so it is diff --git a/web/src/api/client.ts b/web/src/api/client.ts index 601d3e2..a8ac955 100644 --- a/web/src/api/client.ts +++ b/web/src/api/client.ts @@ -276,11 +276,34 @@ function signedOut(): UnauthorizedError { return new UnauthorizedError() } -async function req(path: string, init?: RequestInit): Promise { +// The document-language verdict is decided by the checkpoint pass, so the pass's +// own response is the first moment the client can know it. It rides on a header +// (the pass answers with a bare array of suggestions, and every caller reads it +// as one), and reaches the app through a handler registered here — the same +// shape onUnauthorized already uses, for the same reason: it is one fact from +// deep inside a request that a component several layers up needs. +// +// Without it the editor learns the verdict only from a document save, which is +// always one save behind the pass — and read-aloud is reached for precisely when +// she has stopped typing and no further save is coming. +let docLangHandler: ((docId: string, lang: DocLang) => void) | null = null + +export function onDocLang(handler: (docId: string, lang: DocLang) => void) { + docLangHandler = handler +} + +// `verdictFor` names the document whose language this response may announce. +// Only the three pass endpoints pass it; everything else has no verdict to carry +// and never touches the handler. +async function req(path: string, init?: RequestInit, verdictFor?: string): Promise { const res = await fetch(`/api${path}`, { headers: { 'Content-Type': 'application/json' }, ...init, }) + if (verdictFor && res.ok) { + const lang = res.headers.get('X-Petal-Doc-Lang') + if (lang === '' || lang === 'en' || lang === 'pair') docLangHandler?.(verdictFor, lang) + } if (res.status === 401) throw signedOut() if (!res.ok) { const detail = await res.text().catch(() => '') @@ -322,14 +345,14 @@ export const api = { // Rate-limited per document server-side (returns the existing set if too soon). // Both passes return the UNIFIED pending set (grammar + voice), so the client // never drops one family's highlights when the other refreshes. - checkDoc: (id: string) => req(`/docs/${id}/check`, { method: 'POST' }), + checkDoc: (id: string) => req(`/docs/${id}/check`, { method: 'POST' }, id), // Voice-consistency pass: whole-document, explicit-action, slower. Returns the // unified pending set too. Rate-limited per document server-side. - voiceDoc: (id: string) => req(`/docs/${id}/voice`, { method: 'POST' }), + voiceDoc: (id: string) => req(`/docs/${id}/voice`, { method: 'POST' }, id), // Collocation coach: whole-document, explicit-action pass flagging non-native // word pairings ("do a decision" → "make a decision"). Returns the unified // pending set too. Rate-limited per document server-side. - collocationDoc: (id: string) => req(`/docs/${id}/collocation`, { method: 'POST' }), + collocationDoc: (id: string) => req(`/docs/${id}/collocation`, { method: 'POST' }, id), // Mechanics pass: persist the client-detected deterministic fixes as the // 'mechanics' family and return the unified pending set. Not rate-limited (it's // free, local detection); runs alongside the grammar checkpoint.