Files
petal/internal/db/models.go
T
prosolis 78ed1dd281 Writing passport: evidence of process instead of an AI score
She's submitting work that gets run through an AI detector and wants to
pre-check she won't be wrongly flagged. Petal should not answer that with
a detector of its own: they misfire badly on non-native English (Stanford
2023 found >50% of TOEFL essays flagged as AI vs. near-zero for native
writers), so a percentage aimed at an ESL writer is worse than nothing —
it either scares her off her own voice or gives false comfort.

So the artifact is provenance, not a verdict. Petal already snapshots
every ~3 minutes; this turns that history into a standalone printable
report: session breakdown, word-count growth, span, active time. No score
is emitted anywhere.

Two schema additions back it. preserve_history opts a document out of the
40-snapshot prune cap — right for recovery, wrong for provenance, where
you want the whole span including the oldest rows. content_hash/prev_hash
chain each snapshot to the one before it, so a history edited or thinned
after the fact fails verification. Pruning legitimately severs links, so a
link break reports as "gaps" unless preserve_history is on; only a hash
that fails against its own contents is unconditionally "broken".

The chart's x axis is snapshot order, not wall-clock, and that is the load
-bearing decision. On a linear time axis an essay written in three
sittings across three days renders as three vertical cliffs separated by
empty space — visually identical to text pasted in three chunks, i.e. the
report would have argued the opposite of the truth. Breaks are compressed
into explicitly labelled gutters instead. TestChartGivesWidthToWriting
pins it.

The report volunteers its largest single word-count jump and states its
own limits: it cannot show who was at the keyboard, or whether typed text
was composed or copied in. Overclaiming would be self-defeating — a reader
who catches it overstating discounts all of it.

HTML rather than server-rendered PDF, as with the other exports: a CJK-safe
PDF needs an embedded Unicode font or a headless browser. Print styles are
there so the browser's Save as PDF is the handoff path.

Claude-Session: https://claude.ai/code/session_016Yr6jELuRc7hyzYLccQKZd
2026-07-19 11:45:08 -07:00

121 lines
5.1 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"`
// 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 | 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"
SuggestionTypeMechanics = "mechanics" // deterministic rule-based pass (no LLM)
SuggestionStatusPending = "pending"
SuggestionStatusAccepted = "accepted"
SuggestionStatusRejected = "rejected"
)