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.