Files
petal/internal/suggestions/doclang_test.go
T
prosolis e67f77eb05 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
2026-07-29 00:21:15 -07:00

449 lines
19 KiB
Go

package suggestions
import (
"encoding/json"
"net/http"
"path/filepath"
"strings"
"testing"
"github.com/go-chi/chi/v5"
"gitea.parodia.dev/drwily/petal/internal/auth"
"gitea.parodia.dev/drwily/petal/internal/db"
"gitea.parodia.dev/drwily/petal/internal/llm"
)
// A monolingual document in either language has to be read as that language, and
// the mixed cases in between are where the whole design lives: one quotation
// must not move a document, and one leftover English line must not hold a
// journal in English.
func TestDocumentLangReadsWholeDocuments(t *testing.T) {
const ptJournal = "Hoje foi um dia muito bom. Eu gosto de escrever aqui todas as noites. " +
"A minha irmã também quer aprender. Não sei porque isso é tão difícil para mim."
const enEssay = "The weather was very cold this morning. I think that the bus was late again. " +
"She told me about the meeting, but I could not hear what they said."
const zhJournal = "今天天气很好。我和妹妹一起去公园散步。我们看到很多花。"
tests := []struct {
name string
text string
pairLang string
prev string
want string
}{
{"portuguese journal", ptJournal, "pt-PT", "", docLangPair},
{"english essay", enEssay, "pt-PT", "", docLangEnglish},
{"chinese journal", zhJournal, "zh", "", docLangPair},
{"english essay, zh writer", enEssay, "zh", "", docLangEnglish},
// One English sentence at the end of a Portuguese journal is the case that
// motivated the whole phase: the pass must stay in Portuguese.
{
"portuguese with one english line",
ptJournal + " I will write more tomorrow.",
"pt-PT", "", docLangPair,
},
// And the mirror: an English essay quoting a line of Portuguese is still an
// English essay.
{
"english quoting portuguese",
enEssay + " She wrote: \"Eu não sei o que dizer.\"",
"pt-PT", "", docLangEnglish,
},
// A pair Petal has no test for cannot flip anything. Saying English is what
// every surface did before this phase.
{"untested pair", ptJournal, "de", "", docLangEnglish},
// Nothing to go on holds the previous answer rather than resetting a
// journal because she cleared it to start again.
{"emptied portuguese journal", "", "pt-PT", docLangPair, docLangPair},
{"emptied english essay", " \n ", "pt-PT", docLangEnglish, docLangEnglish},
// Proportion, not presence: a couple of Portuguese words are not a
// Portuguese document even though readsAsPairLang would label that span.
{
"english with a portuguese phrase",
enEssay + " The sign said pão com manteiga.",
"pt-PT", "", docLangEnglish,
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
if got := documentLang(tc.text, tc.pairLang, tc.prev); got != tc.want {
t.Fatalf("documentLang = %q, want %q", got, tc.want)
}
})
}
}
// The band, from both directions. A document sitting inside it keeps whatever it
// was, and that is the point: without it, a bilingual paragraph would alternate
// its cards' language every few keystrokes as she typed across the threshold.
func TestDocumentLangHysteresis(t *testing.T) {
// Half and half: two Portuguese sentences, two English ones. Inside the band
// from either side.
const mixed = "Eu gosto muito de escrever aqui. A minha irmã não sabe porque é difícil. " +
"The weather was very cold this morning. I think that they said the same thing."
if got := documentLang(mixed, "pt-PT", docLangEnglish); got != docLangEnglish {
t.Fatalf("mixed document from english = %q, want it to stay %q", got, docLangEnglish)
}
if got := documentLang(mixed, "pt-PT", docLangPair); got != docLangPair {
t.Fatalf("mixed document from pair = %q, want it to stay %q", got, docLangPair)
}
// Above the upper threshold it flips regardless of where it came from; below
// the lower one it flips back regardless.
const mostlyPT = "Eu gosto muito de escrever aqui. A minha irmã não sabe porque é difícil. " +
"Hoje foi um dia bom para mim. Amanhã também quero escrever mais uma coisa. " +
"I think so too."
if got := documentLang(mostlyPT, "pt-PT", docLangEnglish); got != docLangPair {
t.Fatalf("mostly-portuguese from english = %q, want %q", got, docLangPair)
}
const mostlyEN = "The weather was very cold this morning. I think that they said the same thing. " +
"She could not hear what the other people were saying about it. Eu não sei."
if got := documentLang(mostlyEN, "pt-PT", docLangPair); got != docLangEnglish {
t.Fatalf("mostly-english from pair = %q, want %q", got, docLangEnglish)
}
}
// Corroboration: a proportion computed over almost nothing is not evidence. Two
// bare words at 100% must not flip a document, because a flip rewrites every
// card in it.
func TestDocumentLangNeedsCorroboration(t *testing.T) {
if got := documentLang("Não. Eu.", "pt-PT", docLangEnglish); got != docLangEnglish {
t.Fatalf("two bare words flipped the document: %q", got)
}
if got := documentLang("我。", "zh", docLangEnglish); got != docLangEnglish {
t.Fatalf("two Han runes flipped the document: %q", got)
}
}
// The two language decisions are genuinely independent, and only the zh pair can
// prove it today — it is the one pair that can be travelled in both directions.
//
// A Mandarin native practising English who writes Chinese wants Chinese
// corrections explained in Chinese. An English native learning Chinese who writes
// Chinese wants the same Chinese corrections explained in English. Same document,
// same Correct, different Explain.
func TestTargetSeparatesCorrectedFromExplained(t *testing.T) {
learningEn := targetFor("zh", auth.DirectionLearningEn, docLangPair)
if learningEn.Correct.Code != "zh" || learningEn.Explain.Code != "zh" {
t.Fatalf("learning_en on a Chinese document: correct=%s explain=%s", learningEn.Correct.Code, learningEn.Explain.Code)
}
learningPair := targetFor("zh", auth.DirectionLearningPair, docLangPair)
if learningPair.Correct.Code != "zh" {
t.Fatalf("learner direction changed what gets corrected: %s", learningPair.Correct.Code)
}
if learningPair.Explain.Code != "en" {
t.Fatalf("learner direction explained in %s, want English", learningPair.Explain.Code)
}
// An English document is the path every account is on today, in either
// direction: English corrections, English explanations, her language still on
// the Ask Petal and translate taps.
for _, dir := range []string{auth.DirectionLearningEn, auth.DirectionLearningPair} {
got := targetFor("zh", dir, docLangEnglish)
if got.Flipped() || got.Explain.Code != "en" {
t.Fatalf("english document with direction %s: %+v", dir, got)
}
if got.Pair.Code != "zh" {
t.Fatalf("english document lost the writer's pair: %+v", got)
}
}
}
// newDirectedServer seeds one writer on a given pair and direction, with a
// document of her own. Like newPairServer, but the direction is the variable.
func newDirectedServer(t *testing.T, client llm.LLMClient, pairLang, direction, text string) (http.Handler, string, *Handler) {
t.Helper()
database, err := db.Open(filepath.Join(t.TempDir(), "doclang.db"))
if err != nil {
t.Fatalf("open db: %v", err)
}
t.Cleanup(func() { database.Close() })
const userID = "writer-directed"
if _, err := database.Exec(
`INSERT INTO users (id, email, display_name, pair_lang, direction) VALUES (?, ?, ?, ?, ?)`,
userID, "d@example.com", "Writer", pairLang, direction,
); err != nil {
t.Fatalf("seed user: %v", err)
}
var docID string
if err := database.QueryRow(
`INSERT INTO documents (user_id, content_text) VALUES (?, ?) RETURNING id`,
userID, text,
).Scan(&docID); err != nil {
t.Fatalf("seed doc: %v", err)
}
h := New(database, client)
h.Limit = llm.NewRateLimiter(0)
h.VoiceLimit = llm.NewRateLimiter(0)
r := chi.NewRouter()
r.Route("/docs", func(dr chi.Router) { h.RegisterDocRoutes(dr) })
r.Mount("/suggestions", h.Routes())
return auth.Middleware(auth.StaticResolver(userID))(r), docID, h
}
const ptDocument = "Hoje foi um dia muito bom. Eu gosto de escrever aqui todas as noites. " +
"A minha irmã também quer aprender comigo. Não sei porque isso é tão difícil para mim."
// End to end: a Portuguese document reaches the model as a Portuguese
// checkpoint. This is the observed bug from 2026-07-28 — two pt-PT sentences
// drew no cards at all, because Petal was reading them as bad English.
func TestCheckpointFollowsTheDocumentLanguage(t *testing.T) {
client := &stubClient{response: `{"suggestions":[]}`}
srv, docID, _ := newDirectedServer(t, client, "pt-PT", auth.DirectionLearningEn, ptDocument)
if rec := do(t, srv, http.MethodPost, "/docs/"+docID+"/check", ""); rec.Code != http.StatusOK {
t.Fatalf("check: code=%d body=%s", rec.Code, rec.Body)
}
if !strings.Contains(client.lastPrompt, "European Portuguese") {
t.Fatalf("checkpoint didn't follow the document into Portuguese:\n%s", client.lastPrompt)
}
if strings.Contains(client.lastPrompt, "second language") {
t.Fatalf("checkpoint kept the ESL framing on a Portuguese document:\n%s", client.lastPrompt)
}
// And the voice pass, which had no language argument at all before this phase.
if rec := do(t, srv, http.MethodPost, "/docs/"+docID+"/voice", ""); rec.Code != http.StatusOK {
t.Fatalf("voice: code=%d body=%s", rec.Code, rec.Body)
}
if !strings.Contains(client.lastPrompt, "European Portuguese") {
t.Fatalf("voice pass didn't follow the document:\n%s", client.lastPrompt)
}
}
// The verdict is persisted, because hysteresis needs a yesterday.
func TestDocumentLangIsRemembered(t *testing.T) {
client := &stubClient{response: `{"suggestions":[]}`}
srv, docID, h := newDirectedServer(t, client, "pt-PT", auth.DirectionLearningEn, ptDocument)
if rec := do(t, srv, http.MethodPost, "/docs/"+docID+"/check", ""); rec.Code != http.StatusOK {
t.Fatalf("check: code=%d body=%s", rec.Code, rec.Body)
}
var stored string
if err := h.DB.QueryRow(`SELECT doc_lang FROM documents WHERE id = ?`, docID).Scan(&stored); err != nil {
t.Fatalf("read doc_lang: %v", err)
}
if stored != docLangPair {
t.Fatalf("doc_lang = %q, want %q", stored, docLangPair)
}
}
// A document that changes language re-opens every sentence. Without the verdict
// in the chunk salt, the sentences she didn't touch would keep serving cards
// written in the language the document no longer speaks.
func TestLanguageFlipReopensCheckedSentences(t *testing.T) {
client := &stubClient{response: `{"suggestions":[]}`}
const enStart = "The weather was very cold this morning."
srv, docID, h := newDirectedServer(t, client, "pt-PT", auth.DirectionLearningEn, enStart)
if rec := do(t, srv, http.MethodPost, "/docs/"+docID+"/check", ""); rec.Code != http.StatusOK {
t.Fatalf("first check: code=%d body=%s", rec.Code, rec.Body)
}
first := client.calls
// She rewrites the document in Portuguese, keeping the first sentence.
setDocText(t, h, docID, enStart+" "+ptDocument)
if rec := do(t, srv, http.MethodPost, "/docs/"+docID+"/check", ""); rec.Code != http.StatusOK {
t.Fatalf("second check: code=%d body=%s", rec.Code, rec.Body)
}
if client.calls == first {
t.Fatal("the flipped document was never sent to the model")
}
if !strings.Contains(client.lastPrompt, "The weather was very cold") {
t.Fatalf("the already-checked sentence was not re-opened by the flip:\n%s", client.lastPrompt)
}
}
// The translate card, pointed the other way. She is writing her journal in
// Portuguese and drops in the one English sentence she knows; Petal renders it
// into Portuguese, and that card is a translation — not a correction to prose
// that was never wrong.
func TestEnglishSpanBecomesATranslateCardInAPortugueseDocument(t *testing.T) {
const english = "I want to say this but I don't know how to say it."
// The model volunteers "clarity", as it did for the zh case. Not consulted.
client := &stubClient{response: `{"suggestions":[
{"original":"` + english + `","replacement":"Eu quero dizer isto mas não sei como o dizer.","explanation":"Aqui está em português.","type":"clarity"}
]}`}
srv, docID, _ := newDirectedServer(t, client, "pt-PT", auth.DirectionLearningEn, ptDocument+" "+english)
var out []db.Suggestion
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 err := json.Unmarshal(rec.Body.Bytes(), &out); err != nil {
t.Fatalf("decode: %v", err)
}
if len(out) != 1 {
t.Fatalf("want 1 card, got %d: %+v", len(out), out)
}
if out[0].Type != db.SuggestionTypeTranslate {
t.Fatalf("card type = %q, want %q", out[0].Type, db.SuggestionTypeTranslate)
}
}
// And the half that keeps it honest: a genuine Portuguese correction in the same
// document stays a correction. Reading the English-document test backwards would
// have called this a translation, because every Portuguese sentence also "reads
// as English" by that test's deliberately low bar.
func TestPortugueseCorrectionKeepsItsTypeInAPortugueseDocument(t *testing.T) {
client := &stubClient{response: `{"suggestions":[
{"original":"Não sei porque isso é tão difícil para mim.","replacement":"Não sei porque isto é tão difícil para mim.","explanation":"Aqui usa-se isto.","type":"grammar"}
]}`}
srv, docID, _ := newDirectedServer(t, client, "pt-PT", auth.DirectionLearningEn, ptDocument)
var out []db.Suggestion
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 err := json.Unmarshal(rec.Body.Bytes(), &out); err != nil {
t.Fatalf("decode: %v", err)
}
if len(out) != 1 {
t.Fatalf("want 1 card, got %d: %+v", len(out), out)
}
if out[0].Type == db.SuggestionTypeTranslate {
t.Fatal("a Portuguese correction inside a Portuguese document was labelled a translation")
}
}
// setDocLang writes a document's language verdict directly, so a test of the
// tap-through doesn't have to run a checkpoint through the same stub client to
// get one.
func setDocLang(t *testing.T, h *Handler, docID, lang string) {
t.Helper()
if _, err := h.DB.Exec(`UPDATE documents SET doc_lang = ? WHERE id = ?`, lang, docID); err != nil {
t.Fatalf("set doc_lang: %v", err)
}
}
// seedExplanation files one card carrying a given explanation and returns its
// id — the shape the translate tap-through needs, where only the explanation and
// the document it hangs off matter.
func seedExplanation(t *testing.T, h *Handler, docID, explanation string) string {
t.Helper()
var sugID string
if err := h.DB.QueryRow(
`INSERT INTO suggestions (doc_id, original, replacement, explanation, type, from_pos, to_pos)
VALUES (?, ?, ?, ?, ?, 0, 5) RETURNING id`,
docID, "isso", "isto", explanation, "grammar",
).Scan(&sugID); err != nil {
t.Fatalf("seed suggestion: %v", err)
}
return sugID
}
// The tap-through has to read the same decision the card was written under. On a
// Portuguese document by a Portuguese writer the explanation already arrived in
// Portuguese, and the old endpoint would have sent it to the model to be
// rendered into Portuguese again.
func TestTranslateSkipsWhenTheExplanationIsAlreadyHers(t *testing.T) {
client := &stubClient{response: "Não devia ser chamado."}
srv, docID, h := newDirectedServer(t, client, "pt-PT", auth.DirectionLearningEn, ptDocument)
setDocLang(t, h, docID, docLangPair)
sugID := seedExplanation(t, h, docID, "Aqui usa-se isto.")
rec := do(t, srv, http.MethodPost, "/suggestions/"+sugID+"/translate", "")
if rec.Code != http.StatusOK {
t.Fatalf("translate: code=%d body=%s", rec.Code, rec.Body)
}
var out translateResponse
if err := json.Unmarshal(rec.Body.Bytes(), &out); err != nil {
t.Fatalf("decode: %v", err)
}
if out.Translation != "" {
t.Fatalf("translation = %q, want empty: the bubble seeds from the explanation itself", out.Translation)
}
if client.calls != 0 {
t.Fatal("the model was asked to render Portuguese into Portuguese")
}
}
// The learner travelling the other way is the case that proves the endpoint
// derives its destination rather than skipping whenever a document is flipped: a
// native English speaker learning Chinese, writing Chinese, gets her
// explanations in English — and the tap still has somewhere to go.
func TestTranslateStillRendersForALearnersEnglishExplanation(t *testing.T) {
const zhDocument = "今天天气很好。我早上去公园散步。下午我在家里写作业。晚上我和朋友一起吃饭。"
client := &stubClient{response: "这里应该用这个。"}
srv, docID, h := newDirectedServer(t, client, "zh", auth.DirectionLearningPair, zhDocument)
setDocLang(t, h, docID, docLangPair)
sugID := seedExplanation(t, h, docID, "This measure word doesn't fit here.")
rec := do(t, srv, http.MethodPost, "/suggestions/"+sugID+"/translate", "")
if rec.Code != http.StatusOK {
t.Fatalf("translate: code=%d body=%s", rec.Code, rec.Body)
}
var out translateResponse
if err := json.Unmarshal(rec.Body.Bytes(), &out); err != nil {
t.Fatalf("decode: %v", err)
}
if out.Translation == "" {
t.Fatal("a learner's English explanation was left untranslated")
}
if !strings.Contains(client.lastPrompt, "Simplified Chinese") {
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")
}
}