Grammar lite, the false-friend list, the daily invitation and the offline miscollocations — the four remaining §5–§6 items, all client-side and all alive on a box that cannot reach the model. The offline collocations forced a schema change. `type` had been doubling as the answer to "which engine found this" — `mechanics` meant offline — and that stops being true the moment an offline rule proposes a collocation. Migration 0013 adds `source` (llm | local) and every pass now scopes its DELETE by engine; without it the coach silently wiped every offline chunk on the page. Existing rows backfill by type, so a pre-0013 collocation row is claimed as the coach's, which it was: the offline list did not exist yet. The rule pack is hand-curated rather than mined, and the entries left out are the point — `married with` is wrong until "married with children", `arrive to` wants at or in depending on the noun. A pack running on every keystroke must not correct correct writing. Claude-Session: https://claude.ai/code/session_016y6gyuHkQXPiEuW8RGQyua
204 lines
7.9 KiB
Go
204 lines
7.9 KiB
Go
package suggestions
|
|
|
|
import (
|
|
"encoding/json"
|
|
"net/http"
|
|
"testing"
|
|
|
|
"gitea.parodia.dev/drwily/petal/internal/db"
|
|
)
|
|
|
|
// The offline rule pack and the LLM now share the collocation family, which is
|
|
// the point: the writer sees one rail and is never told which engine spoke. What
|
|
// makes that safe is `source` — each pass replaces only its own rows. These tests
|
|
// pin the two ways that could go wrong, both of which the old type-scoped DELETEs
|
|
// would have hit.
|
|
|
|
// pendingOfType counts the pending rows of one family in a response body.
|
|
func pendingOfType(got []db.Suggestion, typ string) []db.Suggestion {
|
|
var out []db.Suggestion
|
|
for _, s := range got {
|
|
if s.Type == typ {
|
|
out = append(out, s)
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
// TestOfflineCollocationFilesAsCollocation proves a miscollocation the rule pack
|
|
// found is stored in the collocation family (so accepting it plants a garden
|
|
// card, exactly as the coach's would) while still being marked as locally found.
|
|
func TestOfflineCollocationFilesAsCollocation(t *testing.T) {
|
|
srv, docID, _ := newTestServer(t, &stubClient{response: `{"suggestions":[]}`})
|
|
|
|
got := postMechanics(t, srv, docID, `[
|
|
{"from":0,"to":13,"original":"do a decision","replacement":"make a decision","explanation":"pairing","type":"collocation"},
|
|
{"from":20,"to":27,"original":"the the","replacement":"the","explanation":"doubled word","type":"mechanics"}
|
|
]`)
|
|
if len(got) != 2 {
|
|
t.Fatalf("want both findings, got %+v", got)
|
|
}
|
|
coll := pendingOfType(got, db.SuggestionTypeCollocation)
|
|
if len(coll) != 1 {
|
|
t.Fatalf("want 1 collocation, got %+v", got)
|
|
}
|
|
if coll[0].Source != db.SuggestionSourceLocal {
|
|
t.Errorf("offline finding should be source=local, got %q", coll[0].Source)
|
|
}
|
|
if mech := pendingOfType(got, db.SuggestionTypeMechanics); len(mech) != 1 {
|
|
t.Fatalf("want 1 mechanics finding, got %+v", got)
|
|
}
|
|
}
|
|
|
|
// TestUnknownLocalTypeFallsBackToMechanics: a family the offline pass isn't
|
|
// allowed to claim (or an older client sending none at all) must land in
|
|
// mechanics. Otherwise a stray label would smuggle a row into an LLM family,
|
|
// where nothing would ever replace it.
|
|
func TestUnknownLocalTypeFallsBackToMechanics(t *testing.T) {
|
|
srv, docID, _ := newTestServer(t, &stubClient{response: `{"suggestions":[]}`})
|
|
|
|
got := postMechanics(t, srv, docID, `[
|
|
{"from":0,"to":5,"original":"aaaaa","replacement":"bbbbb","explanation":"x","type":"voice"},
|
|
{"from":6,"to":11,"original":"ccccc","replacement":"ddddd","explanation":"y"}
|
|
]`)
|
|
if len(got) != 2 {
|
|
t.Fatalf("want 2 findings, got %+v", got)
|
|
}
|
|
for _, s := range got {
|
|
if s.Type != db.SuggestionTypeMechanics {
|
|
t.Errorf("offline finding claimed family %q; only mechanics/collocation are allowed", s.Type)
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestCoachDoesNotWipeOfflineCollocations is the collision the source column
|
|
// exists for: the LLM collocation pass replaces the collocation family, and the
|
|
// rule pack's share of that family has to survive it. Before `source`, running
|
|
// the coach silently deleted every offline chunk on the page.
|
|
func TestCoachDoesNotWipeOfflineCollocations(t *testing.T) {
|
|
client := &stubClient{response: `{"suggestions":[
|
|
{"original":"apple","replacement":"an apple","explanation":"article","type":"collocation"}
|
|
]}`}
|
|
srv, docID, _ := newTestServer(t, client)
|
|
|
|
// The seeded doc is "I has two apple." — the coach's flag anchors on "apple"
|
|
// at [10,15], so the offline finding is given a span well clear of it. Two
|
|
// findings fighting over the same characters is a different rule (see
|
|
// TestOfflineCardWinsSpanCollision); this test is about the DELETE.
|
|
postMechanics(t, srv, docID, `[
|
|
{"from":0,"to":5,"original":"do a decision","replacement":"make a decision","explanation":"pairing","type":"collocation"}
|
|
]`)
|
|
|
|
rec := do(t, srv, http.MethodPost, "/docs/"+docID+"/collocation", "")
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("collocation pass: code=%d body=%s", rec.Code, rec.Body)
|
|
}
|
|
var got []db.Suggestion
|
|
if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil {
|
|
t.Fatalf("decode: %v", err)
|
|
}
|
|
|
|
var local, llm int
|
|
for _, s := range pendingOfType(got, db.SuggestionTypeCollocation) {
|
|
if s.Source == db.SuggestionSourceLocal {
|
|
local++
|
|
} else {
|
|
llm++
|
|
}
|
|
}
|
|
if local != 1 {
|
|
t.Errorf("the coach wiped the offline collocation: local=%d, got %+v", local, got)
|
|
}
|
|
if llm != 1 {
|
|
t.Errorf("want the coach's own flag alongside it: llm=%d, got %+v", llm, got)
|
|
}
|
|
}
|
|
|
|
// TestOfflinePassReplacesItsOwnCollocations is the mirror: the rule pack
|
|
// recomputes the whole document every run, so a chunk the current text no longer
|
|
// warrants must go — and the coach's flags must stay. Scoping the offline DELETE
|
|
// by type instead of source would have stranded the first row forever.
|
|
func TestOfflinePassReplacesItsOwnCollocations(t *testing.T) {
|
|
client := &stubClient{response: `{"suggestions":[
|
|
{"original":"apple","replacement":"an apple","explanation":"article","type":"collocation"}
|
|
]}`}
|
|
srv, docID, _ := newTestServer(t, client)
|
|
|
|
// A coach flag, then an offline chunk, then a rerun that no longer finds it.
|
|
do(t, srv, http.MethodPost, "/docs/"+docID+"/collocation", "")
|
|
postMechanics(t, srv, docID, `[
|
|
{"from":0,"to":13,"original":"do a decision","replacement":"make a decision","explanation":"pairing","type":"collocation"}
|
|
]`)
|
|
got := postMechanics(t, srv, docID, `[]`)
|
|
|
|
for _, s := range got {
|
|
if s.Source == db.SuggestionSourceLocal {
|
|
t.Errorf("stale offline finding survived a recompute: %+v", s)
|
|
}
|
|
}
|
|
if len(pendingOfType(got, db.SuggestionTypeCollocation)) != 1 {
|
|
t.Fatalf("the coach's own flag should be untouched, got %+v", got)
|
|
}
|
|
}
|
|
|
|
// TestOfflineCollocationPlantsOnAccept closes the loop the family split was for:
|
|
// a chunk the rule pack found, accepted, becomes a vocabulary-garden card — with
|
|
// no model involved anywhere in the path.
|
|
func TestOfflineCollocationPlantsOnAccept(t *testing.T) {
|
|
srv, docID, h := newTestServer(t, &stubClient{response: `{"suggestions":[]}`})
|
|
if _, err := h.DB.Exec(
|
|
`UPDATE documents SET content_text = ? WHERE id = ?`,
|
|
"I had to do a decision about the job.", docID,
|
|
); err != nil {
|
|
t.Fatalf("set content: %v", err)
|
|
}
|
|
|
|
got := postMechanics(t, srv, docID, `[
|
|
{"from":9,"to":22,"original":"do a decision","replacement":"make a decision","explanation":"pairing","type":"collocation"}
|
|
]`)
|
|
if len(got) != 1 {
|
|
t.Fatalf("want the offline chunk, got %+v", got)
|
|
}
|
|
if rec := do(t, srv, http.MethodPost, "/suggestions/"+got[0].ID+"/accept", ""); rec.Code != http.StatusNoContent {
|
|
t.Fatalf("accept: code=%d body=%s", rec.Code, rec.Body)
|
|
}
|
|
|
|
cards := gardenCards(t, h)
|
|
if len(cards) != 1 || cards[0].word != "make a decision" {
|
|
t.Fatalf("want a planted phrase card, got %+v", cards)
|
|
}
|
|
// The example is the corrected sentence — the phrasing she kept, not the one
|
|
// she just left behind.
|
|
if cards[0].example != "I had to make a decision about the job." {
|
|
t.Errorf("example should be the corrected sentence, got %q", cards[0].example)
|
|
}
|
|
}
|
|
|
|
// TestOfflineCardWinsSpanCollision: the tiebreak is by engine, not by family. An
|
|
// offline miscollocation has an exact span; the coach's overlapping flag is only
|
|
// advisory, so it is the one that goes.
|
|
func TestOfflineCardWinsSpanCollision(t *testing.T) {
|
|
client := &stubClient{response: `{"suggestions":[
|
|
{"original":"do a decision about","replacement":"decide about","explanation":"wordy","type":"collocation"}
|
|
]}`}
|
|
srv, docID, h := newTestServer(t, client)
|
|
if _, err := h.DB.Exec(
|
|
`UPDATE documents SET content_text = ? WHERE id = ?`,
|
|
"I had to do a decision about the job.", docID,
|
|
); err != nil {
|
|
t.Fatalf("set content: %v", err)
|
|
}
|
|
|
|
do(t, srv, http.MethodPost, "/docs/"+docID+"/collocation", "")
|
|
got := postMechanics(t, srv, docID, `[
|
|
{"from":9,"to":22,"original":"do a decision","replacement":"make a decision","explanation":"pairing","type":"collocation"}
|
|
]`)
|
|
|
|
if len(got) != 1 {
|
|
t.Fatalf("want the overlapping coach flag dropped, got %+v", got)
|
|
}
|
|
if got[0].Source != db.SuggestionSourceLocal {
|
|
t.Errorf("the exact offline card should own the span, got %+v", got[0])
|
|
}
|
|
}
|