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:
@@ -41,6 +41,9 @@ export interface Document {
|
||||
word_count: number
|
||||
created_at: string
|
||||
updated_at: string
|
||||
// When true, this document's automatic snapshots are never pruned, so its
|
||||
// full writing trail survives as authorship evidence (see the passport).
|
||||
preserve_history: boolean
|
||||
}
|
||||
|
||||
// Fields the editor sends on auto-save. All optional so a rename can send title
|
||||
@@ -51,6 +54,7 @@ export interface DocUpdate {
|
||||
content_text?: string
|
||||
tone?: string
|
||||
word_count?: number
|
||||
preserve_history?: boolean
|
||||
}
|
||||
|
||||
// One sense of a word from the offline dictionary.
|
||||
@@ -219,6 +223,11 @@ export const api = {
|
||||
// the given format. A one-click "download all my writing" safety net.
|
||||
exportAllUrl: (format: ExportFormat) => `/api/docs/export-all?format=${format}`,
|
||||
|
||||
// Download URL for the writing passport: a standalone HTML report of how this
|
||||
// document was written (timeline, growth, sessions), for showing someone who
|
||||
// questions its authorship. Print to PDF from the browser to hand it over.
|
||||
passportUrl: (id: string) => `/api/docs/${id}/passport`,
|
||||
|
||||
// Offline word lookup (gloss + definition + synonyms) for the right-click popover.
|
||||
lookupWord: (word: string) => req<WordInfo>(`/word/${encodeURIComponent(word)}`),
|
||||
// Lightweight Chinese-only gloss for the inline hover/select tooltip — instant
|
||||
|
||||
@@ -42,6 +42,8 @@ export function HistoryPanel({ docId, onClose, onRestored }: Props) {
|
||||
const [selected, setSelected] = useState<DocumentVersion | null>(null)
|
||||
const [preview, setPreview] = useState<DocumentVersion | null>(null)
|
||||
const [busy, setBusy] = useState(false)
|
||||
// Null until the document loads; the passport controls stay inert until then.
|
||||
const [preserve, setPreserve] = useState<boolean | null>(null)
|
||||
const panelRef = useFocusTrap<HTMLElement>()
|
||||
|
||||
const load = useCallback(async () => {
|
||||
@@ -57,6 +59,25 @@ export function HistoryPanel({ docId, onClose, onRestored }: Props) {
|
||||
void load()
|
||||
}, [load])
|
||||
|
||||
// The keep-full-history flag lives on the document, not on its snapshots.
|
||||
useEffect(() => {
|
||||
void api
|
||||
.getDoc(docId)
|
||||
.then((d) => setPreserve(d.preserve_history))
|
||||
.catch(() => setPreserve(null))
|
||||
}, [docId])
|
||||
|
||||
const togglePreserve = useCallback(async () => {
|
||||
if (preserve === null) return
|
||||
const next = !preserve
|
||||
setPreserve(next) // optimistic; revert if the save fails
|
||||
try {
|
||||
await api.updateDoc(docId, { preserve_history: next })
|
||||
} catch {
|
||||
setPreserve(!next)
|
||||
}
|
||||
}, [docId, preserve])
|
||||
|
||||
// Escape closes the drawer.
|
||||
useEffect(() => {
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
@@ -231,6 +252,51 @@ export function HistoryPanel({ docId, onClose, onRestored }: Props) {
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Writing passport: turn this history into something you can show
|
||||
someone who doubts you wrote this yourself. */}
|
||||
<div
|
||||
className="shrink-0 px-4 py-3"
|
||||
style={{ borderTop: '1px solid var(--color-border)' }}
|
||||
>
|
||||
<a
|
||||
href={api.passportUrl(docId)}
|
||||
download
|
||||
className="flex w-full items-center justify-center gap-1.5 rounded-full py-2.5 text-sm font-extrabold"
|
||||
style={{
|
||||
border: '1.5px solid var(--color-accent)',
|
||||
color: 'var(--color-accent)',
|
||||
}}
|
||||
onMouseEnter={(e) => (e.currentTarget.style.background = 'var(--color-surface-alt)')}
|
||||
onMouseLeave={(e) => (e.currentTarget.style.background = 'transparent')}
|
||||
>
|
||||
📜 写作证明 · Writing passport
|
||||
</a>
|
||||
<div className="mt-1.5 text-center text-[11px]" style={{ color: 'var(--color-muted)' }}>
|
||||
A report showing how this draft grew, session by session.
|
||||
</div>
|
||||
|
||||
<label
|
||||
className="mt-3 flex cursor-pointer items-start gap-2 text-[11px]"
|
||||
style={{ color: 'var(--color-muted)' }}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={preserve ?? false}
|
||||
disabled={preserve === null}
|
||||
onChange={togglePreserve}
|
||||
className="mt-0.5 shrink-0"
|
||||
style={{ accentColor: 'var(--color-accent)' }}
|
||||
/>
|
||||
<span>
|
||||
<span className="font-bold" style={{ color: 'var(--color-plum)' }}>
|
||||
保留完整历史 · Keep full history
|
||||
</span>
|
||||
<br />
|
||||
Never delete old snapshots of this document, so the record stays complete.
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user