Files
petal/internal/db/db.go
T
prosolis 76dede8856 Correct the language she wrote in, not the one she was practising
Every pass was English-shaped: CheckpointMessages took the text and the tone and
nothing else, so there was never a language decision to get wrong. On the live
build two pt-PT sentences drew no cards at all — Petal read the Portuguese, said
nothing about it, and filed a mechanics note about the one English line.

The rule is two decisions reading different state. What gets corrected follows
the document. What language the explanation is written in follows the writer —
the half of her pair she is not learning, from users.direction — because an
explanation is teaching, and teaching lands in the language she reads most
easily. Those coincide for every account that exists today (learnerPairs is
{"zh"}), which is a fact about the roster and not about the design, so Target
keeps them apart. It carries a third language too: the collocation gloss is
addressed to her rather than to the document, and folding it into Explain would
have quietly moved it into English on every English document.

The document verdict is a proportion, not a presence — one Portuguese quotation
must not flip an English essay. Per sentence, three-way: pair, English, or no
answer. The third value is the load-bearing one; counting the undecided as
English is exactly what would hold a journal of short Portuguese sentences in
English forever, so the Latin pairs needed an englishMarkers list curated against
pt/fr/es as carefully as latinMarkers was curated against English. Hysteresis at
70/40 because a bilingual paragraph would otherwise alternate its cards' language
every few keystrokes, and hysteresis needs a yesterday — hence the column. Plus a
corroboration floor: a ratio computed over "Não. Eu." is 100% of nothing, and a
flip rewrites every card in the document.

The verdict folds into the chunk salt beside the tone, so a document that changes
language re-opens every sentence rather than serving back cards in a language it
no longer speaks.

checkpointSystemPrompt could not simply take a language — it opens by naming the
reader an ESL learner, and appending "explain in Portuguese" hands the model two
contradictory framings. Separate constants, sharing the JSON contract below the
framing. Both carry a "never translate it into English" line, which is the
instruction the model will most want to disobey. The English prompts are
untouched byte for byte, and a golden says so out loud.

Collocation deliberately did not move: its prompt is per-language knowledge, not
framing, and "natives usually say" for Portuguese is a claim Petal cannot back.

Not deployed and not smoked against a real model. The tests drive the real router
and a real DB; what none of them prove is how Qwen behaves on a Portuguese
document, in particular whether the never-translate line holds.

Claude-Session: https://claude.ai/code/session_01GJHNvirh7Hzhc9RL3HAvz7
2026-07-28 23:20:53 -07:00

631 lines
26 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'));
`,
},
}
}
// 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
}