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
This commit is contained in:
@@ -33,6 +33,7 @@ func (h *Handler) versionRoutes(r chi.Router) {
|
||||
r.Post("/{id}/versions", h.createVersion) // explicit "save a restore point"
|
||||
r.Get("/{id}/versions/{vid}", h.getVersion) // full body for preview
|
||||
r.Post("/{id}/versions/{vid}/restore", h.restoreVersion)
|
||||
r.Get("/{id}/passport", h.passport) // authorship report over that history
|
||||
}
|
||||
|
||||
// listVersions returns the document's snapshots, newest first, without the heavy
|
||||
@@ -202,20 +203,73 @@ func (h *Handler) maybeAutoSnapshot(doc db.Document) error {
|
||||
|
||||
// insertVersion writes a snapshot row of the given kind and returns it (without
|
||||
// the heavy content fields, matching the list shape).
|
||||
//
|
||||
// The row is linked into the document's hash chain: it carries the previous
|
||||
// snapshot's hash, and its own hash covers that link plus its content. The hash
|
||||
// can only be computed once the database has assigned created_at, so the insert
|
||||
// and the hash write share a transaction — a snapshot is never visible with a
|
||||
// hash that doesn't cover its own timestamp.
|
||||
func (h *Handler) insertVersion(doc db.Document, kind string) (db.DocumentVersion, error) {
|
||||
tx, err := h.DB.Begin()
|
||||
if err != nil {
|
||||
return db.DocumentVersion{}, err
|
||||
}
|
||||
defer tx.Rollback() //nolint:errcheck // no-op once committed
|
||||
|
||||
// Chain onto the newest existing snapshot. created_at has second
|
||||
// granularity, so rowid breaks ties in true insertion order; verification
|
||||
// walks the same ordering in reverse.
|
||||
var prevHash string
|
||||
err = tx.QueryRow(
|
||||
`SELECT content_hash FROM document_versions
|
||||
WHERE doc_id = ? ORDER BY created_at DESC, rowid DESC LIMIT 1`,
|
||||
doc.ID,
|
||||
).Scan(&prevHash)
|
||||
if err != nil && !errors.Is(err, sql.ErrNoRows) {
|
||||
return db.DocumentVersion{}, err
|
||||
}
|
||||
|
||||
var v db.DocumentVersion
|
||||
err := h.DB.QueryRow(
|
||||
`INSERT INTO document_versions (doc_id, title, content, content_text, word_count, kind)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
if err := tx.QueryRow(
|
||||
`INSERT INTO document_versions (doc_id, title, content, content_text, word_count, kind, prev_hash)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||
RETURNING id, doc_id, title, word_count, kind, created_at`,
|
||||
doc.ID, doc.Title, doc.Content, doc.ContentText, doc.WordCount, kind,
|
||||
).Scan(&v.ID, &v.DocID, &v.Title, &v.WordCount, &v.Kind, &v.CreatedAt)
|
||||
return v, err
|
||||
doc.ID, doc.Title, doc.Content, doc.ContentText, doc.WordCount, kind, prevHash,
|
||||
).Scan(&v.ID, &v.DocID, &v.Title, &v.WordCount, &v.Kind, &v.CreatedAt); err != nil {
|
||||
return db.DocumentVersion{}, err
|
||||
}
|
||||
|
||||
v.PrevHash = prevHash
|
||||
v.ContentHash = chainHash(prevHash, v.DocID, v.CreatedAt, v.WordCount, doc.ContentText)
|
||||
if _, err := tx.Exec(
|
||||
`UPDATE document_versions SET content_hash = ? WHERE id = ?`, v.ContentHash, v.ID,
|
||||
); err != nil {
|
||||
return db.DocumentVersion{}, err
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
return db.DocumentVersion{}, err
|
||||
}
|
||||
return v, nil
|
||||
}
|
||||
|
||||
// pruneAutoVersions trims a document's 'auto' snapshots to the newest
|
||||
// maxAutoVersions, leaving 'manual' and 'pre_restore' restore points intact.
|
||||
//
|
||||
// Documents flagged preserve_history are exempt entirely: their history is
|
||||
// authorship evidence, and evidence with the oldest entries dropped is exactly
|
||||
// the part a reader would want — the early, sparse, figuring-it-out edits that
|
||||
// distinguish writing from pasting.
|
||||
func (h *Handler) pruneAutoVersions(docID string) error {
|
||||
var preserve bool
|
||||
if err := h.DB.QueryRow(
|
||||
`SELECT preserve_history FROM documents WHERE id = ?`, docID,
|
||||
).Scan(&preserve); err != nil {
|
||||
return err
|
||||
}
|
||||
if preserve {
|
||||
return nil
|
||||
}
|
||||
|
||||
_, err := h.DB.Exec(
|
||||
`DELETE FROM document_versions
|
||||
WHERE doc_id = ? AND kind = 'auto'
|
||||
|
||||
Reference in New Issue
Block a user