A card vanishing and coming back seconds later, with different words, was never about latency: every pass deleted its whole family and re-inserted it, so each round minted new row ids. The rail keys on suggestion.id, so a full remount was guaranteed — new id, new created_at (hence the re-fired chime), and a fresh explanation from a model that re-reasons every time it is asked. One unchanged mistake carried three different explanations in a single sitting. Passes now reconcile instead of replace. A re-proposed edit keeps its row: its id, its created_at, and the wording she has already read. And the grammar checkpoint stops asking about sentences nobody touched — the document is split into hashed sentences, checked_chunks records which ones a family has read, and only the difference is sent. When nothing changed it doesn't call the model at all, and doesn't spend its rate-limit slot on having done nothing. The tone is part of a sentence's identity: cached advice was written for the old register, so switching doc type re-reads every line. replaceMechanics reconciles too, which mattered more than expected — the rule pack fires 250 ms after a keystroke, so it was re-minting every local card's id several times a sentence. Only the grammar checkpoint is chunked. Voice is a property of the whole document, and the collocation coach is a button she pressed asking for a fresh read. No client change was needed; stable ids were the whole of it. Claude-Session: https://claude.ai/code/session_016y6gyuHkQXPiEuW8RGQyua
192 lines
7.3 KiB
Go
192 lines
7.3 KiB
Go
package suggestions
|
|
|
|
import (
|
|
"encoding/json"
|
|
"net/http"
|
|
"strings"
|
|
"testing"
|
|
|
|
"gitea.parodia.dev/drwily/petal/internal/db"
|
|
"gitea.parodia.dev/drwily/petal/internal/llm"
|
|
)
|
|
|
|
// byOriginal indexes a pending set by the text each card flags.
|
|
func byOriginal(in []db.Suggestion) map[string]db.Suggestion {
|
|
out := map[string]db.Suggestion{}
|
|
for _, s := range in {
|
|
out[s.Original] = s
|
|
}
|
|
return out
|
|
}
|
|
|
|
// TestUntouchedSentencesKeepTheirCards is the heart of the stability work: she
|
|
// edits one sentence, and the cards on every other sentence stay exactly as they
|
|
// were — same id (so the rail keeps the card instead of remounting it), same
|
|
// explanation (the model re-words its reasoning every time it is asked, and one
|
|
// unchanged mistake used to carry three different explanations in a sitting).
|
|
// The model is only asked about the sentence that changed.
|
|
func TestUntouchedSentencesKeepTheirCards(t *testing.T) {
|
|
client := &stubClient{response: `{"suggestions":[
|
|
{"original":"I has two apple","replacement":"I have two apples","explanation":"first wording","type":"grammar"},
|
|
{"original":"She go to market","replacement":"She goes to market","explanation":"agreement","type":"grammar"}
|
|
]}`}
|
|
srv, docID, h := newTestServer(t, client)
|
|
h.Limit = llm.NewRateLimiter(0)
|
|
setDocText(t, h, docID, "I has two apple. She go to market yesterday.")
|
|
|
|
rec := do(t, srv, http.MethodPost, "/docs/"+docID+"/check", "")
|
|
var first []db.Suggestion
|
|
if err := json.Unmarshal(rec.Body.Bytes(), &first); err != nil {
|
|
t.Fatalf("decode: %v", err)
|
|
}
|
|
if len(first) != 2 {
|
|
t.Fatalf("first pass: want 2, got %d: %+v", len(first), first)
|
|
}
|
|
kept := byOriginal(first)["I has two apple"]
|
|
|
|
// She fixes only the second sentence. The model, asked again, re-words its
|
|
// reasoning about the first — which it must never get the chance to do.
|
|
setDocText(t, h, docID, "I has two apple. She goes to market yesterday.")
|
|
client.response = `{"suggestions":[
|
|
{"original":"I has two apple","replacement":"I have two apples","explanation":"REWORDED","type":"grammar"}
|
|
]}`
|
|
rec = do(t, srv, http.MethodPost, "/docs/"+docID+"/check", "")
|
|
var second []db.Suggestion
|
|
if err := json.Unmarshal(rec.Body.Bytes(), &second); err != nil {
|
|
t.Fatalf("decode: %v", err)
|
|
}
|
|
|
|
if strings.Contains(client.lastPrompt, "I has two apple") {
|
|
t.Fatalf("untouched sentence was sent to the model:\n%s", client.lastPrompt)
|
|
}
|
|
if !strings.Contains(client.lastPrompt, "She goes to market") {
|
|
t.Fatalf("edited sentence was not sent to the model:\n%s", client.lastPrompt)
|
|
}
|
|
|
|
now := byOriginal(second)["I has two apple"]
|
|
if now.ID != kept.ID {
|
|
t.Fatalf("card was remounted: id %q became %q", kept.ID, now.ID)
|
|
}
|
|
if now.Explanation != "first wording" {
|
|
t.Fatalf("explanation drifted: %q", now.Explanation)
|
|
}
|
|
// The fixed sentence's card is gone, and the model's stray re-proposal for the
|
|
// cached sentence did not become a second card.
|
|
if len(second) != 1 {
|
|
t.Fatalf("want exactly one card left, got %d: %+v", len(second), second)
|
|
}
|
|
}
|
|
|
|
// TestUnchangedDocumentSkipsTheModel proves a check with nothing new to read
|
|
// costs nothing: no model call, and every card left standing untouched. This is
|
|
// the doc-open and tone-less re-check path.
|
|
func TestUnchangedDocumentSkipsTheModel(t *testing.T) {
|
|
client := &stubClient{response: `{"suggestions":[
|
|
{"original":"I has","replacement":"I have","explanation":"agreement","type":"grammar"}
|
|
]}`}
|
|
srv, docID, h := newTestServer(t, client)
|
|
h.Limit = llm.NewRateLimiter(0)
|
|
|
|
rec := do(t, srv, http.MethodPost, "/docs/"+docID+"/check", "")
|
|
var first []db.Suggestion
|
|
_ = json.Unmarshal(rec.Body.Bytes(), &first)
|
|
if len(first) != 1 || client.calls != 1 {
|
|
t.Fatalf("first pass: %d cards, %d calls", len(first), client.calls)
|
|
}
|
|
|
|
rec = do(t, srv, http.MethodPost, "/docs/"+docID+"/check", "")
|
|
var second []db.Suggestion
|
|
if err := json.Unmarshal(rec.Body.Bytes(), &second); err != nil {
|
|
t.Fatalf("decode: %v", err)
|
|
}
|
|
if client.calls != 1 {
|
|
t.Fatalf("re-checking an unedited document called the model %d times", client.calls)
|
|
}
|
|
if len(second) != 1 || second[0].ID != first[0].ID {
|
|
t.Fatalf("card did not survive an idle re-check: %+v", second)
|
|
}
|
|
}
|
|
|
|
// TestDeletedSentenceDropsItsCard covers the other half of the skip path: she
|
|
// removes a flagged sentence outright, so nothing changed that the model could
|
|
// be asked about — but its card must still go.
|
|
func TestDeletedSentenceDropsItsCard(t *testing.T) {
|
|
client := &stubClient{response: `{"suggestions":[
|
|
{"original":"I has","replacement":"I have","explanation":"agreement","type":"grammar"}
|
|
]}`}
|
|
srv, docID, h := newTestServer(t, client)
|
|
h.Limit = llm.NewRateLimiter(0)
|
|
|
|
do(t, srv, http.MethodPost, "/docs/"+docID+"/check", "")
|
|
setDocText(t, h, docID, "")
|
|
|
|
rec := do(t, srv, http.MethodPost, "/docs/"+docID+"/check", "")
|
|
var got []db.Suggestion
|
|
if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil {
|
|
t.Fatalf("decode: %v", err)
|
|
}
|
|
if len(got) != 0 {
|
|
t.Fatalf("card outlived its sentence: %+v", got)
|
|
}
|
|
}
|
|
|
|
// TestToneChangeReopensEverySentence: the checkpoint's advice is written for the
|
|
// document's tone, so switching from a journal to an academic essay has to
|
|
// re-read sentences that haven't changed a character.
|
|
func TestToneChangeReopensEverySentence(t *testing.T) {
|
|
client := &stubClient{response: `{"suggestions":[
|
|
{"original":"I has","replacement":"I have","explanation":"agreement","type":"grammar"}
|
|
]}`}
|
|
srv, docID, h := newTestServer(t, client)
|
|
h.Limit = llm.NewRateLimiter(0)
|
|
|
|
do(t, srv, http.MethodPost, "/docs/"+docID+"/check", "")
|
|
if _, err := h.DB.Exec(`UPDATE documents SET tone = 'academic' WHERE id = ?`, docID); err != nil {
|
|
t.Fatalf("set tone: %v", err)
|
|
}
|
|
do(t, srv, http.MethodPost, "/docs/"+docID+"/check", "")
|
|
|
|
if client.calls != 2 {
|
|
t.Fatalf("tone change did not re-read the document: %d model calls", client.calls)
|
|
}
|
|
}
|
|
|
|
// TestMechanicsFindingsKeepTheirRows: the rule pack re-runs 250 ms after every
|
|
// keystroke. A finding it still reports must keep its row, or the rail would
|
|
// remount several times a sentence — collapsing a card she has open, and
|
|
// re-firing the arrival chime for advice she is already reading.
|
|
func TestMechanicsFindingsKeepTheirRows(t *testing.T) {
|
|
srv, docID, _ := newTestServer(t, &stubClient{response: `{"suggestions":[]}`})
|
|
body := `{"findings":[
|
|
{"from":0,"to":5,"original":"I has","replacement":"I have","explanation":"agreement"},
|
|
{"from":6,"to":15,"original":"two apple","replacement":"two apples","explanation":"plural"}
|
|
]}`
|
|
|
|
rec := do(t, srv, http.MethodPost, "/docs/"+docID+"/mechanics", body)
|
|
var first []db.Suggestion
|
|
if err := json.Unmarshal(rec.Body.Bytes(), &first); err != nil {
|
|
t.Fatalf("decode: %v", err)
|
|
}
|
|
if len(first) != 2 {
|
|
t.Fatalf("want 2 rows, got %d", len(first))
|
|
}
|
|
|
|
// She types elsewhere: same findings, shifted spans, one of them now fixed.
|
|
rec = do(t, srv, http.MethodPost, "/docs/"+docID+"/mechanics", `{"findings":[
|
|
{"from":20,"to":25,"original":"I has","replacement":"I have","explanation":"agreement"}
|
|
]}`)
|
|
var second []db.Suggestion
|
|
if err := json.Unmarshal(rec.Body.Bytes(), &second); err != nil {
|
|
t.Fatalf("decode: %v", err)
|
|
}
|
|
if len(second) != 1 {
|
|
t.Fatalf("want 1 row, got %d: %+v", len(second), second)
|
|
}
|
|
if second[0].ID != byOriginal(first)["I has"].ID {
|
|
t.Fatalf("surviving finding was given a new identity: %+v", second[0])
|
|
}
|
|
if second[0].FromPos != 20 {
|
|
t.Fatalf("span did not follow the text: %+v", second[0])
|
|
}
|
|
}
|