Finish Phase 22: the half of Petal that works with the tunnel down
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
This commit is contained in:
@@ -477,6 +477,26 @@ CREATE TABLE personal_words (
|
||||
ALTER TABLE suggestions ADD COLUMN resolved_at DATETIME;
|
||||
UPDATE suggestions SET resolved_at = created_at WHERE status != 'pending';
|
||||
CREATE INDEX idx_suggestions_resolved ON suggestions(status, resolved_at);
|
||||
`,
|
||||
},
|
||||
{
|
||||
// Which engine proposed a row. Until now `type` doubled as that answer —
|
||||
// 'mechanics' meant "the offline rule pack found this" and everything else
|
||||
// meant "the model did". That breaks the moment an offline rule proposes a
|
||||
// *collocation*: the miscollocation list (SUGGESTIONS §6) is the same
|
||||
// family, the same rail and the same warm phrasing as the LLM coach, and it
|
||||
// must stay type='collocation' so an accepted chunk still plants in the
|
||||
// garden and still counts in the journal. With type no longer naming the
|
||||
// engine, the two passes could not scope their own DELETEs — the coach
|
||||
// would wipe the offline flags, and the offline pass would leave the
|
||||
// coach's behind to accumulate.
|
||||
//
|
||||
// Existing mechanics rows are local by definition; everything else came
|
||||
// from a model.
|
||||
name: "0013_suggestion_source",
|
||||
stmt: `
|
||||
ALTER TABLE suggestions ADD COLUMN source TEXT NOT NULL DEFAULT 'llm';
|
||||
UPDATE suggestions SET source = 'local' WHERE type = 'mechanics';
|
||||
`,
|
||||
},
|
||||
}
|
||||
|
||||
@@ -150,3 +150,80 @@ func TestResolvedAtBackfill(t *testing.T) {
|
||||
t.Errorf("pending row got resolved_at = %v, want NULL — nothing was decided", *pending)
|
||||
}
|
||||
}
|
||||
|
||||
// TestSuggestionSourceBackfill runs migration 0013 against a database that
|
||||
// predates it — the shape the live box is actually in. `source` is the column
|
||||
// that lets the offline rule pack and the model share the collocation family
|
||||
// without deleting each other's rows, and it can only do that if the existing
|
||||
// rows are labelled correctly on the way in: everything the old deterministic
|
||||
// pass wrote is local, and everything else came from a model.
|
||||
func TestSuggestionSourceBackfill(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "old.db")
|
||||
d, err := Open(path)
|
||||
if err != nil {
|
||||
t.Fatalf("open: %v", err)
|
||||
}
|
||||
|
||||
// Rewind to the state before 0013.
|
||||
if _, err := d.Exec(`ALTER TABLE suggestions DROP COLUMN source`); err != nil {
|
||||
t.Fatalf("rewind schema: %v", err)
|
||||
}
|
||||
if _, err := d.Exec(`DELETE FROM schema_migrations WHERE name = '0013_suggestion_source'`); err != nil {
|
||||
t.Fatalf("rewind migration record: %v", err)
|
||||
}
|
||||
if _, err := d.Exec(`INSERT INTO documents (id, user_id) VALUES ('d1', ?)`, LocalUserID); err != nil {
|
||||
t.Fatalf("insert document: %v", err)
|
||||
}
|
||||
for _, s := range []struct{ id, typ string }{
|
||||
{"s-mech", SuggestionTypeMechanics},
|
||||
{"s-gram", SuggestionTypeGrammar},
|
||||
{"s-coll", SuggestionTypeCollocation},
|
||||
} {
|
||||
if _, err := d.Exec(
|
||||
`INSERT INTO suggestions (id, doc_id, from_pos, to_pos, original, replacement, explanation, type)
|
||||
VALUES (?, 'd1', 0, 3, 'teh', 'the', 'x', ?)`,
|
||||
s.id, s.typ,
|
||||
); err != nil {
|
||||
t.Fatalf("seed %s: %v", s.id, err)
|
||||
}
|
||||
}
|
||||
d.Close()
|
||||
|
||||
d2, err := Open(path)
|
||||
if err != nil {
|
||||
t.Fatalf("reopen (migrate): %v", err)
|
||||
}
|
||||
defer d2.Close()
|
||||
|
||||
// A pre-0013 collocation row can only have come from the coach — the offline
|
||||
// miscollocation list did not exist yet — so it must NOT be claimed as local.
|
||||
for id, want := range map[string]string{
|
||||
"s-mech": SuggestionSourceLocal,
|
||||
"s-gram": SuggestionSourceLLM,
|
||||
"s-coll": SuggestionSourceLLM,
|
||||
} {
|
||||
var got string
|
||||
if err := d2.QueryRow(`SELECT source FROM suggestions WHERE id = ?`, id).Scan(&got); err != nil {
|
||||
t.Fatalf("read %s: %v", id, err)
|
||||
}
|
||||
if got != want {
|
||||
t.Errorf("%s: source = %q, want %q", id, got, want)
|
||||
}
|
||||
}
|
||||
|
||||
// And a row written after the migration defaults to the model, so a code path
|
||||
// that forgets to name a source can never silently claim to be offline.
|
||||
if _, err := d2.Exec(
|
||||
`INSERT INTO suggestions (id, doc_id, from_pos, to_pos, original, replacement, explanation, type)
|
||||
VALUES ('s-new', 'd1', 0, 3, 'teh', 'the', 'x', 'grammar')`,
|
||||
); err != nil {
|
||||
t.Fatalf("insert new row: %v", err)
|
||||
}
|
||||
var fresh string
|
||||
if err := d2.QueryRow(`SELECT source FROM suggestions WHERE id = 's-new'`).Scan(&fresh); err != nil {
|
||||
t.Fatalf("read new row: %v", err)
|
||||
}
|
||||
if fresh != SuggestionSourceLLM {
|
||||
t.Errorf("default source = %q, want %q", fresh, SuggestionSourceLLM)
|
||||
}
|
||||
}
|
||||
|
||||
+11
-1
@@ -106,7 +106,11 @@ type Suggestion struct {
|
||||
Explanation string `json:"explanation"`
|
||||
Type string `json:"type"` // grammar | phrasing | idiom | clarity | voice | collocation
|
||||
Status string `json:"status"` // pending | accepted | rejected
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
// Source names the engine that proposed the edit, not its family: an offline
|
||||
// rule and the model can both propose a collocation, and the writer is never
|
||||
// told which one spoke. It exists so each pass can replace its own rows.
|
||||
Source string `json:"source"` // llm | local
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
// Suggestion type and status values, mirrored from the schema CHECK constraints.
|
||||
@@ -119,6 +123,12 @@ const (
|
||||
SuggestionTypeCollocation = "collocation"
|
||||
SuggestionTypeMechanics = "mechanics" // deterministic rule-based pass (no LLM)
|
||||
|
||||
// Who proposed it. The offline rule pack ('local') runs on every edit inside
|
||||
// the browser and survives a VPN-down box; the model ('llm') adds the long
|
||||
// tail when it is reachable.
|
||||
SuggestionSourceLLM = "llm"
|
||||
SuggestionSourceLocal = "local"
|
||||
|
||||
SuggestionStatusPending = "pending"
|
||||
SuggestionStatusAccepted = "accepted"
|
||||
SuggestionStatusRejected = "rejected"
|
||||
|
||||
Reference in New Issue
Block a user