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
161 lines
7.4 KiB
Go
161 lines
7.4 KiB
Go
package db
|
|
|
|
import "time"
|
|
|
|
// User is an account. Its ID is the OIDC subject for anyone who signed in, or
|
|
// LocalUserID for the pre-auth single user (and for local development, where
|
|
// StaticResolver still hands out that id).
|
|
type User struct {
|
|
ID string `json:"id"`
|
|
Email string `json:"email"`
|
|
DisplayName string `json:"display_name"`
|
|
CreatedAt time.Time `json:"created_at"`
|
|
|
|
// PairLang is the X in this writer's (English + X) language pair — "zh"
|
|
// today, "pt-PT"/"fr"/"es" once the langpacks land. It selects the UI copy
|
|
// and dictionary set, not the language they may type in.
|
|
PairLang string `json:"pair_lang"`
|
|
|
|
// Direction says which half of the pair is being *learned*. Every pair until
|
|
// now assumed one answer: the writer is native in X and practising English,
|
|
// so hanzi is never tokenized and English is what gets underlined. Turn it
|
|
// around — a native English speaker learning Chinese — and the same pair
|
|
// wants the opposite of nearly every default.
|
|
//
|
|
// It is a separate column from PairLang rather than a second pair code
|
|
// ("zh-learner") because it is a genuinely separate question: the pair says
|
|
// *which two languages*, this says *which way round*. Keeping them apart is
|
|
// what lets fr, es and pt-PT inherit the learner direction later without a
|
|
// second langpack each.
|
|
Direction string `json:"direction"`
|
|
}
|
|
|
|
// 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"`
|
|
// DocLang is which language this document is written in — '' | 'en' | 'pair'
|
|
// (migration 0017), written by the checkpoint pass and never by the client.
|
|
// It reaches the client read-only, for the one decision the client has to
|
|
// make on its own: which voice reads a selection aloud. '' means English.
|
|
DocLang string `json:"doc_lang"`
|
|
CreatedAt time.Time `json:"created_at"`
|
|
UpdatedAt time.Time `json:"updated_at"`
|
|
|
|
// PreserveHistory opts this document out of auto-snapshot pruning so its
|
|
// full writing trail survives as authorship evidence (see the passport).
|
|
PreserveHistory bool `json:"preserve_history"`
|
|
}
|
|
|
|
// 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"`
|
|
|
|
// ContentHash chains this snapshot to the previous one (PrevHash), so a
|
|
// history that was edited or thinned after the fact fails verification.
|
|
// Both are empty for snapshots taken before the chain existed. Omitted from
|
|
// list responses; the passport loads them explicitly.
|
|
ContentHash string `json:"content_hash,omitempty"`
|
|
PrevHash string `json:"prev_hash,omitempty"`
|
|
}
|
|
|
|
// 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 | translate | voice | collocation
|
|
Status string `json:"status"` // pending | accepted | rejected
|
|
// 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.
|
|
const (
|
|
SuggestionTypeGrammar = "grammar"
|
|
SuggestionTypePhrasing = "phrasing"
|
|
SuggestionTypeIdiom = "idiom"
|
|
SuggestionTypeClarity = "clarity"
|
|
// A span she wrote in her own language, rendered into English. Not a
|
|
// correction — nothing was wrong with it — which is why it is its own type
|
|
// rather than a clarity fix: the card is the pair model's flagship moment
|
|
// (SUGGESTIONS §1), and labelling it "Clarity" reads as a tidy-up of her
|
|
// first language. The model isn't asked for this label; it is derived from the
|
|
// span itself (see suggestions/language.go), so it can't drift.
|
|
SuggestionTypeTranslate = "translate"
|
|
SuggestionTypeVoice = "voice"
|
|
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"
|
|
)
|