She reaches for her own language mid-sentence when English won't come, and Petal already handled it: it found the span and rendered it into English. It just filed the result as a Clarity fix, so the pair model's flagship moment read as tidying up her Chinese. The type is now derived from the span rather than asked of the model. A type is structural, and a model that re-reasons every pass would drift between labels for a sentence nobody had touched — the instability the last session spent itself removing. The label the model volunteers is still ignored. Only the grammar checkpoint can be promoted. A pass with a forced type owns its family: voice reads paragraphs for tone and its rows carry no replacement, so a "translation" there would be a card offering nothing to accept. zh is a different script and counting Han runes is close to certain. The Latin pairs share an alphabet with English and get none of that, so they fall back to function words and need two before Petal claims anything — with every word that is also English left out, even the common ones. The heuristic is justified by how cheap being wrong is: it changes a coloured pill, and nothing else. The pill is the one bilingual type name in the rail. Every other type stays English because those are the terms she is learning; this card's whole subject is her own language. And it stops truncating its two lines — elsewhere the diff is a word and the explanation is what she reads, but here the two sentences are the card. Two things only the running page could report. The inline underline was invisible: the decoration carries a per-type class and the base rule is a transparent border, so a type with no colour rule gets no mark at all. And at 1517×810 with the document list open there is no rail — the margin is 258 where railEnabled wants 348 — so what she gets is the inline hover card. Item 7 is written the other way round. Migration 0015 rebuilds the suggestions table for the CHECK, which makes it the first one here that could quietly drop her rows; there is a test that carries every column, both timestamps and both indexes across it. Claude-Session: https://claude.ai/code/session_016y6gyuHkQXPiEuW8RGQyua
143 lines
6.4 KiB
Go
143 lines
6.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"`
|
|
}
|
|
|
|
// 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"`
|
|
|
|
// 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"
|
|
)
|