Phase 28 (c), the last of the phase. A word met inside a Portuguese document is a Portuguese card: migration 0018 mirrors documents.doc_lang onto vocab_words, set server-side from the ownership lookup capture was already making. Every card still reviews — filtering the queue to the half she is learning would drop the words she actually met. Read-aloud was the larger surprise. detectLang routed Han/kana to Chinese and everything else to en-US, so the zh pair was accidentally right and every Latin pair wrong. doc_lang now reaches the client read-only on the document JSON, and docLang(text, verdict) answers for a passage taken out of it — with the script test still winning, because quoted Chinese must never be spelled out one "Chinese letter" at a time. Claude-Session: https://claude.ai/code/session_01GJHNvirh7Hzhc9RL3HAvz7
656 lines
27 KiB
Go
656 lines
27 KiB
Go
// Package db owns the SQLite connection, schema migrations, and the core data
|
|
// models. It uses modernc.org/sqlite (pure Go, no cgo) so the app stays a
|
|
// single static binary.
|
|
package db
|
|
|
|
import (
|
|
"database/sql"
|
|
"fmt"
|
|
"net/url"
|
|
"os"
|
|
"path/filepath"
|
|
|
|
_ "modernc.org/sqlite"
|
|
)
|
|
|
|
// LocalUserID is the id of the single hardcoded user the app runs as while auth
|
|
// is deferred. The seeded row keeps foreign keys valid; real auth replaces it
|
|
// later without a schema change.
|
|
const LocalUserID = "local"
|
|
|
|
// DB wraps the SQL handle. It's a thin alias today, leaving room for prepared
|
|
// statements or helpers later without churning call sites.
|
|
type DB struct {
|
|
*sql.DB
|
|
}
|
|
|
|
// Open initialises the database at path: it ensures the parent directory
|
|
// exists, opens the connection with foreign keys and WAL enabled, runs all
|
|
// pending migrations, and seeds the local user.
|
|
func Open(path string) (*DB, error) {
|
|
if dir := filepath.Dir(path); dir != "" && dir != "." {
|
|
if err := os.MkdirAll(dir, 0o755); err != nil {
|
|
return nil, fmt.Errorf("create data dir: %w", err)
|
|
}
|
|
}
|
|
|
|
sqlDB, err := sql.Open("sqlite", dsn(path))
|
|
if err != nil {
|
|
return nil, fmt.Errorf("open sqlite: %w", err)
|
|
}
|
|
// SQLite is a single writer; one connection avoids "database is locked"
|
|
// churn while keeping WAL's concurrent readers.
|
|
sqlDB.SetMaxOpenConns(1)
|
|
|
|
if err := sqlDB.Ping(); err != nil {
|
|
_ = sqlDB.Close()
|
|
return nil, fmt.Errorf("ping sqlite: %w", err)
|
|
}
|
|
|
|
d := &DB{sqlDB}
|
|
if err := d.migrate(); err != nil {
|
|
_ = sqlDB.Close()
|
|
return nil, fmt.Errorf("migrate: %w", err)
|
|
}
|
|
if err := d.seed(); err != nil {
|
|
_ = sqlDB.Close()
|
|
return nil, fmt.Errorf("seed: %w", err)
|
|
}
|
|
return d, nil
|
|
}
|
|
|
|
// dsn builds the modernc.org/sqlite connection string with the pragmas we want
|
|
// applied to every connection.
|
|
func dsn(path string) string {
|
|
q := url.Values{}
|
|
q.Add("_pragma", "foreign_keys(1)")
|
|
q.Add("_pragma", "busy_timeout(5000)")
|
|
q.Add("_pragma", "journal_mode(WAL)")
|
|
return "file:" + path + "?" + q.Encode()
|
|
}
|
|
|
|
// migration is one ordered, idempotent schema step. Append new migrations to the
|
|
// slice in migrate(); never edit or reorder an already-shipped one.
|
|
type migration struct {
|
|
name string
|
|
stmt string
|
|
}
|
|
|
|
// migrate runs every migration not yet recorded in schema_migrations, inside a
|
|
// transaction each, in order.
|
|
func (d *DB) migrate() error {
|
|
if _, err := d.Exec(`CREATE TABLE IF NOT EXISTS schema_migrations (
|
|
name TEXT PRIMARY KEY,
|
|
applied_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
|
)`); err != nil {
|
|
return err
|
|
}
|
|
|
|
for _, m := range migrations() {
|
|
var exists bool
|
|
if err := d.QueryRow(
|
|
`SELECT EXISTS(SELECT 1 FROM schema_migrations WHERE name = ?)`, m.name,
|
|
).Scan(&exists); err != nil {
|
|
return err
|
|
}
|
|
if exists {
|
|
continue
|
|
}
|
|
|
|
tx, err := d.Begin()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if _, err := tx.Exec(m.stmt); err != nil {
|
|
_ = tx.Rollback()
|
|
return fmt.Errorf("migration %q: %w", m.name, err)
|
|
}
|
|
if _, err := tx.Exec(`INSERT INTO schema_migrations (name) VALUES (?)`, m.name); err != nil {
|
|
_ = tx.Rollback()
|
|
return fmt.Errorf("record migration %q: %w", m.name, err)
|
|
}
|
|
if err := tx.Commit(); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// migrations returns the ordered schema history. The initial migration mirrors
|
|
// the schema in petal-spec.md.
|
|
func migrations() []migration {
|
|
return []migration{
|
|
{
|
|
name: "0001_initial_schema",
|
|
stmt: `
|
|
CREATE TABLE users (
|
|
id TEXT PRIMARY KEY,
|
|
email TEXT NOT NULL,
|
|
display_name TEXT,
|
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
|
);
|
|
|
|
CREATE TABLE documents (
|
|
id TEXT PRIMARY KEY DEFAULT (lower(hex(randomblob(16)))),
|
|
user_id TEXT NOT NULL REFERENCES users(id),
|
|
title TEXT NOT NULL DEFAULT 'Untitled',
|
|
content TEXT NOT NULL DEFAULT '{}',
|
|
content_text TEXT NOT NULL DEFAULT '',
|
|
word_count INTEGER NOT NULL DEFAULT 0,
|
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
|
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
|
);
|
|
|
|
CREATE TABLE suggestions (
|
|
id TEXT PRIMARY KEY DEFAULT (lower(hex(randomblob(16)))),
|
|
doc_id TEXT NOT NULL REFERENCES documents(id) ON DELETE CASCADE,
|
|
from_pos INTEGER NOT NULL,
|
|
to_pos INTEGER NOT NULL,
|
|
original TEXT NOT NULL,
|
|
replacement TEXT NOT NULL,
|
|
explanation TEXT NOT NULL,
|
|
type TEXT NOT NULL CHECK(type IN ('grammar','phrasing','idiom','clarity','voice')),
|
|
status TEXT NOT NULL DEFAULT 'pending' CHECK(status IN ('pending','accepted','rejected')),
|
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
|
);
|
|
|
|
CREATE TABLE plagiarism_reports (
|
|
id TEXT PRIMARY KEY DEFAULT (lower(hex(randomblob(16)))),
|
|
doc_id TEXT NOT NULL REFERENCES documents(id) ON DELETE CASCADE,
|
|
backend TEXT NOT NULL DEFAULT 'copyleaks',
|
|
similarity_pct REAL,
|
|
status TEXT NOT NULL DEFAULT 'pending'
|
|
CHECK(status IN ('pending','complete','error')),
|
|
result_json TEXT,
|
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
|
);
|
|
|
|
CREATE INDEX idx_suggestions_doc_id ON suggestions(doc_id);
|
|
CREATE INDEX idx_plagiarism_doc_id ON plagiarism_reports(doc_id);
|
|
`,
|
|
},
|
|
{
|
|
// Per-document tone: guides the grammar-checkpoint LLM so advice fits
|
|
// the writer's target register (academic essay vs casual journal).
|
|
// 'general' means no specific tone steering.
|
|
name: "0002_document_tone",
|
|
stmt: `ALTER TABLE documents ADD COLUMN tone TEXT NOT NULL DEFAULT 'general';`,
|
|
},
|
|
{
|
|
// Version history: point-in-time snapshots of a document's body so a
|
|
// bad edit or LLM mishap is always recoverable. `kind` distinguishes
|
|
// throttled background snapshots ('auto'), explicit user restore
|
|
// points ('manual'), and the safety copy taken right before a restore
|
|
// ('pre_restore') so restoring is itself undoable. Snapshots cascade
|
|
// with the document. Stored fully (content + content_text) so a
|
|
// restore is a plain copy with no re-derivation.
|
|
name: "0003_document_versions",
|
|
stmt: `
|
|
CREATE TABLE document_versions (
|
|
id TEXT PRIMARY KEY DEFAULT (lower(hex(randomblob(16)))),
|
|
doc_id TEXT NOT NULL REFERENCES documents(id) ON DELETE CASCADE,
|
|
title TEXT NOT NULL,
|
|
content TEXT NOT NULL,
|
|
content_text TEXT NOT NULL,
|
|
word_count INTEGER NOT NULL DEFAULT 0,
|
|
kind TEXT NOT NULL DEFAULT 'auto'
|
|
CHECK(kind IN ('auto','manual','pre_restore')),
|
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
|
);
|
|
|
|
CREATE INDEX idx_versions_doc_id ON document_versions(doc_id, created_at DESC);
|
|
`,
|
|
},
|
|
{
|
|
// Organization & search (Phase 10). Two parts:
|
|
//
|
|
// 1. Tags. A small, user-scoped label set; `color` holds a palette key
|
|
// (rose/mint/peach/lavender/sky/honey) the frontend maps to CSS.
|
|
// document_tags is the many-to-many join; both sides cascade so
|
|
// deleting a doc or a tag cleans up its assignments.
|
|
//
|
|
// 2. Full-text search. A standalone FTS5 virtual table over title +
|
|
// content_text using the `trigram` tokenizer so search works for
|
|
// both English and space-free Chinese (the default tokenizer treats a
|
|
// CJK run as one token). It carries an UNINDEXED doc_id to map hits
|
|
// back to documents, kept in sync by AFTER INSERT/UPDATE/DELETE
|
|
// triggers, and is back-filled from the existing documents here.
|
|
// (Trigram needs ≥3 chars to MATCH; the search handler falls back to
|
|
// LIKE for shorter queries — common for 2-character Chinese words.)
|
|
name: "0004_tags_and_search",
|
|
stmt: `
|
|
CREATE TABLE tags (
|
|
id TEXT PRIMARY KEY DEFAULT (lower(hex(randomblob(16)))),
|
|
user_id TEXT NOT NULL REFERENCES users(id),
|
|
name TEXT NOT NULL,
|
|
color TEXT NOT NULL DEFAULT 'rose',
|
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
|
UNIQUE(user_id, name)
|
|
);
|
|
|
|
CREATE TABLE document_tags (
|
|
doc_id TEXT NOT NULL REFERENCES documents(id) ON DELETE CASCADE,
|
|
tag_id TEXT NOT NULL REFERENCES tags(id) ON DELETE CASCADE,
|
|
PRIMARY KEY (doc_id, tag_id)
|
|
);
|
|
|
|
CREATE INDEX idx_document_tags_tag ON document_tags(tag_id);
|
|
|
|
CREATE VIRTUAL TABLE documents_fts USING fts5(
|
|
doc_id UNINDEXED,
|
|
title,
|
|
content_text,
|
|
tokenize='trigram'
|
|
);
|
|
|
|
CREATE TRIGGER documents_ai AFTER INSERT ON documents BEGIN
|
|
INSERT INTO documents_fts (doc_id, title, content_text)
|
|
VALUES (new.id, new.title, new.content_text);
|
|
END;
|
|
|
|
CREATE TRIGGER documents_ad AFTER DELETE ON documents BEGIN
|
|
DELETE FROM documents_fts WHERE doc_id = old.id;
|
|
END;
|
|
|
|
CREATE TRIGGER documents_au AFTER UPDATE ON documents BEGIN
|
|
UPDATE documents_fts
|
|
SET title = new.title, content_text = new.content_text
|
|
WHERE doc_id = old.id;
|
|
END;
|
|
|
|
INSERT INTO documents_fts (doc_id, title, content_text)
|
|
SELECT id, title, content_text FROM documents;
|
|
`,
|
|
},
|
|
{
|
|
// Collocation coach (Phase 12). Adds a third suggestion family,
|
|
// 'collocation', for gentle "natives usually say…" hints on
|
|
// non-native word pairings. The `type` column carries a CHECK
|
|
// constraint and SQLite cannot ALTER one in place, so we rebuild the
|
|
// suggestions table with the extended CHECK, copy every row across,
|
|
// and recreate its index. Nothing references suggestions, so dropping
|
|
// the old table is safe; the new table keeps the same ON DELETE
|
|
// CASCADE to documents.
|
|
name: "0005_collocation_suggestion_type",
|
|
stmt: `
|
|
CREATE TABLE suggestions_new (
|
|
id TEXT PRIMARY KEY DEFAULT (lower(hex(randomblob(16)))),
|
|
doc_id TEXT NOT NULL REFERENCES documents(id) ON DELETE CASCADE,
|
|
from_pos INTEGER NOT NULL,
|
|
to_pos INTEGER NOT NULL,
|
|
original TEXT NOT NULL,
|
|
replacement TEXT NOT NULL,
|
|
explanation TEXT NOT NULL,
|
|
type TEXT NOT NULL CHECK(type IN ('grammar','phrasing','idiom','clarity','voice','collocation')),
|
|
status TEXT NOT NULL DEFAULT 'pending' CHECK(status IN ('pending','accepted','rejected')),
|
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
|
);
|
|
|
|
INSERT INTO suggestions_new (id, doc_id, from_pos, to_pos, original, replacement, explanation, type, status, created_at)
|
|
SELECT id, doc_id, from_pos, to_pos, original, replacement, explanation, type, status, created_at FROM suggestions;
|
|
|
|
DROP TABLE suggestions;
|
|
ALTER TABLE suggestions_new RENAME TO suggestions;
|
|
CREATE INDEX idx_suggestions_doc_id ON suggestions(doc_id);
|
|
`,
|
|
},
|
|
{
|
|
// Vocabulary garden (Phase 13). Every word the writer looks up is
|
|
// captured here and put on a gentle spaced-repetition schedule, turning
|
|
// passive lookups into real vocabulary. `example` holds the sentence the
|
|
// word appeared in (captured at lookup) for context during review;
|
|
// `doc_id` links back to where she met the word (nulled if that doc is
|
|
// deleted — the word stays in the garden). The SM-2-lite scheduling
|
|
// columns (due_at/interval_days/ease/reps/lapses/last_reviewed) drive a
|
|
// Leitner-style ladder (see internal/vocab/scheduler.go). UNIQUE on
|
|
// (user_id, word) makes capture an idempotent upsert.
|
|
name: "0006_vocab_garden",
|
|
stmt: `
|
|
CREATE TABLE vocab_words (
|
|
id TEXT PRIMARY KEY DEFAULT (lower(hex(randomblob(16)))),
|
|
user_id TEXT NOT NULL REFERENCES users(id),
|
|
word TEXT NOT NULL,
|
|
gloss TEXT NOT NULL DEFAULT '',
|
|
phonetic TEXT NOT NULL DEFAULT '',
|
|
example TEXT NOT NULL DEFAULT '',
|
|
doc_id TEXT REFERENCES documents(id) ON DELETE SET NULL,
|
|
due_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
interval_days INTEGER NOT NULL DEFAULT 0,
|
|
ease REAL NOT NULL DEFAULT 2.5,
|
|
reps INTEGER NOT NULL DEFAULT 0,
|
|
lapses INTEGER NOT NULL DEFAULT 0,
|
|
last_reviewed DATETIME,
|
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
|
UNIQUE(user_id, word)
|
|
);
|
|
|
|
CREATE INDEX idx_vocab_due ON vocab_words(user_id, due_at);
|
|
`,
|
|
},
|
|
{
|
|
// Vocabulary garden, follow-up. Some looked-up words have an English
|
|
// definition but no Chinese gloss; those produced an unanswerable review
|
|
// card (review reveals only the gloss). `definition` stores a short
|
|
// English sense captured at lookup as a fallback "meaning" so such words
|
|
// are still reviewable.
|
|
name: "0007_vocab_definition",
|
|
stmt: `
|
|
ALTER TABLE vocab_words ADD COLUMN definition TEXT NOT NULL DEFAULT '';
|
|
`,
|
|
},
|
|
{
|
|
// Deterministic mechanics pass. Adds a 'mechanics' suggestion family for
|
|
// rule-based fixes (doubled words, spacing/punctuation, lowercase "i",
|
|
// curated confusables) detected in pure Go — no LLM. As with 0005, the
|
|
// `type` CHECK can't be ALTERed in place, so rebuild the table with the
|
|
// extended constraint, copy every row across, and recreate the index.
|
|
name: "0008_mechanics_suggestion_type",
|
|
stmt: `
|
|
CREATE TABLE suggestions_new (
|
|
id TEXT PRIMARY KEY DEFAULT (lower(hex(randomblob(16)))),
|
|
doc_id TEXT NOT NULL REFERENCES documents(id) ON DELETE CASCADE,
|
|
from_pos INTEGER NOT NULL,
|
|
to_pos INTEGER NOT NULL,
|
|
original TEXT NOT NULL,
|
|
replacement TEXT NOT NULL,
|
|
explanation TEXT NOT NULL,
|
|
type TEXT NOT NULL CHECK(type IN ('grammar','phrasing','idiom','clarity','voice','collocation','mechanics')),
|
|
status TEXT NOT NULL DEFAULT 'pending' CHECK(status IN ('pending','accepted','rejected')),
|
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
|
);
|
|
|
|
INSERT INTO suggestions_new (id, doc_id, from_pos, to_pos, original, replacement, explanation, type, status, created_at)
|
|
SELECT id, doc_id, from_pos, to_pos, original, replacement, explanation, type, status, created_at FROM suggestions;
|
|
|
|
DROP TABLE suggestions;
|
|
ALTER TABLE suggestions_new RENAME TO suggestions;
|
|
CREATE INDEX idx_suggestions_doc_id ON suggestions(doc_id);
|
|
`,
|
|
},
|
|
{
|
|
// Writing passport: evidence that a document was written, not pasted.
|
|
//
|
|
// `preserve_history` opts a document out of auto-snapshot pruning. The
|
|
// 40-snapshot cap is right for recovery (you want recent states) but
|
|
// wrong for provenance (you want the *whole* span, oldest included), so
|
|
// a writer who may need to defend authorship flags the doc and keeps
|
|
// every snapshot.
|
|
//
|
|
// `content_hash`/`prev_hash` chain each snapshot to the one before it:
|
|
// hash = sha256(prev_hash | doc_id | created_at | word_count | text).
|
|
// This proves the local history is internally consistent — no snapshot
|
|
// was edited, reordered, or removed after the fact without breaking
|
|
// every link downstream. It is NOT third-party attestation: anyone with
|
|
// the DB and the algorithm could forge a fresh chain. It raises the cost
|
|
// of a doctored history from "edit one row" to "rebuild all of them".
|
|
// Pre-existing snapshots keep empty hashes and are reported as
|
|
// unverifiable rather than as failures.
|
|
name: "0009_writing_passport",
|
|
stmt: `
|
|
ALTER TABLE documents ADD COLUMN preserve_history INTEGER NOT NULL DEFAULT 0;
|
|
ALTER TABLE document_versions ADD COLUMN content_hash TEXT NOT NULL DEFAULT '';
|
|
ALTER TABLE document_versions ADD COLUMN prev_hash TEXT NOT NULL DEFAULT '';
|
|
`,
|
|
},
|
|
{
|
|
// Real accounts. Three separate things land together because they are
|
|
// one change: Petal can now tell users apart.
|
|
//
|
|
// `sessions` backs server-side login state. The cookie carries an opaque
|
|
// random token and this table stores only its SHA-256 — a leaked database
|
|
// copy therefore yields no usable session, the same reason passwords are
|
|
// hashed. Server-side rows (rather than a signed stateless cookie) are
|
|
// what make logout and revocation actually revoke.
|
|
//
|
|
// `images` gives the content-addressed image store an owner. Until now it
|
|
// was a flat directory with no database row at all: any caller holding a
|
|
// hash could fetch anyone's image, which is capability-URL security, not
|
|
// access control. The primary key is (name, user_id), so the same picture
|
|
// uploaded by two people is still stored once on disk and simply has two
|
|
// rows — deduplication survives; the file is deleted only with its last
|
|
// row. Rows for images already on disk are backfilled at startup by the
|
|
// images package, which is the only code that knows the storage path.
|
|
//
|
|
// `users.pair_lang` is the writer's language pair (English + X). It is
|
|
// unused until the langpack work, but it belongs to provisioning and
|
|
// costs nothing to add while the users table is already being touched.
|
|
name: "0010_sessions_images_and_pair_lang",
|
|
stmt: `
|
|
CREATE TABLE sessions (
|
|
id TEXT PRIMARY KEY,
|
|
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
|
expires_at DATETIME NOT NULL,
|
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
|
user_agent TEXT NOT NULL DEFAULT ''
|
|
);
|
|
CREATE INDEX idx_sessions_user_id ON sessions(user_id);
|
|
|
|
CREATE TABLE images (
|
|
name TEXT NOT NULL,
|
|
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
|
content_type TEXT NOT NULL DEFAULT '',
|
|
size INTEGER NOT NULL DEFAULT 0,
|
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
|
PRIMARY KEY (name, user_id)
|
|
);
|
|
CREATE INDEX idx_images_user_id ON images(user_id);
|
|
|
|
ALTER TABLE users ADD COLUMN pair_lang TEXT NOT NULL DEFAULT 'zh';
|
|
`,
|
|
},
|
|
{
|
|
// The personal spelling dictionary moves off the browser. It used to be a
|
|
// single `petal.spell.personal` key in localStorage, which meant two
|
|
// people sharing a device shared a word list built from one person's
|
|
// private writing — and one person writing on two devices had two
|
|
// unrelated lists.
|
|
//
|
|
// `lang` is the *dictionary's* language, not the writer's: a word is only
|
|
// ever added while a particular Hunspell dictionary flagged it, and an
|
|
// en-US personal word must not silence a pt-PT flag (or vice versa) once
|
|
// the second pair ships. `word` is stored as typed; matching is exact,
|
|
// because case carries meaning to a speller ("polish" vs "Polish").
|
|
name: "0011_personal_dictionary",
|
|
stmt: `
|
|
CREATE TABLE personal_words (
|
|
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
|
lang TEXT NOT NULL,
|
|
word TEXT NOT NULL,
|
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
|
PRIMARY KEY (user_id, lang, word)
|
|
);
|
|
`,
|
|
},
|
|
{
|
|
// The growth journal reads the suggestions table as a record of what the
|
|
// writer has been learning, and that reading only works if a row is dated
|
|
// by *her decision* rather than by the model's proposal. `created_at` is
|
|
// when a checkpoint offered the edit; a suggestion offered in April and
|
|
// accepted in June is June's growth, not April's.
|
|
//
|
|
// Existing rows are backfilled to created_at — which is exactly the
|
|
// approximation the journal would have had to make anyway, and is very
|
|
// nearly right in practice since edits are settled minutes after a
|
|
// checkpoint. Only pending rows keep a NULL: nothing has been decided.
|
|
name: "0012_suggestion_resolved_at",
|
|
stmt: `
|
|
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';
|
|
`,
|
|
},
|
|
{
|
|
// Sentence-level identity, so a re-check stops regenerating the world.
|
|
// Every pass used to delete its whole family and re-insert it, which
|
|
// meant accepting one edit gave every other card a new id and a newly
|
|
// worded explanation — the rail visibly emptied and refilled, and the
|
|
// model was asked again about sentences nobody had touched.
|
|
//
|
|
// `chunk_hash` records which sentence a suggestion belongs to, and
|
|
// checked_chunks records which sentences a family has already read. A
|
|
// re-check then asks only about the difference and keeps the rest of
|
|
// the rows exactly as they are, id and wording included.
|
|
//
|
|
// Existing rows get '' — "sentence unknown", which reads as in-play, so
|
|
// they are simply reconciled on the next pass like any fresh finding.
|
|
name: "0014_suggestion_chunk_hash",
|
|
stmt: `
|
|
ALTER TABLE suggestions ADD COLUMN chunk_hash TEXT NOT NULL DEFAULT '';
|
|
|
|
CREATE TABLE checked_chunks (
|
|
doc_id TEXT NOT NULL REFERENCES documents(id) ON DELETE CASCADE,
|
|
family TEXT NOT NULL,
|
|
hash TEXT NOT NULL,
|
|
PRIMARY KEY (doc_id, family, hash)
|
|
);
|
|
`,
|
|
},
|
|
{
|
|
// A sentence she wrote in her own language gets its own type. Petal already
|
|
// detected such spans and already rendered them into English — it just
|
|
// filed the result under 'clarity', so the pair model's flagship moment
|
|
// read as tidying up her Chinese. As with 0005 and 0008, the `type` CHECK
|
|
// can't be ALTERed in place, so rebuild the table with the extended
|
|
// constraint, copy every row across, and recreate both indexes.
|
|
//
|
|
// Existing rows are left on whatever type they have. A card she is already
|
|
// reading keeps the label she has already read (the same rule reconcile.go
|
|
// follows for a re-proposed edit); new findings get the new label.
|
|
name: "0015_translate_suggestion_type",
|
|
stmt: `
|
|
CREATE TABLE suggestions_new (
|
|
id TEXT PRIMARY KEY DEFAULT (lower(hex(randomblob(16)))),
|
|
doc_id TEXT NOT NULL REFERENCES documents(id) ON DELETE CASCADE,
|
|
from_pos INTEGER NOT NULL,
|
|
to_pos INTEGER NOT NULL,
|
|
original TEXT NOT NULL,
|
|
replacement TEXT NOT NULL,
|
|
explanation TEXT NOT NULL,
|
|
type TEXT NOT NULL CHECK(type IN ('grammar','phrasing','idiom','clarity','translate','voice','collocation','mechanics')),
|
|
status TEXT NOT NULL DEFAULT 'pending' CHECK(status IN ('pending','accepted','rejected')),
|
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
|
resolved_at DATETIME,
|
|
source TEXT NOT NULL DEFAULT 'llm',
|
|
chunk_hash TEXT NOT NULL DEFAULT ''
|
|
);
|
|
|
|
INSERT INTO suggestions_new (id, doc_id, from_pos, to_pos, original, replacement, explanation, type, status, created_at, resolved_at, source, chunk_hash)
|
|
SELECT id, doc_id, from_pos, to_pos, original, replacement, explanation, type, status, created_at, resolved_at, source, chunk_hash FROM suggestions;
|
|
|
|
DROP TABLE suggestions;
|
|
ALTER TABLE suggestions_new RENAME TO suggestions;
|
|
CREATE INDEX idx_suggestions_doc_id ON suggestions(doc_id);
|
|
CREATE INDEX idx_suggestions_resolved ON suggestions(status, resolved_at);
|
|
`,
|
|
},
|
|
{
|
|
// Which half of the pair is being learned.
|
|
//
|
|
// `pair_lang` (0010) has always answered "which two languages", and every
|
|
// surface built on it assumed the answer to a second question nobody had
|
|
// asked: that English is the language being *learned*. That assumption is
|
|
// load-bearing in a dozen places — CJK is deliberately never tokenized,
|
|
// never spell-checked, never glossed; the prompts explain English in her
|
|
// language; the vocabulary garden captures English words. All correct for
|
|
// a Mandarin native practising English, and all backwards for an English
|
|
// native practising Mandarin.
|
|
//
|
|
// A second pair code ('zh-learner') was the cheaper option and is the
|
|
// wrong shape: it would make the two directions of one pair look like two
|
|
// unrelated languages to every query, and it would have to be repeated for
|
|
// fr, es and pt-PT before any of them could turn around. A column keeps
|
|
// the two questions separate, which is what they are.
|
|
//
|
|
// 'learning_en' is the default and is what every existing row means — the
|
|
// backfill is the DEFAULT itself, and it is right rather than merely
|
|
// convenient: all three accounts today are Mandarin natives writing
|
|
// English.
|
|
name: "0016_user_direction",
|
|
stmt: `
|
|
ALTER TABLE users ADD COLUMN direction TEXT NOT NULL DEFAULT 'learning_en'
|
|
CHECK(direction IN ('learning_en','learning_pair'));
|
|
`,
|
|
},
|
|
{
|
|
// Which language this document is written in — 'en' or 'pair'.
|
|
//
|
|
// It is stored, rather than recomputed per pass and forgotten, for one
|
|
// reason: the verdict has hysteresis (see suggestions/doclang.go). A
|
|
// bilingual document sits between the two thresholds, and "whatever it
|
|
// was last time" is only an answer if last time was written down. Without
|
|
// the column a mixed paragraph would alternate its cards' language
|
|
// between passes.
|
|
//
|
|
// 'pair' rather than a language code, deliberately. Which language "pair"
|
|
// names is the owner's users.pair_lang, so changing her pair re-reads her
|
|
// documents instead of stranding a stale language name on every one of
|
|
// them.
|
|
//
|
|
// Empty is the backfill and means English: every document that exists
|
|
// today was written by a Mandarin native practising English, and English
|
|
// is what every surface assumed before this phase.
|
|
name: "0017_document_lang",
|
|
stmt: `
|
|
ALTER TABLE documents ADD COLUMN doc_lang TEXT NOT NULL DEFAULT ''
|
|
CHECK(doc_lang IN ('', 'en', 'pair'));
|
|
`,
|
|
},
|
|
{
|
|
// Which language a garden card is in — the same '' | 'en' | 'pair'
|
|
// vocabulary as documents.doc_lang, and set from it: a word is captured
|
|
// (or a phrase planted) out of a document, so the document's verdict is
|
|
// the card's language. A card with no document keeps '', which reads as
|
|
// English like every other empty here.
|
|
//
|
|
// The garden needed this the moment a document could be written in her
|
|
// own language. Before Phase 28 every card was English by construction;
|
|
// now a Portuguese lookup lands beside an English one with nothing to
|
|
// tell them apart, and two surfaces get it wrong without the tag — the
|
|
// review card's read-aloud (which would say a Portuguese word in a US
|
|
// English voice) and the panel, where a mixed garden is illegible.
|
|
//
|
|
// Every card is reviewed regardless. Filtering the queue to the half she
|
|
// is learning was the alternative and is wrong for the writer this is
|
|
// for: the words she met while writing Portuguese are still words she
|
|
// met, and a garden that quietly drops them is a garden that stops being
|
|
// a record of her reading.
|
|
name: "0018_vocab_lang",
|
|
stmt: `
|
|
ALTER TABLE vocab_words ADD COLUMN lang TEXT NOT NULL DEFAULT ''
|
|
CHECK(lang IN ('', 'en', 'pair'));
|
|
`,
|
|
},
|
|
}
|
|
}
|
|
|
|
// seed inserts the hardcoded local user if it doesn't already exist. It's
|
|
// idempotent, so it runs safely on every startup.
|
|
func (d *DB) seed() error {
|
|
_, err := d.Exec(
|
|
`INSERT INTO users (id, email, display_name) VALUES (?, ?, ?)
|
|
ON CONFLICT(id) DO NOTHING`,
|
|
LocalUserID, "local@petal.local", "Writer",
|
|
)
|
|
return err
|
|
}
|