Files
petal/internal/db/models.go
prosolis 8aa437ec82 Phase 12 + 13: collocation coach + vocabulary garden
Phase 12 — collocation coach: a third suggestion family for gentle
"natives usually say…" hints on non-native word pairings, reusing the
existing runPass/pendingScope/rail machinery.
- llm/collocation.go (RunCollocation, 25s floor, reuses ParseCheckpoint)
  + collocationSystemPrompt/CollocationMessages (warm, Mandarin gloss,
  defers grammar/spelling to the grammar family)
- migration 0005 rebuilds the suggestions table to extend the type CHECK
  (SQLite can't ALTER a CHECK)
- collocationScope + CollocationLimit + POST /{id}/collocation
- fix: grammarScope was `type != 'voice'` and would wipe the new
  collocation flags; now `type NOT IN ('voice','collocation')`
- frontend: --color-blossom, "Make it sound natural 🌸" pill,
  collocating/runCollocation in useCheckpoint, StatusBar dot

Phase 13 — vocabulary garden: capture looked-up words and surface them
for gentle spaced repetition.
- new internal/vocab package: migration 0006 (vocab_words, SM-2-lite
  columns, doc_id ON DELETE SET NULL, UNIQUE(user_id,word)),
  scheduler.go (Leitner ladder 1/3/7/16/35 then geometric; gentle
  "again", no streak-shaming), handlers (capture-upsert/list/due/
  review/delete, owner-scoped, SQLite-side datetime math)
- auto-capture on word lookup (dictionary-known words only, captures
  the surrounding sentence + doc_id) + 🤍/💚 toggle on WordCard
- GardenPanel: blossom grid (bloom by reps), flashcard review (sentence
  blanked, flip, again/good/easy, recognition↔production), sleepy-kitten
  footer; opened from a global 🌷 header button

Tests: TestCollocationPassCoexists, vocab scheduler + handlers, db CHECK
extended. go build/vet/test + tsc + vite + vitest (51/51) clean;
migration verified against a copy of the live DB; live backend smoke
walked the full vocab lifecycle + the warm-502 collocation path.

Claude-Session: https://claude.ai/code/session_016Yr6jELuRc7hyzYLccQKZd
2026-06-26 15:50:25 -07:00

109 lines
4.5 KiB
Go

package db
import "time"
// User is an account. With auth deferred, the app runs as a single hardcoded
// `local` user (see LocalUserID); the user_id columns and this type exist so
// real auth can drop in later without a schema migration.
type User struct {
ID string `json:"id"`
Email string `json:"email"`
DisplayName string `json:"display_name"`
CreatedAt time.Time `json:"created_at"`
}
// Document is a single piece of writing. `Content` is the Tiptap JSON document
// (source of truth for the editor); `ContentText` is the flattened plain text
// kept in sync on every save and fed to the LLM.
type Document struct {
ID string `json:"id"`
UserID string `json:"user_id"`
Title string `json:"title"`
Content string `json:"content"` // Tiptap JSON
ContentText string `json:"content_text"` // plain text for the LLM
Tone string `json:"tone"` // target writing tone; steers LLM advice
WordCount int `json:"word_count"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
// DocumentVersion is a point-in-time snapshot of a document's body, captured so
// a writer can recover from a bad edit or an unwanted change. `Content` mirrors
// the document's Tiptap JSON at snapshot time; `Kind` records why it was taken
// (see the kind constants). List responses omit the heavy Content/ContentText
// fields (the `omitempty`-friendly zero strings) and load them only on preview
// or restore.
type DocumentVersion struct {
ID string `json:"id"`
DocID string `json:"doc_id"`
Title string `json:"title"`
Content string `json:"content,omitempty"` // Tiptap JSON; omitted in list view
ContentText string `json:"content_text,omitempty"` // plain text; omitted in list view
WordCount int `json:"word_count"`
Kind string `json:"kind"` // auto | manual | pre_restore
CreatedAt time.Time `json:"created_at"`
}
// Document version kinds, mirrored from the schema CHECK constraint.
const (
VersionKindAuto = "auto" // throttled background snapshot on save
VersionKindManual = "manual" // explicit "save a restore point"
VersionKindPreRestore = "pre_restore" // safety copy taken just before a restore
)
// Tag is a user-scoped label for organizing documents. `Color` is a palette key
// (rose, mint, peach, lavender, sky, honey) the frontend maps to a CSS color;
// storing the key (not a hex value) keeps tags in step with the design tokens.
// `DocCount` is populated only by the tag-list endpoint (how many documents wear
// the tag); it's omitted from per-document tag lists.
type Tag struct {
ID string `json:"id"`
Name string `json:"name"`
Color string `json:"color"`
DocCount int `json:"doc_count,omitempty"`
}
// Tag color palette keys, mirrored on the frontend. Kept small and aligned with
// the existing design tokens; unknown values fall back to rose client-side.
const (
TagColorRose = "rose"
TagColorMint = "mint"
TagColorPeach = "peach"
TagColorLavender = "lavender"
TagColorSky = "sky"
TagColorHoney = "honey"
)
// Suggestion is a single LLM-proposed edit anchored to a span of the document.
//
// FromPos/ToPos are plaintext offsets into ContentText for server-side use only;
// the frontend re-anchors by matching the `Original` string in ProseMirror
// coordinates at render time (spec Note #6). `Replacement` is empty for `voice`
// flags — those are awareness-only, with no correction to apply.
type Suggestion struct {
ID string `json:"id"`
DocID string `json:"doc_id"`
FromPos int `json:"from_pos"`
ToPos int `json:"to_pos"`
Original string `json:"original"`
Replacement string `json:"replacement"`
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"`
}
// Suggestion type and status values, mirrored from the schema CHECK constraints.
const (
SuggestionTypeGrammar = "grammar"
SuggestionTypePhrasing = "phrasing"
SuggestionTypeIdiom = "idiom"
SuggestionTypeClarity = "clarity"
SuggestionTypeVoice = "voice"
SuggestionTypeCollocation = "collocation"
SuggestionStatusPending = "pending"
SuggestionStatusAccepted = "accepted"
SuggestionStatusRejected = "rejected"
)