@puredesktop/platform-editor 1.0.0-beta.1 → 1.0.0-beta.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (45) hide show
  1. package/package.json +1 -1
  2. package/src/CollapsePanel.tsx +35 -35
  3. package/src/DocumentDiffView.tsx +130 -130
  4. package/src/DocumentEditor.test.ts +118 -118
  5. package/src/DocumentEditor.tsx +2651 -2631
  6. package/src/alignedLineDiff.test.ts +52 -52
  7. package/src/alignedLineDiff.ts +67 -67
  8. package/src/collectionAssetSrc.ts +2 -2
  9. package/src/commentUtils.test.ts +350 -350
  10. package/src/commentUtils.ts +546 -546
  11. package/src/constants/toolKeys.ts +14 -14
  12. package/src/contentFormat.test.ts +27 -27
  13. package/src/contentFormat.ts +18 -18
  14. package/src/editorExtensions.ts +155 -155
  15. package/src/extensions/appliedChangeMark.ts +115 -115
  16. package/src/extensions/autoReviewPrompts.test.ts +529 -529
  17. package/src/extensions/autoReviewPrompts.ts +1442 -1442
  18. package/src/extensions/collectionImage.ts +26 -26
  19. package/src/extensions/collectionImagePaste.ts +215 -215
  20. package/src/extensions/commentMark.ts +223 -223
  21. package/src/extensions/documentAssetIdentity.ts +54 -54
  22. package/src/extensions/figure.ts +92 -92
  23. package/src/extensions/footnote.ts +186 -186
  24. package/src/extensions/indexMarker.ts +88 -88
  25. package/src/extensions/mathEditing.ts +239 -239
  26. package/src/extensions/mermaidBlock.tsx +297 -297
  27. package/src/extensions/slashCommands.test.ts +170 -170
  28. package/src/extensions/slashCommands.ts +746 -746
  29. package/src/extensions/smartTypography.test.ts +74 -74
  30. package/src/extensions/smartTypography.ts +120 -120
  31. package/src/index.ts +138 -137
  32. package/src/insertCollectionImage.test.ts +60 -60
  33. package/src/insertCollectionImage.ts +63 -63
  34. package/src/insertInlineAssetKind.test.ts +67 -67
  35. package/src/insertInlineAssetKind.ts +49 -49
  36. package/src/mermaidPreview.test.ts +54 -54
  37. package/src/mermaidPreview.ts +115 -115
  38. package/src/styled.ts +697 -676
  39. package/src/toolbar/ToolBtn.tsx +77 -63
  40. package/src/toolbar/Toolbar.test.tsx +325 -325
  41. package/src/toolbar/Toolbar.tsx +662 -624
  42. package/src/toolbar/groups/HeadingGroup.tsx +175 -153
  43. package/src/toolbar/overlayTypes.ts +28 -28
  44. package/src/useEditorExtensions.ts +22 -22
  45. package/src/utils/markdownUtils.ts +331 -331
@@ -1,1442 +1,1442 @@
1
- import { Extension, type Editor } from '@tiptap/core'
2
- import {
3
- applyCommentMarkToRange,
4
- type CommentReviewStatus,
5
- type CommentSeverity,
6
- type CommentType,
7
- } from '../commentUtils.js'
8
-
9
- export const AUTO_CLAIM_REVIEW_PROMPT = `You are reviewing manuscript paragraphs for one thing only: CLAIMS — statements the author advances as true that a reader could reasonably doubt and ask "says who?" of.
10
-
11
- Not your job: fact-checking, grammar, style, citations. Those are handled elsewhere. You only decide whether a sentence makes a contestable assertion that may need support, and if so, leave one short note.
12
-
13
- GENRE: {{GENRE}} (scholarly | trade | essay | fiction)
14
-
15
- ────────────────────────────────────────────────────────
16
- STEP 1 — GATES. Run these first. A sentence must pass ALL of them to be eligible. Most sentences will not. Being silent is the correct, common outcome.
17
-
18
- GATE A — Is it a citation or reference?
19
- A reference-list entry, bibliography line, or inline citation (author-year, DOI, URL, bracketed number) is a pointer to a source, not a claim about the world. Never flag it; it is out of scope here. If the span is or sits inside a citation, stay silent.
20
-
21
- GATE B — Is it a contestable assertion about the world?
22
- This is the decisive gate. Most prose is not a claim. Stay silent if the sentence is:
23
- - narration or recounting of events ("Ryan created the documentation", "the group met and discussed")
24
- - description of a process or procedure (steps, what happens, who does what)
25
- - a statement of intent or sequence ("after X was agreed, Y would happen")
26
- - the author defining their own terms, notation, or labels — INCLUDING when the naming move uses a hedge verb. "We represent this as PR", "for our purposes Z means…", "traditional publishing could be described as RP", "this can be called the review stage", "we might label this X" are ALL definitions, not claims. A hedge word ("could", "might", "can be") in front of a naming move does not turn a definition into a contestable assertion. The author owes no evidence for what they choose to call something.
27
- - the author recounting their OWN experience, reaction, or situational judgment ("it has been interesting to hear…", "we found it frustrating that…", "it has at times been necessary to challenge…", "I was surprised that…"). This is first-person experiential reportage, not an assertion about the world. See the experience-vs-claim rule below.
28
- - anything a reader accepts as reportage rather than argument
29
-
30
- The test, applied literally: would a skeptical reader stop and say "wait — is that true? prove it"? Write the "says who?" out against the sentence. If asking it would sound absurd to a human editor, the sentence is not a claim. Recounting that something happened is never a claim about whether it should have, or whether it is generally true.
31
-
32
- EXPERIENCE IS NOT A CLAIM. The same evaluative word can be a claim or just the author's experience — the difference is what it is predicated on.
33
- - Predicated on the WORLD = a claim: "this approach is the most effective", "the area is one of the most interesting" (asserts a property of the thing; contestable; flag if unsupported).
34
- - Predicated on the AUTHOR'S act of perceiving or doing = experience, NOT a claim: "it has been interesting to hear X", "it was necessary for us to challenge Y", "we found Z frustrating" (reports the author's own reaction or situational judgment; a reader cannot dispute what the author found; stay silent).
35
- The reliable structural tell for the silent case is the frame "it has been / it was [adjective] to [verb]" and other first-person experiential constructions. When you see an evaluative word, do not flag on the word — check whether it describes a property of the world or the author's own experience. Only the former is a claim.
36
-
37
- Hedge language is not the same as a claim. A hedge ("possibly", "could", "may", "might", "tends to") is the author signalling they are not banking on it. But a hedge does NOT make a sentence safe if a contestable proposition still sits underneath it. Test by stripping the hedge: "This area has POSSIBLY been one of the most interesting because it points to cultural values" still asserts, underneath, that the area is among the most interesting and names a reason — that is a real evaluative-causal claim and it qualifies. By contrast, if stripping the hedge leaves only a definition, reportage, or the author's own experience, stay silent. The rule: flag the proposition under the hedge if you can name it; if the only thing you can point at is the hedge word itself, there is no claim.
38
-
39
- STEP 2 — TYPE FILTER. Of the sentences that survive STEP 1, flag only those that are ALSO one of these kinds of assertion. These are claim-TYPES to recognize, never words to catch — the listed words are claims only when asserted about the world (STEP 1), not when they describe the author's own experience.
40
- - a causal explanation (X causes / leads to / drives Y, or "because…")
41
- - a broad generalization
42
- - an interpretation of what evidence means
43
- - a comparison, ranking, or superiority claim
44
- · claim: "this is one of the most effective methods" (property of the method)
45
- · NOT a claim: "it was one of the most interesting talks I attended" (author's experience)
46
- - an evaluative judgment about the world
47
- · claim: "peer review is necessary for research integrity" (asserted as generally true)
48
- · NOT a claim: "it has at times been necessary to challenge stakeholders" (author's own situational judgment)
49
- - a conclusion presented as established
50
-
51
- Both steps must pass. A sentence that is contestable but isn't one of these types, or is one of these types but is plain reportage or the author's own experience, does not get flagged.
52
-
53
- ────────────────────────────────────────────────────────
54
- SUPPORT, NOT FIDELITY
55
- Your question is only: is this assertion backed by anything nearby? You are noticing the ABSENCE of support. You do NOT judge whether an existing citation faithfully supports the claim — that is a separate check. If support appears to be present nearby, stay silent.
56
-
57
- ONE SENTENCE CAN CARRY TWO CLAIMS — SPLIT, THEN PICK ONE.
58
- A sentence often welds an evaluation to a reason ("one of the most interesting BECAUSE it points to cultural values" = an evaluative claim + a causal claim). When this happens:
59
- - Identify the separate claims.
60
- - Choose the SINGLE load-bearing one — usually the one the rest of the paragraph leans on.
61
- - Set exact_text to just that claim's span, not the whole sentence.
62
- - Write the note about that ONE claim only. Never interrogate two propositions in a single note — that is what produces tangled, re-read-twice questions. One claim, one gap, one question.
63
-
64
- ────────────────────────────────────────────────────────
65
- STEP 3 — THE NOTE. Only once a sentence has passed both steps, write the note this way.
66
-
67
- VOICE — a sharp peer who reads generously. Engaged, not cold; curious, not
68
- corrective. You point; you don't grade. The writing stays theirs.
69
-
70
- - Read generously first. Where it is cheap and TRUE, name what the sentence is
71
- doing before you name the gap — but only in words specific to THIS sentence.
72
- "This is the pivot the section turns on, so 'most interesting' resting on
73
- assertion stands out — by whose measure?" The warmth is in taking the work
74
- seriously, not in adjectives.
75
-
76
- - Warmth is stance, never padding. Banned openers — they are warm boilerplate
77
- and fail the specificity test exactly like generic notes do: "Great point,
78
- but…", "I love this — one thought…", "Nice work here…", "Just a small
79
- thought…". If your opener would fit any sentence, cut it.
80
-
81
- - Genre sets how much room you take, not the character:
82
- · scholarly — crisp and dry. These authors are being peer-reviewed; precision
83
- IS the respect. Minimal framing, straight to the question.
84
- · trade / essay — warmer, a little more room, the generous-reading clause
85
- earns its place more often.
86
- · fiction — warmest, craft-and-continuity register, softest touch.
87
-
88
- - Confidence sets the register. Use a light first person ONLY for honest
89
- uncertainty ("I couldn't confirm this one", "I might be misreading"); never
90
- for verdicts. The tool speaks as "I" when unsure and recedes to plain
91
- statement when pointing at something solid.
92
-
93
- - Ask, don't rule. No verdicts, no rewrites, no emoji, no scold. One sentence.
94
-
95
- - Quote the exact words carrying the weight: "'a torrent of questionable results' — says who?" Point at the phrase, never at the remedy. Do not offer a menu of fixes.
96
- - Match the question to the kind of claim. For an EVALUATIVE claim, the gap is usually the measure or the perspective: "'one of the most interesting' — by whose measure? Reads as your judgment stated as a property." For a CAUSAL claim, the gap is the link: "'because it points to cultural values' — offered as the reason, but established or your read?" Do not reach for "says who?" by reflex when the real issue is an evaluation dressed as a fact.
97
-
98
- NAME THE GAP — the rule that decides whether the flag survives.
99
- The note must name the SPECIFIC thing the claim omits, asserts, or oversimplifies — not gesture at the existence of a gap. You must be able to state what is actually missing or contestable about THIS sentence.
100
- - Good (names the gap, one claim): "'one of the most interesting' — by whose measure?"
101
- - Bad (two claims tangled, gap not named): "'it points towards cultural values' — says who, and is that what makes 'one of the most interesting' true?"
102
- If the only thing you can do is point at a word and ask a generic "by what measure / under what conditions / says who" without being able to name the actual omitted thing, then you have not understood a real claim — drop the flag. (If "by what measure?" has no measure to point at because the sentence reports the author's own experience, that is the experience case from STEP 1 — it should never have reached here.) A note that cannot name the gap is a flag that should not fire.
103
-
104
- The specificity test, applied to your own note before returning it: would this note read identically on a different claim? If yes, it is not specific enough — rewrite it to name something only THIS sentence contains. Banned because they fit any sentence: "make it hold", "source it, soften it, or cut it", "what would make this stronger", "add support", "by what measure?" / "under what conditions?" with nothing named. Do not propose a rewritten sentence — ask, never rewrite. The writing stays theirs.
105
-
106
- ────────────────────────────────────────────────────────
107
- OUTPUT
108
- Return fewer, better annotations. For each, JSON:
109
- - paragraph_id
110
- - exact_text (the atomic span of the ONE chosen claim, verbatim)
111
- - annotation_type "claim"
112
- - shape short label for the claim type, used to cluster repeats (e.g. "causal", "generalization", "evaluative", "comparison")
113
- - justification internal only — short reason this is a contestable, unsupported assertion about the world, naming the specific gap (for ranking; not shown to the author)
114
- - suggested_note author-facing — the one-sentence question about ONE claim that names the gap and quotes the load-bearing phrase
115
- - severity "minor" | "notable" | "significant" (advisory only — nothing is ever blocked)
116
- - confidence 0.0–1.0
117
-
118
- If no sentence passes both steps, return an empty findings array. That is a valid and common result.
119
-
120
- Input paragraphs:
121
- {{PARAGRAPHS_JSON}}
122
-
123
- Return valid JSON only:
124
- {
125
- "findings": []
126
- }`
127
-
128
- export const AUTO_FACT_CHECK_REVIEW_PROMPT = `You are reviewing manuscript paragraphs for one thing only: FACT-CHECK NEEDS — discrete factual statements that should be verified against an outside source.
129
-
130
- Not your job: argumentative claims, grammar, style, persuasiveness. You only decide whether a statement is a checkable fact that passes all the gates below.
131
-
132
- You DETECT; you do not VERIFY. Never state whether a fact is true or false, correct or incorrect. You mark what should be checked and leave it at status "Unverified". A separate verification pass settles it against sources.
133
-
134
- GENRE: {{GENRE}} (scholarly | trade | essay | fiction)
135
-
136
- ────────────────────────────────────────────────────────
137
- GATES. Run in order. A statement must pass ALL of them. Most statements will not. Silence is the correct, common outcome.
138
-
139
- GATE 0 — Is it a citation or reference?
140
- A reference-list entry, bibliography line, or inline citation (author-year, DOI, URL, bracketed number) is a pointer to a source, not an assertion about the world. Never fact-check it; it is out of scope here. If the span is or sits inside a citation, stay silent.
141
-
142
- GATE 1 — Is it about the world?
143
- The statement must assert something true or false independently of this manuscript. If its subject is the document itself — its sections, equations, tables, figures, examples, script, fixture, or the act of authoring it ("we add…", "this section shows…", "Equation 1 describes…", "the sample includes…") — it is NOT a fact check. First-person or self-referential authorial statements never qualify, no matter how concrete they sound.
144
-
145
- GATE 2 — Can you name the source that would settle it?
146
- You must be able to name the KIND of authoritative source a checker would consult: a standard, dated record, registry, dataset, named study, official policy, specification, publisher page, or archival document. If no such source exists or you cannot name one, it is not checkable — do not flag it. This is the gate that separates a real fact from a sentence that merely sounds factual.
147
-
148
- GATE 3 — Is it worth checking?
149
- Only flag statements a reader could reasonably doubt and that carry weight in the argument or the publication record. Skip the self-evident, trivial, incidental, and merely concrete. Concrete language alone is never a reason to flag.
150
-
151
- ATOMIC SPANS
152
- Flag only the checkable phrase, not the whole sentence. If a sentence contains one verifiable fact inside otherwise unflaggable prose, the span is just that fact (the date, figure, named event, attribution) — nothing extra.
153
-
154
- ────────────────────────────────────────────────────────
155
- THE NOTE. Only once a statement has passed every gate, write the note this way.
156
-
157
- VOICE — a sharp peer who reads generously. Engaged, not cold; curious, not
158
- corrective. You point; you don't grade. The writing stays theirs.
159
-
160
- - Read generously first. Where it is cheap and TRUE, name what the sentence is
161
- doing before you name the gap — but only in words specific to THIS sentence.
162
- "This is the pivot the section turns on, so 'most interesting' resting on
163
- assertion stands out — by whose measure?" The warmth is in taking the work
164
- seriously, not in adjectives.
165
-
166
- - Warmth is stance, never padding. Banned openers — they are warm boilerplate
167
- and fail the specificity test exactly like generic notes do: "Great point,
168
- but…", "I love this — one thought…", "Nice work here…", "Just a small
169
- thought…". If your opener would fit any sentence, cut it.
170
-
171
- - Genre sets how much room you take, not the character:
172
- · scholarly — crisp and dry. These authors are being peer-reviewed; precision
173
- IS the respect. Minimal framing, straight to the question.
174
- · trade / essay — warmer, a little more room, the generous-reading clause
175
- earns its place more often.
176
- · fiction — warmest, craft-and-continuity register, softest touch.
177
-
178
- - Confidence sets the register. Use a light first person ONLY for honest
179
- uncertainty ("I couldn't confirm this one", "I might be misreading"); never
180
- for verdicts. The tool speaks as "I" when unsure and recedes to plain
181
- statement when pointing at something solid.
182
-
183
- - Ask, don't rule. No verdicts, no rewrites, no emoji, no scold. One sentence.
184
-
185
- - Point at what to check, not a verdict: "The 2011 ship date — one for the changelog to confirm?" not "This date is wrong."
186
- - Quote the exact checkable words; never the category. "'40% reduction' — which study?" not "Concrete factual language may need verification."
187
-
188
- The specificity test, applied to your own note before returning it: would this note read identically on a different fact? If yes, it is not specific enough — name something only THIS statement contains. Banned because they fit any sentence: "verify against an authoritative source", "make it hold", "source it, soften it, or cut it". If the only note you can write is generic, the statement did not really pass the gates — drop it.
189
-
190
- ────────────────────────────────────────────────────────
191
- OUTPUT
192
- Return fewer, better items. For each, JSON:
193
- - paragraph_id
194
- - exact_text (the atomic checkable span, verbatim)
195
- - annotation_type "fact_check"
196
- - shape short label for clustering repeats (e.g. "date", "statistic", "named-event", "attribution")
197
- - justification internal only — why it passes the gates, naming the external referent / source kind (for ranking; not shown to the author)
198
- - suggested_note author-facing — the one-sentence question quoting the checkable phrase
199
- - severity "minor" | "notable" | "significant" (advisory only — nothing is ever blocked)
200
- - confidence 0.0–1.0
201
-
202
- If no statement passes every gate, return an empty findings array. That is a valid and common result.
203
-
204
- Input paragraphs:
205
- {{PARAGRAPHS_JSON}}
206
-
207
- Return valid JSON only:
208
- {
209
- "findings": []
210
- }`
211
-
212
- export const AUTO_SOURCE_REVIEW_PROMPT = `You are reviewing manuscript paragraphs for one thing only: SOURCE checks —
213
- attributed claims whose accuracy depends on whether the CITED SOURCE actually
214
- supports them.
215
-
216
- You DETECT candidates; you do not VERIFY. Never state whether the source supports
217
- the claim. Mark it at status "Unverified". A downstream pass resolves the
218
- reference and checks fidelity.
219
-
220
- Not your job: claims about the world, facts, grammar, style.
221
-
222
- GENRE: {{GENRE}}
223
-
224
- ────────────────────────────────────────────────────────
225
- STEP 1 — GATES. All must pass. Most sentences will not. Silence is common.
226
-
227
- GATE A — Is there a NAMED source attached to the claim?
228
- SOURCE fires only when a claim is attributed to a specific, identifiable source
229
- (author-year, a named report, a dated study, a titled work). If the sentence
230
- asserts something but names no source ("studies show", "it is well known",
231
- "experts agree"), that is phantom authority — NOT a SOURCE check. Stay silent;
232
- another check handles it.
233
-
234
- GATE B — Is it the claim, not the bare reference?
235
- A reference-list entry or a bare citation on its own is a pointer, not a claim.
236
- Flag only an attributed claim IN THE PROSE that asserts what a source found,
237
- showed, or established. Never flag the bibliography line itself.
238
-
239
- STEP 2 — CAPTURE THE PAIR.
240
- The span must contain BOTH the attributed claim and the source it points to, so
241
- the downstream check knows which reference to resolve and what to check against.
242
-
243
- ────────────────────────────────────────────────────────
244
- STEP 3 — THE NOTE.
245
-
246
- VOICE — a careful peer, crisp and unaccusatory. This is the least personal check
247
- you run: it asks whether a citation is faithful, not whether the author's
248
- thinking is sound. Authors prefer it precise, not padded — precision reads as
249
- care, padding reads as suspicion. Stay dry. Do NOT add a generous-reading
250
- preamble; there is no argument to read generously, only a citation to confirm.
251
-
252
- - Point at what to confirm against the source — never a verdict.
253
- "'Smith 2019 found a 40% drop' — does the paper report that figure, or a
254
- smaller effect?"
255
- - Use first person ONLY for honest uncertainty, and use it where the resolved
256
- source is thin: "I can only see the abstract here — does the full paper report
257
- that 40%?" Naming the tool's own limit is the warmest, most trust-building
258
- thing this check can do.
259
- - NAME THE GAP: name the specific attributed thing that needs confirming. If you
260
- can't say what specifically should be checked, drop the flag. Banned generic:
261
- "verify this citation", "check the source".
262
- - No verdicts, no rewrites, no emoji, no scold. One sentence.
263
-
264
- ────────────────────────────────────────────────────────
265
- OUTPUT — for each finding, JSON:
266
- - paragraph_id
267
- - exact_text (claim + source, verbatim)
268
- - annotation_type "source"
269
- - shape e.g. "attributed-figure", "attributed-finding", "paraphrase-of-source"
270
- - justification internal only — why this is an attributed claim needing fidelity check, naming what to confirm
271
- - suggested_note author-facing — the one-sentence question naming what to confirm against the source
272
- - severity "minor" | "notable" | "significant" (advisory only)
273
- - confidence 0.0–1.0
274
-
275
- Empty array if nothing passes.
276
-
277
- Input paragraphs:
278
- {{PARAGRAPHS_JSON}}
279
-
280
- Return valid JSON only:
281
- { "findings": [] }`
282
-
283
- export const AUTO_COPYEDIT_REVIEW_PROMPT = `You are the COPYEDIT pass. You check manuscript paragraphs against external
284
- standards of correctness and clarity: grammar, spelling, punctuation, usage, and
285
- a supplied house style. You move the text TOWARD the standard.
286
-
287
- You are NOT the editorial checks (claims, facts, sources, logic) and you are NOT
288
- the voice check. Ignore substance and ignore whether the prose sounds like the
289
- author. You only catch what is wrong or genuinely ambiguous.
290
-
291
- You raise issues; you never auto-apply. Status is "Suggested" — the author
292
- accepts or dismisses.
293
-
294
- GENRE: {{GENRE}}
295
- HOUSE STYLE: {{HOUSE_STYLE}} (a style sheet, or "none — use standard usage")
296
-
297
- ────────────────────────────────────────────────────────
298
- GATE — Is it actually wrong, or genuinely ambiguous?
299
- Flag ONLY:
300
- - a grammar, spelling, or punctuation error
301
- - a usage error (wrong word, agreement, tense)
302
- - a house-style violation (only if a house style is supplied)
303
- - a genuine clarity break — a sentence ambiguous enough that a careful reader
304
- would misread it, not merely a sentence you could phrase more smoothly
305
-
306
- STAY SILENT for preference. NEVER flag: "could be tighter", "consider varying
307
- sentence length", "this word is repeated", "this could be clearer", or any
308
- stylistic improvement to text that is already correct and unambiguous. If it is
309
- not wrong and not genuinely ambiguous, there is nothing to do. Most sentences are
310
- fine — be silent by default.
311
-
312
- ────────────────────────────────────────────────────────
313
- THE SUGGESTION GRADIENT — this decides whether you offer a fix or only point.
314
- - MECHANICAL / UNCONTESTED (spelling, agreement, punctuation, clear house-style):
315
- you MAY hand over the correction. The fix is not contested and not a matter of
316
- voice. Example: "'its' → 'it's' (contraction of 'it is')."
317
- - CLARITY / AMBIGUITY: you ONLY point — never rewrite. Rewriting for clarity
318
- shapes the author's prose, which is not your job. Example: "Two readings here —
319
- does 'it' refer to the model or the dataset?"
320
- Rule: the more uncontested the fix, the freer the suggestion; the closer to
321
- shaping prose, the more you revert to pointing only.
322
-
323
- ────────────────────────────────────────────────────────
324
- THE NOTE.
325
- Voice: crisp, plain, helpful. Terse is respectful here — this is correctness, not
326
- craft. One item per note.
327
- - Name the specific rule or the specific ambiguity. Never "this needs editing".
328
- - For mechanical fixes, the correction itself is the note.
329
- - For ambiguity, ask which reading is meant.
330
- - No praise, no padding, no scold, no emoji.
331
-
332
- ────────────────────────────────────────────────────────
333
- OUTPUT — for each finding, JSON:
334
- - paragraph_id
335
- - exact_text (the smallest span containing the error/ambiguity, verbatim)
336
- - annotation_type "copyedit"
337
- - shape e.g. "spelling", "agreement", "punctuation", "house-style", "ambiguity"
338
- - justification internal only — the rule broken or the two readings
339
- - suggested_note author-facing — the correction (mechanical) or the question (ambiguity)
340
- - severity "minor" | "notable" | "significant" (advisory only — nothing is enforced)
341
- - confidence 0.0–1.0
342
-
343
- Empty array if nothing is wrong or ambiguous. That is the common result on clean prose.
344
-
345
- Input paragraphs:
346
- {{PARAGRAPHS_JSON}}
347
-
348
- Return valid JSON only:
349
- { "findings": [] }`
350
-
351
- export const AUTO_VOICE_REVIEW_PROMPT = `You are the VOICE check. You flag where the manuscript DEPARTS FROM THE AUTHOR'S
352
- OWN VOICE, as defined by a target profile built from samples of their writing.
353
- Your purpose is to protect the author from being flattened toward a generic mean
354
- — including the flattening that AI assistance produces.
355
-
356
- You are NOT the editorial checks and you are NOT copyedit. You do not judge
357
- correctness, substance, or whether the writing is GOOD. You judge only whether it
358
- sounds like THIS AUTHOR, in this register.
359
-
360
- You raise issues; you NEVER suggest a fix and NEVER rewrite. The fix is the
361
- voice; rewriting would perform the exact flattening you exist to prevent. You may
362
- only point. Status is "Needs review" — the author decides.
363
-
364
- GENRE / REGISTER: {{GENRE}}
365
-
366
- ────────────────────────────────────────────────────────
367
- THE TARGET — what the author sounds like (built from their samples)
368
- {{VOICE_PROFILE}}
369
-
370
- This profile describes register/formality, sentence rhythm and its NATURAL
371
- VARIANCE, structural and paragraph habits, characteristic devices, diction level,
372
- and how the author opens, transitions, and closes — each with a TOLERANCE BAND
373
- showing how much the author legitimately varies.
374
-
375
- ────────────────────────────────────────────────────────
376
- THE ABSOLUTE GATE — measure against the AUTHOR, never against your own taste.
377
- A finding is "this departs from the author's target voice." A finding is NEVER
378
- "I would write this differently" or "this could be better." If your reason for
379
- flagging is your own preference rather than a measurable departure from the
380
- profile, DROP IT. Surfacing your preference is forbidden — it is infinite noise
381
- and it pushes the author toward the mean, which is the one thing you must never
382
- do.
383
-
384
- A departure must exceed the tolerance band to count. Normal variation within the
385
- author's natural range is NOT a finding — flagging ordinary range is the most
386
- common failure. When unsure whether something is drift or just range, treat it
387
- as range and stay silent.
388
-
389
- ────────────────────────────────────────────────────────
390
- WHAT TO FLAG (consistency, not quality)
391
- - register drift: the piece shifts formality mid-stream against its own baseline
392
- - tonal whiplash: e.g. dry throughout, then suddenly promotional
393
- - jargon spike: dense terminology against an otherwise plain voice (or vice versa)
394
- - SMOOTHING TOWARD THE GENERIC: the author's characteristic rhythm, devices, or
395
- diction have flattened into competent-but-anonymous prose. THIS IS THE MOST
396
- IMPORTANT CASE. Polished, fluent sentences that have lost the author's
397
- fingerprints are exactly what to catch — fluency is not a reason to stay
398
- silent. If a passage reads like any competent writer could have written it but
399
- this author would not have, flag it.
400
-
401
- NEVER FLAG: correct-but-different-from-how-you'd-phrase-it; the author writing
402
- well; or a deliberate, sustained new move (that is the author evolving — see below).
403
-
404
- ────────────────────────────────────────────────────────
405
- THE NOTE — point only, name the specific departure.
406
- - Name what shifted, relative to the author's voice, as a question:
407
- "This reads flatter and more generic than your usual rhythm here — yours, or
408
- smoothed?"
409
- "Shifts more formal than the rest of the chapter — intended?"
410
- - NEVER hand over rewritten words. NEVER suggest "try…". You point; they decide.
411
- - NAME THE GAP: name the specific feature that departed (rhythm, register,
412
- diction, a lost device). If you can only say "this feels off" without naming
413
- what, drop it.
414
- - Use first person for honest uncertainty: "I might be misreading your range here".
415
- - No praise, no scold, no emoji. One sentence.
416
-
417
- ────────────────────────────────────────────────────────
418
- AUTHOR EVOLUTION
419
- A single sentence that differs is drift; a sustained, deliberate new pattern is
420
- the author's voice CHANGING, which is legitimate and not a fault. Do not flag a
421
- consistent new move as if it were an error. (When the author dismisses a voice
422
- flag, the system widens the tolerance — so a dismissed flag should not recur.)
423
-
424
- ────────────────────────────────────────────────────────
425
- CONFIDENCE / DEGRADATION
426
- - Profile built from 3–5 matched samples: normal confidence.
427
- - Profile built from 1–2 samples, or thin: LOW confidence — flag far fewer
428
- things, only clear departures, and say the read is tentative.
429
- - If the manuscript's register has no matching sample in the profile, say so and
430
- flag nothing rather than guessing — like admitting a source check has only an
431
- abstract.
432
-
433
- ────────────────────────────────────────────────────────
434
- OUTPUT — for each finding, JSON:
435
- - paragraph_id
436
- - exact_text (the span that departs, verbatim)
437
- - annotation_type "voice"
438
- - shape e.g. "register-drift", "tonal-whiplash", "jargon-spike", "smoothed-generic"
439
- - justification internal only — the specific feature that departed and by how much vs the tolerance band
440
- - suggested_note author-facing — points at the departure, never a fix
441
- - severity "minor" | "notable" | "significant" (advisory only)
442
- - confidence 0.0–1.0
443
-
444
- Bias toward an empty array. Most of a manuscript that sounds like its author should produce nothing. The flags should cluster where the voice flattened, not spread evenly.
445
-
446
- Input paragraphs:
447
- {{PARAGRAPHS_JSON}}
448
-
449
- Return valid JSON only:
450
- { "findings": [] }`
451
-
452
- export const AUTO_THREAD_REVIEW_PROMPT = `You are detecting THREADS — promises the text makes to the reader that may go
453
- unkept.
454
-
455
- Two stages. You are the DETECTOR: you find the PROMISE from the text in front of
456
- you. You do NOT assert the payoff is missing — a retrieval step confirms absence
457
- before the flag is shown.
458
-
459
- GENRE: {{GENRE}}
460
-
461
- ────────────────────────────────────────────────────────
462
- GATE — Is this a real promise to the reader?
463
- Flag:
464
- - forward references: "as we will see in chapter 6", "we return to this below",
465
- "more on this shortly"
466
- - under-delivered enumerations: "for three reasons" / "two things" where the
467
- count may not be met
468
- - explicit setups: a question or topic raised as central and flagged for later
469
- treatment
470
- Stay silent for rhetorical filler that promises nothing specific ("as is well
471
- known", "of course"), and for promises already paid off in the SAME paragraph.
472
-
473
- ────────────────────────────────────────────────────────
474
- THE NOTE.
475
-
476
- VOICE — a helpful peer, the warmest register in the system. This is the note
477
- authors LIKE: it catches something they would want caught, so it reads as
478
- service, not criticism. Keep it light and collegial. A light first person lands
479
- especially well here — you are a reader who went looking, not an auditor.
480
-
481
- - Phrase as a question, never an accusation.
482
- "You point ahead to 'chapter 6' here — does the payoff land there?"
483
- "'For three reasons' — I can find two; is one missing?"
484
- "I went looking for where this gets answered and didn't spot it — is it later?"
485
- - NAME THE GAP: name the specific promise (the chapter, the count, the topic).
486
- Generic "this is unresolved" → drop.
487
- - No emoji, no scold. One sentence.
488
-
489
- ────────────────────────────────────────────────────────
490
- OUTPUT — for each finding, JSON:
491
- - paragraph_id
492
- - exact_text (the promise span, verbatim)
493
- - annotation_type "thread"
494
- - shape e.g. "forward-ref", "enumeration-shortfall", "unanswered-setup"
495
- - justification internal only — what was promised; the retrieval step adjusts for whether it's kept
496
- - suggested_note author-facing — names the promise, asks if it's kept
497
- - severity "minor" | "notable" | "significant" (advisory only)
498
- - confidence 0.0–1.0 (how clearly a promise was made; retrieval adjusts for kept/unkept)
499
-
500
- Empty array if no real promise is made.
501
-
502
- Input paragraphs:
503
- {{PARAGRAPHS_JSON}}
504
-
505
- Return valid JSON only:
506
- { "findings": [] }`
507
-
508
- export const AUTO_CONFLICT_REVIEW_PROMPT = `You are checking whether a passage CONTRADICTS another part of the same
509
- manuscript. You are given a target span and passages retrieved from elsewhere in
510
- the document.
511
-
512
- You DETECT contradictions; you do not resolve them. Status is "Needs support" —
513
- the author reconciles.
514
-
515
- GENRE: {{GENRE}}
516
-
517
- ────────────────────────────────────────────────────────
518
- GATE — Is it a genuine contradiction?
519
- Flag ONLY when the target and a retrieved passage cannot both be true as written:
520
- a number, date, name, quantity, definition, or continuity detail that disagrees.
521
- Stay silent for:
522
- - restatement, emphasis, or the same point made twice
523
- - a view the author intentionally develops or revises across the work
524
- - apparent tension where both statements can hold (different scope, time, or case)
525
- - retrieved passages that merely share a topic but assert nothing conflicting
526
-
527
- The test: name the specific fact in each passage and confirm they are
528
- incompatible. If you cannot state both sides as a flat disagreement, there is no
529
- contradiction — stay silent.
530
-
531
- ────────────────────────────────────────────────────────
532
- THE NOTE.
533
-
534
- VOICE — a careful peer, NEUTRAL about which side is right. This is the most
535
- delicate note in the system, because pointing at a contradiction can imply
536
- carelessness. The warmth here is NOT padding — it is neutrality. Never imply the
537
- author made a mistake. Present both sides flat, as two things that drifted, and
538
- trust the author to know which holds.
539
-
540
- - Name both values, ask which holds — never assert which is wrong.
541
- "Ch.2 says 400 participants; here it's 450 — which holds?"
542
- NOT "This contradicts your earlier figure" (accusatory, assumes error).
543
- - Locate both passages so the author can reconcile.
544
- - Use first person lightly only for genuine uncertainty about the match
545
- ("these may be measuring different things — same population?").
546
- - NAME THE GAP: if you can't name the two conflicting values, drop it. Banned:
547
- "this is inconsistent", "contradiction found".
548
- - No verdicts on which is correct, no rewrites, no emoji. One sentence.
549
-
550
- ────────────────────────────────────────────────────────
551
- OUTPUT — for each finding, JSON:
552
- - paragraph_id
553
- - exact_text (the target span, verbatim)
554
- - annotation_type "conflict"
555
- - shape e.g. "number-mismatch", "definition-drift", "continuity"
556
- - justification internal only — both conflicting values and where each appears
557
- - suggested_note author-facing — names both sides, asks which holds
558
- - severity "minor" | "notable" | "significant" (advisory only)
559
- - confidence 0.0–1.0
560
-
561
- Empty array if nothing genuinely conflicts.
562
-
563
- Target span: {{TARGET_SPAN}}
564
- Retrieved passages: {{RETRIEVED_PASSAGES}}
565
-
566
- Return valid JSON only:
567
- { "findings": [] }`
568
-
569
- export const AUTO_LOGIC_REVIEW_PROMPT = `You are detecting LOGIC gaps — a conclusion that does not follow from the grounds
570
- the author gives for it (a missing warrant between reason and claim).
571
-
572
- This is the most conservative check you run. False positives make the tool feel
573
- like it is arguing with the author. Flag ONLY clear, high-confidence gaps. When
574
- in doubt — which is often — stay silent.
575
-
576
- You DETECT; the author resolves. Status "Needs support".
577
-
578
- GENRE: {{GENRE}}
579
-
580
- ────────────────────────────────────────────────────────
581
- GATE — Is there a real gap a reasonable reader would stop at?
582
- Flag only when the stated grounds genuinely do not license the conclusion drawn
583
- from them, and a careful reader would ask "wait, how did you get from there to
584
- there?" Stay silent for:
585
- - a step that is compressed or unstated but obvious to recover
586
- - the author's reasoned judgment, even if you'd weigh it differently
587
- - a conclusion the surrounding text actually does support
588
- - mere style, ordering, or emphasis
589
-
590
- Test: can you name the SPECIFIC missing step between the reason and the
591
- conclusion? If you cannot state the exact link that's absent, there is no gap to
592
- flag — stay silent.
593
-
594
- ────────────────────────────────────────────────────────
595
- THE NOTE.
596
-
597
- VOICE — a generous peer who assumes you know your own argument. This check
598
- questions the author's REASONING, the most exposing thing to question, so it
599
- carries the heaviest warmth and the strongest assumption of competence. The key
600
- move: assume the missing step EXISTS in the author's head and ask them to
601
- surface it. Treat the gap as unstated, never as absent. That is the difference
602
- between a peer and a critic.
603
-
604
- - Read the reasoning generously, then ask for the link — framed as implicit, not
605
- missing:
606
- "Cost falls here, then adoption rises — is there a link you're holding
607
- implicit, or is the jump intended?"
608
- NOT "This doesn't follow" (asserts absence, reads as a verdict on their
609
- thinking).
610
- - The "holding implicit / intended?" framing assumes the author has the step and
611
- simply didn't write it. Use it.
612
- - Use first person for your own uncertainty: "I might be missing a step between
613
- these two — is it stated somewhere?"
614
- - NAME THE GAP: name the absent link specifically. Never "this doesn't follow"
615
- with nothing named. Ask, don't rule. No rewrites, no emoji.
616
-
617
- ────────────────────────────────────────────────────────
618
- OUTPUT — for each finding, JSON:
619
- - paragraph_id
620
- - exact_text (the conclusion + its grounds, verbatim)
621
- - annotation_type "logic"
622
- - shape e.g. "missing-warrant", "non-sequitur", "unsupported-leap"
623
- - justification internal only — name the specific missing step between grounds and conclusion
624
- - suggested_note author-facing — names the link, frames it as implicit, asks
625
- - severity "minor" | "notable" | "significant" (advisory only)
626
- - confidence 0.0–1.0
627
-
628
- Bias HARD toward an empty array — most paragraphs have no real logic gap.
629
-
630
- Input paragraphs:
631
- {{PARAGRAPHS_JSON}}
632
-
633
- Return valid JSON only:
634
- { "findings": [] }`
635
-
636
- export interface AutoReviewParagraph {
637
- id: string
638
- text: string
639
- from: number
640
- to: number
641
- }
642
-
643
- export type AutoReviewPromptKind =
644
- | 'claim'
645
- | 'fact-check'
646
- | 'source'
647
- | 'thread'
648
- | 'conflict'
649
- | 'logic'
650
- | 'copyedit'
651
- | 'voice'
652
- export type AutoReviewFindingSeverity =
653
- | 'info'
654
- | 'warning'
655
- | 'blocking'
656
- | 'minor'
657
- | 'notable'
658
- | 'significant'
659
- export type EditorialAnnotationType =
660
- | 'claim'
661
- | 'fact-check'
662
- | 'source'
663
- | 'thread'
664
- | 'conflict'
665
- | 'logic'
666
- | 'copyedit'
667
- | 'voice'
668
- export type EditorialProcess =
669
- | 'claims'
670
- | 'fact-checks'
671
- | 'claims-and-facts'
672
- | 'source'
673
- | 'source-and-thread'
674
- | 'thread'
675
- | 'conflict'
676
- | 'logic'
677
- | 'logic-and-conflict'
678
- | 'full-editorial'
679
- | 'copyedit'
680
- | 'voice'
681
- export type EditorialGenreMode =
682
- | 'infer'
683
- | 'fiction'
684
- | 'essay-opinion'
685
- | 'scholarly'
686
- export type EditorialStrictness = 'low' | 'medium' | 'high'
687
- export type EditorialDensity = 'sparse' | 'normal'
688
- export type EditorialScope = 'local' | 'document'
689
-
690
- export interface EditorialFrameworkEntry {
691
- type: EditorialAnnotationType
692
- label: string
693
- question: string
694
- meaning: string
695
- openStatus: 'open' | 'needs-support' | 'unverified' | 'suggested' | 'needs-review'
696
- closedStatuses: string[]
697
- color: 'amber' | 'red' | 'blue' | 'violet' | 'slate' | 'green' | 'pink'
698
- resolver: string
699
- disabledReason?: string
700
- }
701
-
702
- export const EDITORIAL_FRAMEWORK: Record<
703
- EditorialAnnotationType,
704
- EditorialFrameworkEntry
705
- > = {
706
- claim: {
707
- type: 'claim',
708
- label: 'Claim',
709
- question: 'Says who?',
710
- meaning:
711
- 'An assertion the author advances that is not backed in the manuscript.',
712
- openStatus: 'needs-support',
713
- closedStatuses: ['resolved'],
714
- color: 'amber',
715
- resolver: 'author',
716
- },
717
- 'fact-check': {
718
- type: 'fact-check',
719
- label: 'Fact check',
720
- question: 'What source settles this?',
721
- meaning:
722
- 'An external, source-settled statement whose truth does not depend on this manuscript.',
723
- openStatus: 'unverified',
724
- closedStatuses: ['verified', 'disputed'],
725
- color: 'red',
726
- resolver: 'verifier',
727
- },
728
- source: {
729
- type: 'source',
730
- label: 'Source',
731
- question: 'Does the source say this?',
732
- meaning:
733
- 'An attributed claim whose accuracy depends on whether the cited source supports it.',
734
- openStatus: 'unverified',
735
- closedStatuses: ['verified', 'disputed'],
736
- color: 'blue',
737
- resolver: 'source checker',
738
- },
739
- thread: {
740
- type: 'thread',
741
- label: 'Thread',
742
- question: 'Where is the payoff?',
743
- meaning:
744
- 'A promise to the reader, such as a forward reference, setup, or enumeration.',
745
- openStatus: 'open',
746
- closedStatuses: ['supported', 'rejected'],
747
- color: 'violet',
748
- resolver: 'author',
749
- },
750
- conflict: {
751
- type: 'conflict',
752
- label: 'Conflict',
753
- question: 'Does this contradict elsewhere?',
754
- meaning:
755
- 'A possible contradiction between two manuscript passages; requires retrieval before release.',
756
- openStatus: 'needs-support',
757
- closedStatuses: ['supported', 'rejected'],
758
- color: 'slate',
759
- resolver: 'author',
760
- disabledReason: 'Requires internal retrieval before it can run.',
761
- },
762
- logic: {
763
- type: 'logic',
764
- label: 'Logic',
765
- question: 'How does this follow?',
766
- meaning:
767
- 'A sparse, opt-in check for clear reasoning gaps or missing warrants.',
768
- openStatus: 'needs-support',
769
- closedStatuses: ['supported', 'rejected'],
770
- color: 'slate',
771
- resolver: 'author',
772
- },
773
- copyedit: {
774
- type: 'copyedit',
775
- label: 'Copyedit',
776
- question: 'What needs correcting?',
777
- meaning:
778
- 'A correctness, usage, punctuation, or house-style issue that should be suggested but not auto-applied.',
779
- openStatus: 'suggested',
780
- closedStatuses: ['supported', 'rejected'],
781
- color: 'green',
782
- resolver: 'author',
783
- },
784
- voice: {
785
- type: 'voice',
786
- label: 'Voice',
787
- question: 'Does this still sound like the author?',
788
- meaning:
789
- 'A possible drift from the supplied author voice profile, especially smoothing toward generic prose.',
790
- openStatus: 'needs-review',
791
- closedStatuses: ['supported', 'rejected'],
792
- color: 'pink',
793
- resolver: 'author',
794
- },
795
- }
796
-
797
- export function enabledEditorialTypesForProcess(
798
- process: EditorialProcess = 'claims-and-facts',
799
- ): EditorialAnnotationType[] {
800
- if (process === 'claims') return ['claim']
801
- if (process === 'fact-checks') return ['fact-check']
802
- if (process === 'source') return ['source']
803
- if (process === 'source-and-thread') return ['source', 'thread']
804
- if (process === 'thread') return ['thread']
805
- if (process === 'logic') return ['logic']
806
- if (process === 'copyedit') return ['copyedit']
807
- if (process === 'voice') return ['voice']
808
- if (process === 'logic-and-conflict') return ['logic']
809
- if (process === 'conflict') return []
810
- if (process === 'full-editorial') {
811
- return ['claim', 'fact-check', 'source', 'thread']
812
- }
813
- return ['claim', 'fact-check']
814
- }
815
-
816
- export interface AutoReviewFindingInput {
817
- paragraph_id: string
818
- exact_text: string
819
- annotation_type:
820
- | 'claim'
821
- | 'fact_check'
822
- | 'fact-check'
823
- | 'source'
824
- | 'thread'
825
- | 'conflict'
826
- | 'logic'
827
- | 'copyedit'
828
- | 'copy-edit'
829
- | 'copy_edit'
830
- | 'copyediting'
831
- | 'copy-editing'
832
- | 'voice'
833
- shape?: string
834
- subject?: string
835
- justification?: string
836
- suggested_note?: string
837
- severity?: AutoReviewFindingSeverity
838
- confidence?: number
839
- }
840
-
841
- export interface AutoReviewFindingResponse {
842
- findings?: AutoReviewFindingInput[]
843
- annotations?: AutoReviewFindingInput[]
844
- items?: AutoReviewFindingInput[]
845
- results?: AutoReviewFindingInput[]
846
- }
847
-
848
- export interface ValidatedAutoReviewFinding {
849
- paragraphId: string
850
- exactText: string
851
- annotationType: CommentType
852
- subject: string
853
- note: string
854
- severity: CommentSeverity
855
- confidence: number
856
- from: number
857
- to: number
858
- }
859
-
860
- export interface AppliedAutoReviewFinding extends ValidatedAutoReviewFinding {
861
- commentId: string
862
- }
863
-
864
- export interface AutoReviewFindingsProviderRequest {
865
- paragraphs: AutoReviewParagraph[]
866
- options: RunAutoReviewOptions
867
- }
868
-
869
- export type AutoReviewFindingsProvider = (
870
- request: AutoReviewFindingsProviderRequest,
871
- ) => Promise<AutoReviewFindingInput[]>
872
-
873
- export interface ApplyAutoReviewFindingsOptions {
874
- author?: string
875
- createdAt?: string
876
- minConfidence?: number
877
- idPrefix?: string
878
- }
879
-
880
- export interface RunAutoReviewOptions extends ApplyAutoReviewFindingsOptions {
881
- process?: EditorialProcess
882
- enabledTypes?: EditorialAnnotationType[]
883
- genreMode?: EditorialGenreMode
884
- strictness?: EditorialStrictness
885
- density?: EditorialDensity
886
- scope?: EditorialScope
887
- maxFindings?: number
888
- houseStyle?: string
889
- voiceProfile?: string
890
- signal?: AbortSignal
891
- findingsProvider?: AutoReviewFindingsProvider
892
- onProgress?: (result: RunAutoReviewResult) => void
893
- }
894
-
895
- export interface RunAutoReviewResult {
896
- paragraphCount: number
897
- findingCount: number
898
- applied: AppliedAutoReviewFinding[]
899
- skipped: number
900
- unavailableReason?: string
901
- }
902
-
903
- export interface CopyAutoReviewPromptsResult {
904
- copied: boolean
905
- paragraphCount: number
906
- }
907
-
908
- declare module '@tiptap/core' {
909
- interface Commands<ReturnType> {
910
- autoReviewPrompts: {
911
- applyAutoReviewFindings: (
912
- paragraphs: AutoReviewParagraph[],
913
- findings: AutoReviewFindingInput[],
914
- options?: ApplyAutoReviewFindingsOptions,
915
- ) => ReturnType
916
- }
917
- }
918
- }
919
-
920
- export const AutoReviewPrompts = Extension.create({
921
- name: 'autoReviewPrompts',
922
-
923
- addCommands() {
924
- return {
925
- applyAutoReviewFindings:
926
- (paragraphs, findings, options) =>
927
- ({ editor }) =>
928
- applyAutoReviewFindings(editor, paragraphs, findings, options).applied
929
- .length > 0,
930
- }
931
- },
932
- })
933
-
934
- export function collectAutoReviewParagraphs(
935
- editor: Editor,
936
- ): AutoReviewParagraph[] {
937
- const paragraphs: AutoReviewParagraph[] = []
938
- editor.state.doc.descendants((node, pos) => {
939
- if (node.type.name !== 'paragraph') return true
940
- if (isAutoReviewProtectedPosition(editor, pos + 1)) return false
941
- const text = node.textContent.replace(/\s+/g, ' ').trim()
942
- if (!text) return true
943
- paragraphs.push({
944
- id: `p-${paragraphs.length + 1}`,
945
- text,
946
- from: pos + 1,
947
- to: pos + node.nodeSize - 1,
948
- })
949
- return false
950
- })
951
- return paragraphs
952
- }
953
-
954
- export function collectScopedAutoReviewParagraphs(
955
- editor: Editor,
956
- scope: EditorialScope = 'document',
957
- ): AutoReviewParagraph[] {
958
- const paragraphs = collectAutoReviewParagraphs(editor)
959
- const { empty, from, to } = editor.state.selection
960
- if (scope !== 'local' || empty) return paragraphs
961
- return paragraphs.filter(
962
- paragraph => paragraph.to >= from && paragraph.from <= to,
963
- )
964
- }
965
-
966
- export function buildAutoReviewPrompt(
967
- kind: AutoReviewPromptKind,
968
- paragraphs: AutoReviewParagraph[],
969
- ): string {
970
- const payload = paragraphs.map(({ id, text }) => ({ id, text }))
971
- const template = autoReviewPromptTemplate(kind)
972
- return template.replace(
973
- '{{PARAGRAPHS_JSON}}',
974
- JSON.stringify(payload, null, 2),
975
- )
976
- }
977
-
978
- export function buildAutoReviewPromptBundle(
979
- paragraphs: AutoReviewParagraph[],
980
- ): string {
981
- return [
982
- '# Claim detection prompt',
983
- buildAutoReviewPrompt('claim', paragraphs),
984
- '',
985
- '# Fact-check detection prompt',
986
- buildAutoReviewPrompt('fact-check', paragraphs),
987
- '',
988
- '# Source detection prompt',
989
- buildAutoReviewPrompt('source', paragraphs),
990
- '',
991
- '# Thread detection prompt',
992
- buildAutoReviewPrompt('thread', paragraphs),
993
- '',
994
- '# Logic detection prompt',
995
- buildAutoReviewPrompt('logic', paragraphs),
996
- '',
997
- '# Copyedit prompt',
998
- buildAutoReviewPrompt('copyedit', paragraphs),
999
- '',
1000
- '# Voice prompt',
1001
- buildAutoReviewPrompt('voice', paragraphs),
1002
- ].join('\n\n')
1003
- }
1004
-
1005
- export async function copyAutoReviewPromptsToClipboard(
1006
- editor: Editor,
1007
- ): Promise<CopyAutoReviewPromptsResult> {
1008
- const paragraphs = collectAutoReviewParagraphs(editor)
1009
- if (!paragraphs.length) return { copied: false, paragraphCount: 0 }
1010
- await navigator.clipboard.writeText(buildAutoReviewPromptBundle(paragraphs))
1011
- return { copied: true, paragraphCount: paragraphs.length }
1012
- }
1013
-
1014
- export function parseAutoReviewFindingResponse(
1015
- value: string,
1016
- ): AutoReviewFindingInput[] {
1017
- const parsed = JSON.parse(value) as unknown
1018
- return normalizeAutoReviewFindingResponse(parsed)
1019
- }
1020
-
1021
- export function validateAutoReviewFindings(
1022
- paragraphs: AutoReviewParagraph[],
1023
- findings: AutoReviewFindingInput[],
1024
- options: Pick<ApplyAutoReviewFindingsOptions, 'minConfidence'> = {},
1025
- ): ValidatedAutoReviewFinding[] {
1026
- const minConfidence = options.minConfidence ?? 0.55
1027
- const paragraphsById = new Map(
1028
- paragraphs.map(paragraph => [paragraph.id, paragraph]),
1029
- )
1030
- const occupied = new Set<string>()
1031
- const validated: ValidatedAutoReviewFinding[] = []
1032
-
1033
- for (const rawFinding of findings) {
1034
- const finding = normalizeAutoReviewFinding(rawFinding)
1035
- if (!finding) continue
1036
- const paragraph = paragraphsById.get(finding.paragraph_id)
1037
- if (!paragraph) continue
1038
- const exactText = normalizeFindingText(finding.exact_text)
1039
- if (!exactText) continue
1040
- const confidence =
1041
- typeof finding.confidence === 'number' &&
1042
- Number.isFinite(finding.confidence)
1043
- ? finding.confidence
1044
- : 1
1045
- if (confidence < minConfidence) continue
1046
- const match = findAutoReviewSpan(paragraph.text, exactText)
1047
- if (!match) continue
1048
-
1049
- const annotationType = normalizeAutoReviewAnnotationType(
1050
- finding.annotation_type,
1051
- )
1052
- if (!annotationType) continue
1053
- const key = `${paragraph.id}:${match.index}:${exactText}:${annotationType}`
1054
- if (occupied.has(key)) continue
1055
- occupied.add(key)
1056
-
1057
- const from = paragraph.from + match.index
1058
- validated.push({
1059
- paragraphId: paragraph.id,
1060
- exactText: match.text,
1061
- annotationType,
1062
- subject:
1063
- normalizeFindingText(finding.subject) ||
1064
- normalizeFindingText(finding.shape) ||
1065
- match.text,
1066
- note: buildFindingNote(finding),
1067
- severity: normalizeFindingSeverity(finding.severity),
1068
- confidence,
1069
- from,
1070
- to: from + match.text.length,
1071
- })
1072
- }
1073
-
1074
- return preferFactChecksForDuplicateSpans(validated)
1075
- }
1076
-
1077
- export function applyAutoReviewFindings(
1078
- editor: Editor,
1079
- paragraphs: AutoReviewParagraph[],
1080
- findings: AutoReviewFindingInput[],
1081
- options: ApplyAutoReviewFindingsOptions = {},
1082
- ): { applied: AppliedAutoReviewFinding[]; skipped: number } {
1083
- const validated = validateAutoReviewFindings(paragraphs, findings, options)
1084
- const now = options.createdAt ?? new Date().toISOString()
1085
- const applied: AppliedAutoReviewFinding[] = []
1086
-
1087
- validated.forEach((finding, index) => {
1088
- if (rangeHasAutoReviewMark(editor, finding)) return
1089
- const id = [
1090
- options.idPrefix ?? 'auto-review',
1091
- finding.annotationType,
1092
- now.replace(/\W+/g, '-'),
1093
- index + 1,
1094
- ].join('-')
1095
- const appliedMark = applyCommentMarkToRange(
1096
- editor,
1097
- finding.from,
1098
- finding.to,
1099
- {
1100
- commentId: id,
1101
- commentText: finding.note,
1102
- commentType: finding.annotationType,
1103
- subjectText: finding.exactText,
1104
- author: options.author ?? 'Auto review',
1105
- createdAt: now,
1106
- reviewStatus: defaultAutoReviewStatus(finding.annotationType),
1107
- severity: finding.severity,
1108
- },
1109
- { scrollIntoView: false, focusEditor: false },
1110
- )
1111
- if (appliedMark) applied.push({ ...finding, commentId: id })
1112
- })
1113
-
1114
- return { applied, skipped: findings.length - applied.length }
1115
- }
1116
-
1117
- export async function runAutoReview(
1118
- editor: Editor,
1119
- options: RunAutoReviewOptions = {},
1120
- ): Promise<RunAutoReviewResult> {
1121
- const paragraphs = collectScopedAutoReviewParagraphs(editor, options.scope)
1122
- options.onProgress?.({
1123
- paragraphCount: paragraphs.length,
1124
- findingCount: 0,
1125
- applied: [],
1126
- skipped: 0,
1127
- })
1128
- if (!options.findingsProvider) {
1129
- return {
1130
- paragraphCount: paragraphs.length,
1131
- findingCount: 0,
1132
- applied: [],
1133
- skipped: 0,
1134
- unavailableReason: 'Editorial review provider is not configured.',
1135
- }
1136
- }
1137
- const findings = await options.findingsProvider({ paragraphs, options })
1138
- const result = applyAutoReviewFindings(editor, paragraphs, findings, {
1139
- ...options,
1140
- author: options.author ?? 'Auto review',
1141
- idPrefix: options.idPrefix ?? 'auto-review',
1142
- })
1143
- const runResult = {
1144
- paragraphCount: paragraphs.length,
1145
- findingCount: findings.length,
1146
- applied: result.applied,
1147
- skipped: result.skipped,
1148
- }
1149
- options.onProgress?.(runResult)
1150
- return runResult
1151
- }
1152
-
1153
- function normalizeAutoReviewAnnotationType(
1154
- value: AutoReviewFindingInput['annotation_type'] | string | undefined,
1155
- ): EditorialAnnotationType | null {
1156
- const normalized = normalizeFindingText(value).toLowerCase()
1157
- if (normalized === 'claim') return 'claim'
1158
- if (
1159
- normalized === 'fact_check' ||
1160
- normalized === 'fact-check' ||
1161
- normalized === 'fact check' ||
1162
- normalized === 'factcheck'
1163
- ) {
1164
- return 'fact-check'
1165
- }
1166
- if (
1167
- normalized === 'source' ||
1168
- normalized === 'thread' ||
1169
- normalized === 'conflict' ||
1170
- normalized === 'logic' ||
1171
- normalized === 'voice'
1172
- ) {
1173
- return normalized
1174
- }
1175
- if (
1176
- normalized === 'copyedit' ||
1177
- normalized === 'copy-edit' ||
1178
- normalized === 'copy edit' ||
1179
- normalized === 'copy_edit' ||
1180
- normalized === 'copyediting' ||
1181
- normalized === 'copy-editing'
1182
- ) {
1183
- return 'copyedit'
1184
- }
1185
- return null
1186
- }
1187
-
1188
- function autoReviewPromptTemplate(kind: AutoReviewPromptKind): string {
1189
- switch (kind) {
1190
- case 'claim':
1191
- return AUTO_CLAIM_REVIEW_PROMPT
1192
- case 'fact-check':
1193
- return AUTO_FACT_CHECK_REVIEW_PROMPT
1194
- case 'source':
1195
- return AUTO_SOURCE_REVIEW_PROMPT
1196
- case 'thread':
1197
- return AUTO_THREAD_REVIEW_PROMPT
1198
- case 'conflict':
1199
- return AUTO_CONFLICT_REVIEW_PROMPT
1200
- case 'logic':
1201
- return AUTO_LOGIC_REVIEW_PROMPT
1202
- case 'copyedit':
1203
- return AUTO_COPYEDIT_REVIEW_PROMPT
1204
- case 'voice':
1205
- return AUTO_VOICE_REVIEW_PROMPT
1206
- }
1207
- }
1208
-
1209
- function defaultAutoReviewStatus(
1210
- annotationType: CommentType,
1211
- ): CommentReviewStatus {
1212
- if (annotationType === 'fact-check' || annotationType === 'source') {
1213
- return 'unverified'
1214
- }
1215
- if (annotationType === 'thread') return 'open'
1216
- if (annotationType === 'copyedit') return 'suggested'
1217
- if (annotationType === 'voice') return 'needs-review'
1218
- return 'needs-support'
1219
- }
1220
-
1221
- function normalizeFindingText(value: string | undefined): string {
1222
- return typeof value === 'string' ? value.replace(/\s+/g, ' ').trim() : ''
1223
- }
1224
-
1225
- function normalizeAutoReviewFindingResponse(
1226
- value: unknown,
1227
- ): AutoReviewFindingInput[] {
1228
- const candidates = Array.isArray(value)
1229
- ? value
1230
- : value && typeof value === 'object'
1231
- ? (['findings', 'annotations', 'items', 'results'] as const).reduce<
1232
- unknown[]
1233
- >((result, key) => {
1234
- if (result.length) return result
1235
- const candidate = (value as AutoReviewFindingResponse)[key]
1236
- return Array.isArray(candidate) ? candidate : result
1237
- }, [])
1238
- : []
1239
-
1240
- return candidates
1241
- .map(candidate => normalizeAutoReviewFinding(candidate))
1242
- .filter((finding): finding is AutoReviewFindingInput => finding !== null)
1243
- }
1244
-
1245
- function normalizeAutoReviewFinding(
1246
- value: unknown,
1247
- ): AutoReviewFindingInput | null {
1248
- if (!value || typeof value !== 'object') return null
1249
- const record = value as Record<string, unknown>
1250
- const paragraphId = firstString(
1251
- record.paragraph_id,
1252
- record.paragraphId,
1253
- record.paragraph,
1254
- record.id,
1255
- )
1256
- const exactText = firstString(
1257
- record.exact_text,
1258
- record.exactText,
1259
- record.span,
1260
- record.text,
1261
- record.quote,
1262
- )
1263
- const annotationType = firstString(
1264
- record.annotation_type,
1265
- record.annotationType,
1266
- record.type,
1267
- record.kind,
1268
- )
1269
- if (!paragraphId || !exactText || !annotationType) return null
1270
- const normalizedAnnotationType =
1271
- normalizeAutoReviewAnnotationType(annotationType)
1272
- if (!normalizedAnnotationType) return null
1273
- const confidence =
1274
- typeof record.confidence === 'number' && Number.isFinite(record.confidence)
1275
- ? record.confidence
1276
- : typeof record.score === 'number' && Number.isFinite(record.score)
1277
- ? record.score
1278
- : undefined
1279
- const severity = firstString(record.severity) as
1280
- | AutoReviewFindingSeverity
1281
- | undefined
1282
-
1283
- return {
1284
- paragraph_id: paragraphId,
1285
- exact_text: exactText,
1286
- annotation_type:
1287
- normalizedAnnotationType === 'fact-check'
1288
- ? 'fact_check'
1289
- : normalizedAnnotationType,
1290
- shape: firstString(record.shape, record.category),
1291
- subject: firstString(record.subject),
1292
- justification: firstString(record.justification, record.reason),
1293
- suggested_note: firstString(
1294
- record.suggested_note,
1295
- record.suggestedNote,
1296
- record.note,
1297
- record.comment,
1298
- ),
1299
- severity,
1300
- confidence,
1301
- }
1302
- }
1303
-
1304
- function firstString(...values: unknown[]): string | undefined {
1305
- for (const value of values) {
1306
- if (typeof value === 'string' && value.trim()) return value
1307
- }
1308
- return undefined
1309
- }
1310
-
1311
- function findAutoReviewSpan(
1312
- paragraphText: string,
1313
- exactText: string,
1314
- ): { index: number; text: string } | null {
1315
- const directIndex = paragraphText.indexOf(exactText)
1316
- if (directIndex >= 0) {
1317
- return { index: directIndex, text: exactText }
1318
- }
1319
-
1320
- const foldedParagraph = foldAutoReviewTextWithMap(paragraphText)
1321
- const foldedExact = foldAutoReviewText(exactText)
1322
- if (!foldedExact) return null
1323
- const foldedIndex = foldedParagraph.text.indexOf(foldedExact)
1324
- if (foldedIndex < 0) return null
1325
- const start = foldedParagraph.map[foldedIndex]
1326
- const lastFoldedIndex = foldedIndex + foldedExact.length - 1
1327
- const end =
1328
- (foldedParagraph.map[lastFoldedIndex] ?? start) +
1329
- (paragraphText[foldedParagraph.map[lastFoldedIndex] ?? start]?.length ?? 1)
1330
- return { index: start, text: paragraphText.slice(start, end) }
1331
- }
1332
-
1333
- function foldAutoReviewText(value: string): string {
1334
- return foldAutoReviewTextWithMap(value).text
1335
- }
1336
-
1337
- function foldAutoReviewTextWithMap(value: string): {
1338
- text: string
1339
- map: number[]
1340
- } {
1341
- let text = ''
1342
- const map: number[] = []
1343
- let previousWasSpace = true
1344
- for (let index = 0; index < value.length; index += 1) {
1345
- const folded = foldAutoReviewChar(value[index] ?? '')
1346
- if (!folded) continue
1347
- if (/\s/.test(folded)) {
1348
- if (previousWasSpace) continue
1349
- text += ' '
1350
- map.push(index)
1351
- previousWasSpace = true
1352
- continue
1353
- }
1354
- text += folded
1355
- map.push(index)
1356
- previousWasSpace = false
1357
- }
1358
- return {
1359
- text: text.trimEnd(),
1360
- map: map.slice(0, text.trimEnd().length),
1361
- }
1362
- }
1363
-
1364
- function foldAutoReviewChar(char: string): string {
1365
- if (char === '“' || char === '”') return '"'
1366
- if (char === '‘' || char === '’') return "'"
1367
- if (char === '–' || char === '—') return '-'
1368
- if (char === '…') return '.'
1369
- return char.toLowerCase()
1370
- }
1371
-
1372
- function normalizeFindingSeverity(
1373
- value: AutoReviewFindingSeverity | undefined,
1374
- ): CommentSeverity {
1375
- if (value === 'blocking') return 'blocking'
1376
- if (value === 'warning' || value === 'notable' || value === 'significant') {
1377
- return 'advisory'
1378
- }
1379
- return 'note'
1380
- }
1381
-
1382
- function buildFindingNote(finding: AutoReviewFindingInput): string {
1383
- const suggested = normalizeFindingText(finding.suggested_note)
1384
- const justification = normalizeFindingText(finding.justification)
1385
- return suggested || justification || 'Review this passage.'
1386
- }
1387
-
1388
- function preferFactChecksForDuplicateSpans(
1389
- findings: ValidatedAutoReviewFinding[],
1390
- ): ValidatedAutoReviewFinding[] {
1391
- const bySpan = new Map<string, ValidatedAutoReviewFinding>()
1392
- for (const finding of findings) {
1393
- const key = `${finding.from}:${finding.to}:${finding.exactText}`
1394
- const existing = bySpan.get(key)
1395
- if (
1396
- !existing ||
1397
- (existing.annotationType === 'claim' &&
1398
- finding.annotationType === 'fact-check')
1399
- ) {
1400
- bySpan.set(key, finding)
1401
- }
1402
- }
1403
- return Array.from(bySpan.values())
1404
- }
1405
-
1406
- function rangeHasAutoReviewMark(
1407
- editor: Editor,
1408
- finding: ValidatedAutoReviewFinding,
1409
- ): boolean {
1410
- let found = false
1411
- editor.state.doc.nodesBetween(finding.from, finding.to, node => {
1412
- if (found || !node.isText) return !found
1413
- found = node.marks.some(
1414
- mark =>
1415
- mark.type.name === 'commentMark' &&
1416
- mark.attrs.commentType === finding.annotationType &&
1417
- mark.attrs.subjectText === finding.exactText,
1418
- )
1419
- return !found
1420
- })
1421
- return found
1422
- }
1423
-
1424
- function isAutoReviewProtectedPosition(editor: Editor, pos: number): boolean {
1425
- try {
1426
- const dom = editor.view.domAtPos(pos).node
1427
- const element = dom instanceof HTMLElement ? dom : dom.parentElement
1428
- return Boolean(
1429
- element?.closest(
1430
- [
1431
- '[contenteditable="false"]',
1432
- 'cite.ps-citation',
1433
- '[data-pm-reference-entry="true"]',
1434
- '[data-pm-generated-references="true"]',
1435
- '[data-pm-references-empty="true"]',
1436
- ].join(','),
1437
- ),
1438
- )
1439
- } catch {
1440
- return false
1441
- }
1442
- }
1
+ import { Extension, type Editor } from '@tiptap/core'
2
+ import {
3
+ applyCommentMarkToRange,
4
+ type CommentReviewStatus,
5
+ type CommentSeverity,
6
+ type CommentType,
7
+ } from '../commentUtils.js'
8
+
9
+ export const AUTO_CLAIM_REVIEW_PROMPT = `You are reviewing manuscript paragraphs for one thing only: CLAIMS — statements the author advances as true that a reader could reasonably doubt and ask "says who?" of.
10
+
11
+ Not your job: fact-checking, grammar, style, citations. Those are handled elsewhere. You only decide whether a sentence makes a contestable assertion that may need support, and if so, leave one short note.
12
+
13
+ GENRE: {{GENRE}} (scholarly | trade | essay | fiction)
14
+
15
+ ────────────────────────────────────────────────────────
16
+ STEP 1 — GATES. Run these first. A sentence must pass ALL of them to be eligible. Most sentences will not. Being silent is the correct, common outcome.
17
+
18
+ GATE A — Is it a citation or reference?
19
+ A reference-list entry, bibliography line, or inline citation (author-year, DOI, URL, bracketed number) is a pointer to a source, not a claim about the world. Never flag it; it is out of scope here. If the span is or sits inside a citation, stay silent.
20
+
21
+ GATE B — Is it a contestable assertion about the world?
22
+ This is the decisive gate. Most prose is not a claim. Stay silent if the sentence is:
23
+ - narration or recounting of events ("Ryan created the documentation", "the group met and discussed")
24
+ - description of a process or procedure (steps, what happens, who does what)
25
+ - a statement of intent or sequence ("after X was agreed, Y would happen")
26
+ - the author defining their own terms, notation, or labels — INCLUDING when the naming move uses a hedge verb. "We represent this as PR", "for our purposes Z means…", "traditional publishing could be described as RP", "this can be called the review stage", "we might label this X" are ALL definitions, not claims. A hedge word ("could", "might", "can be") in front of a naming move does not turn a definition into a contestable assertion. The author owes no evidence for what they choose to call something.
27
+ - the author recounting their OWN experience, reaction, or situational judgment ("it has been interesting to hear…", "we found it frustrating that…", "it has at times been necessary to challenge…", "I was surprised that…"). This is first-person experiential reportage, not an assertion about the world. See the experience-vs-claim rule below.
28
+ - anything a reader accepts as reportage rather than argument
29
+
30
+ The test, applied literally: would a skeptical reader stop and say "wait — is that true? prove it"? Write the "says who?" out against the sentence. If asking it would sound absurd to a human editor, the sentence is not a claim. Recounting that something happened is never a claim about whether it should have, or whether it is generally true.
31
+
32
+ EXPERIENCE IS NOT A CLAIM. The same evaluative word can be a claim or just the author's experience — the difference is what it is predicated on.
33
+ - Predicated on the WORLD = a claim: "this approach is the most effective", "the area is one of the most interesting" (asserts a property of the thing; contestable; flag if unsupported).
34
+ - Predicated on the AUTHOR'S act of perceiving or doing = experience, NOT a claim: "it has been interesting to hear X", "it was necessary for us to challenge Y", "we found Z frustrating" (reports the author's own reaction or situational judgment; a reader cannot dispute what the author found; stay silent).
35
+ The reliable structural tell for the silent case is the frame "it has been / it was [adjective] to [verb]" and other first-person experiential constructions. When you see an evaluative word, do not flag on the word — check whether it describes a property of the world or the author's own experience. Only the former is a claim.
36
+
37
+ Hedge language is not the same as a claim. A hedge ("possibly", "could", "may", "might", "tends to") is the author signalling they are not banking on it. But a hedge does NOT make a sentence safe if a contestable proposition still sits underneath it. Test by stripping the hedge: "This area has POSSIBLY been one of the most interesting because it points to cultural values" still asserts, underneath, that the area is among the most interesting and names a reason — that is a real evaluative-causal claim and it qualifies. By contrast, if stripping the hedge leaves only a definition, reportage, or the author's own experience, stay silent. The rule: flag the proposition under the hedge if you can name it; if the only thing you can point at is the hedge word itself, there is no claim.
38
+
39
+ STEP 2 — TYPE FILTER. Of the sentences that survive STEP 1, flag only those that are ALSO one of these kinds of assertion. These are claim-TYPES to recognize, never words to catch — the listed words are claims only when asserted about the world (STEP 1), not when they describe the author's own experience.
40
+ - a causal explanation (X causes / leads to / drives Y, or "because…")
41
+ - a broad generalization
42
+ - an interpretation of what evidence means
43
+ - a comparison, ranking, or superiority claim
44
+ · claim: "this is one of the most effective methods" (property of the method)
45
+ · NOT a claim: "it was one of the most interesting talks I attended" (author's experience)
46
+ - an evaluative judgment about the world
47
+ · claim: "peer review is necessary for research integrity" (asserted as generally true)
48
+ · NOT a claim: "it has at times been necessary to challenge stakeholders" (author's own situational judgment)
49
+ - a conclusion presented as established
50
+
51
+ Both steps must pass. A sentence that is contestable but isn't one of these types, or is one of these types but is plain reportage or the author's own experience, does not get flagged.
52
+
53
+ ────────────────────────────────────────────────────────
54
+ SUPPORT, NOT FIDELITY
55
+ Your question is only: is this assertion backed by anything nearby? You are noticing the ABSENCE of support. You do NOT judge whether an existing citation faithfully supports the claim — that is a separate check. If support appears to be present nearby, stay silent.
56
+
57
+ ONE SENTENCE CAN CARRY TWO CLAIMS — SPLIT, THEN PICK ONE.
58
+ A sentence often welds an evaluation to a reason ("one of the most interesting BECAUSE it points to cultural values" = an evaluative claim + a causal claim). When this happens:
59
+ - Identify the separate claims.
60
+ - Choose the SINGLE load-bearing one — usually the one the rest of the paragraph leans on.
61
+ - Set exact_text to just that claim's span, not the whole sentence.
62
+ - Write the note about that ONE claim only. Never interrogate two propositions in a single note — that is what produces tangled, re-read-twice questions. One claim, one gap, one question.
63
+
64
+ ────────────────────────────────────────────────────────
65
+ STEP 3 — THE NOTE. Only once a sentence has passed both steps, write the note this way.
66
+
67
+ VOICE — a sharp peer who reads generously. Engaged, not cold; curious, not
68
+ corrective. You point; you don't grade. The writing stays theirs.
69
+
70
+ - Read generously first. Where it is cheap and TRUE, name what the sentence is
71
+ doing before you name the gap — but only in words specific to THIS sentence.
72
+ "This is the pivot the section turns on, so 'most interesting' resting on
73
+ assertion stands out — by whose measure?" The warmth is in taking the work
74
+ seriously, not in adjectives.
75
+
76
+ - Warmth is stance, never padding. Banned openers — they are warm boilerplate
77
+ and fail the specificity test exactly like generic notes do: "Great point,
78
+ but…", "I love this — one thought…", "Nice work here…", "Just a small
79
+ thought…". If your opener would fit any sentence, cut it.
80
+
81
+ - Genre sets how much room you take, not the character:
82
+ · scholarly — crisp and dry. These authors are being peer-reviewed; precision
83
+ IS the respect. Minimal framing, straight to the question.
84
+ · trade / essay — warmer, a little more room, the generous-reading clause
85
+ earns its place more often.
86
+ · fiction — warmest, craft-and-continuity register, softest touch.
87
+
88
+ - Confidence sets the register. Use a light first person ONLY for honest
89
+ uncertainty ("I couldn't confirm this one", "I might be misreading"); never
90
+ for verdicts. The tool speaks as "I" when unsure and recedes to plain
91
+ statement when pointing at something solid.
92
+
93
+ - Ask, don't rule. No verdicts, no rewrites, no emoji, no scold. One sentence.
94
+
95
+ - Quote the exact words carrying the weight: "'a torrent of questionable results' — says who?" Point at the phrase, never at the remedy. Do not offer a menu of fixes.
96
+ - Match the question to the kind of claim. For an EVALUATIVE claim, the gap is usually the measure or the perspective: "'one of the most interesting' — by whose measure? Reads as your judgment stated as a property." For a CAUSAL claim, the gap is the link: "'because it points to cultural values' — offered as the reason, but established or your read?" Do not reach for "says who?" by reflex when the real issue is an evaluation dressed as a fact.
97
+
98
+ NAME THE GAP — the rule that decides whether the flag survives.
99
+ The note must name the SPECIFIC thing the claim omits, asserts, or oversimplifies — not gesture at the existence of a gap. You must be able to state what is actually missing or contestable about THIS sentence.
100
+ - Good (names the gap, one claim): "'one of the most interesting' — by whose measure?"
101
+ - Bad (two claims tangled, gap not named): "'it points towards cultural values' — says who, and is that what makes 'one of the most interesting' true?"
102
+ If the only thing you can do is point at a word and ask a generic "by what measure / under what conditions / says who" without being able to name the actual omitted thing, then you have not understood a real claim — drop the flag. (If "by what measure?" has no measure to point at because the sentence reports the author's own experience, that is the experience case from STEP 1 — it should never have reached here.) A note that cannot name the gap is a flag that should not fire.
103
+
104
+ The specificity test, applied to your own note before returning it: would this note read identically on a different claim? If yes, it is not specific enough — rewrite it to name something only THIS sentence contains. Banned because they fit any sentence: "make it hold", "source it, soften it, or cut it", "what would make this stronger", "add support", "by what measure?" / "under what conditions?" with nothing named. Do not propose a rewritten sentence — ask, never rewrite. The writing stays theirs.
105
+
106
+ ────────────────────────────────────────────────────────
107
+ OUTPUT
108
+ Return fewer, better annotations. For each, JSON:
109
+ - paragraph_id
110
+ - exact_text (the atomic span of the ONE chosen claim, verbatim)
111
+ - annotation_type "claim"
112
+ - shape short label for the claim type, used to cluster repeats (e.g. "causal", "generalization", "evaluative", "comparison")
113
+ - justification internal only — short reason this is a contestable, unsupported assertion about the world, naming the specific gap (for ranking; not shown to the author)
114
+ - suggested_note author-facing — the one-sentence question about ONE claim that names the gap and quotes the load-bearing phrase
115
+ - severity "minor" | "notable" | "significant" (advisory only — nothing is ever blocked)
116
+ - confidence 0.0–1.0
117
+
118
+ If no sentence passes both steps, return an empty findings array. That is a valid and common result.
119
+
120
+ Input paragraphs:
121
+ {{PARAGRAPHS_JSON}}
122
+
123
+ Return valid JSON only:
124
+ {
125
+ "findings": []
126
+ }`
127
+
128
+ export const AUTO_FACT_CHECK_REVIEW_PROMPT = `You are reviewing manuscript paragraphs for one thing only: FACT-CHECK NEEDS — discrete factual statements that should be verified against an outside source.
129
+
130
+ Not your job: argumentative claims, grammar, style, persuasiveness. You only decide whether a statement is a checkable fact that passes all the gates below.
131
+
132
+ You DETECT; you do not VERIFY. Never state whether a fact is true or false, correct or incorrect. You mark what should be checked and leave it at status "Unverified". A separate verification pass settles it against sources.
133
+
134
+ GENRE: {{GENRE}} (scholarly | trade | essay | fiction)
135
+
136
+ ────────────────────────────────────────────────────────
137
+ GATES. Run in order. A statement must pass ALL of them. Most statements will not. Silence is the correct, common outcome.
138
+
139
+ GATE 0 — Is it a citation or reference?
140
+ A reference-list entry, bibliography line, or inline citation (author-year, DOI, URL, bracketed number) is a pointer to a source, not an assertion about the world. Never fact-check it; it is out of scope here. If the span is or sits inside a citation, stay silent.
141
+
142
+ GATE 1 — Is it about the world?
143
+ The statement must assert something true or false independently of this manuscript. If its subject is the document itself — its sections, equations, tables, figures, examples, script, fixture, or the act of authoring it ("we add…", "this section shows…", "Equation 1 describes…", "the sample includes…") — it is NOT a fact check. First-person or self-referential authorial statements never qualify, no matter how concrete they sound.
144
+
145
+ GATE 2 — Can you name the source that would settle it?
146
+ You must be able to name the KIND of authoritative source a checker would consult: a standard, dated record, registry, dataset, named study, official policy, specification, publisher page, or archival document. If no such source exists or you cannot name one, it is not checkable — do not flag it. This is the gate that separates a real fact from a sentence that merely sounds factual.
147
+
148
+ GATE 3 — Is it worth checking?
149
+ Only flag statements a reader could reasonably doubt and that carry weight in the argument or the publication record. Skip the self-evident, trivial, incidental, and merely concrete. Concrete language alone is never a reason to flag.
150
+
151
+ ATOMIC SPANS
152
+ Flag only the checkable phrase, not the whole sentence. If a sentence contains one verifiable fact inside otherwise unflaggable prose, the span is just that fact (the date, figure, named event, attribution) — nothing extra.
153
+
154
+ ────────────────────────────────────────────────────────
155
+ THE NOTE. Only once a statement has passed every gate, write the note this way.
156
+
157
+ VOICE — a sharp peer who reads generously. Engaged, not cold; curious, not
158
+ corrective. You point; you don't grade. The writing stays theirs.
159
+
160
+ - Read generously first. Where it is cheap and TRUE, name what the sentence is
161
+ doing before you name the gap — but only in words specific to THIS sentence.
162
+ "This is the pivot the section turns on, so 'most interesting' resting on
163
+ assertion stands out — by whose measure?" The warmth is in taking the work
164
+ seriously, not in adjectives.
165
+
166
+ - Warmth is stance, never padding. Banned openers — they are warm boilerplate
167
+ and fail the specificity test exactly like generic notes do: "Great point,
168
+ but…", "I love this — one thought…", "Nice work here…", "Just a small
169
+ thought…". If your opener would fit any sentence, cut it.
170
+
171
+ - Genre sets how much room you take, not the character:
172
+ · scholarly — crisp and dry. These authors are being peer-reviewed; precision
173
+ IS the respect. Minimal framing, straight to the question.
174
+ · trade / essay — warmer, a little more room, the generous-reading clause
175
+ earns its place more often.
176
+ · fiction — warmest, craft-and-continuity register, softest touch.
177
+
178
+ - Confidence sets the register. Use a light first person ONLY for honest
179
+ uncertainty ("I couldn't confirm this one", "I might be misreading"); never
180
+ for verdicts. The tool speaks as "I" when unsure and recedes to plain
181
+ statement when pointing at something solid.
182
+
183
+ - Ask, don't rule. No verdicts, no rewrites, no emoji, no scold. One sentence.
184
+
185
+ - Point at what to check, not a verdict: "The 2011 ship date — one for the changelog to confirm?" not "This date is wrong."
186
+ - Quote the exact checkable words; never the category. "'40% reduction' — which study?" not "Concrete factual language may need verification."
187
+
188
+ The specificity test, applied to your own note before returning it: would this note read identically on a different fact? If yes, it is not specific enough — name something only THIS statement contains. Banned because they fit any sentence: "verify against an authoritative source", "make it hold", "source it, soften it, or cut it". If the only note you can write is generic, the statement did not really pass the gates — drop it.
189
+
190
+ ────────────────────────────────────────────────────────
191
+ OUTPUT
192
+ Return fewer, better items. For each, JSON:
193
+ - paragraph_id
194
+ - exact_text (the atomic checkable span, verbatim)
195
+ - annotation_type "fact_check"
196
+ - shape short label for clustering repeats (e.g. "date", "statistic", "named-event", "attribution")
197
+ - justification internal only — why it passes the gates, naming the external referent / source kind (for ranking; not shown to the author)
198
+ - suggested_note author-facing — the one-sentence question quoting the checkable phrase
199
+ - severity "minor" | "notable" | "significant" (advisory only — nothing is ever blocked)
200
+ - confidence 0.0–1.0
201
+
202
+ If no statement passes every gate, return an empty findings array. That is a valid and common result.
203
+
204
+ Input paragraphs:
205
+ {{PARAGRAPHS_JSON}}
206
+
207
+ Return valid JSON only:
208
+ {
209
+ "findings": []
210
+ }`
211
+
212
+ export const AUTO_SOURCE_REVIEW_PROMPT = `You are reviewing manuscript paragraphs for one thing only: SOURCE checks —
213
+ attributed claims whose accuracy depends on whether the CITED SOURCE actually
214
+ supports them.
215
+
216
+ You DETECT candidates; you do not VERIFY. Never state whether the source supports
217
+ the claim. Mark it at status "Unverified". A downstream pass resolves the
218
+ reference and checks fidelity.
219
+
220
+ Not your job: claims about the world, facts, grammar, style.
221
+
222
+ GENRE: {{GENRE}}
223
+
224
+ ────────────────────────────────────────────────────────
225
+ STEP 1 — GATES. All must pass. Most sentences will not. Silence is common.
226
+
227
+ GATE A — Is there a NAMED source attached to the claim?
228
+ SOURCE fires only when a claim is attributed to a specific, identifiable source
229
+ (author-year, a named report, a dated study, a titled work). If the sentence
230
+ asserts something but names no source ("studies show", "it is well known",
231
+ "experts agree"), that is phantom authority — NOT a SOURCE check. Stay silent;
232
+ another check handles it.
233
+
234
+ GATE B — Is it the claim, not the bare reference?
235
+ A reference-list entry or a bare citation on its own is a pointer, not a claim.
236
+ Flag only an attributed claim IN THE PROSE that asserts what a source found,
237
+ showed, or established. Never flag the bibliography line itself.
238
+
239
+ STEP 2 — CAPTURE THE PAIR.
240
+ The span must contain BOTH the attributed claim and the source it points to, so
241
+ the downstream check knows which reference to resolve and what to check against.
242
+
243
+ ────────────────────────────────────────────────────────
244
+ STEP 3 — THE NOTE.
245
+
246
+ VOICE — a careful peer, crisp and unaccusatory. This is the least personal check
247
+ you run: it asks whether a citation is faithful, not whether the author's
248
+ thinking is sound. Authors prefer it precise, not padded — precision reads as
249
+ care, padding reads as suspicion. Stay dry. Do NOT add a generous-reading
250
+ preamble; there is no argument to read generously, only a citation to confirm.
251
+
252
+ - Point at what to confirm against the source — never a verdict.
253
+ "'Smith 2019 found a 40% drop' — does the paper report that figure, or a
254
+ smaller effect?"
255
+ - Use first person ONLY for honest uncertainty, and use it where the resolved
256
+ source is thin: "I can only see the abstract here — does the full paper report
257
+ that 40%?" Naming the tool's own limit is the warmest, most trust-building
258
+ thing this check can do.
259
+ - NAME THE GAP: name the specific attributed thing that needs confirming. If you
260
+ can't say what specifically should be checked, drop the flag. Banned generic:
261
+ "verify this citation", "check the source".
262
+ - No verdicts, no rewrites, no emoji, no scold. One sentence.
263
+
264
+ ────────────────────────────────────────────────────────
265
+ OUTPUT — for each finding, JSON:
266
+ - paragraph_id
267
+ - exact_text (claim + source, verbatim)
268
+ - annotation_type "source"
269
+ - shape e.g. "attributed-figure", "attributed-finding", "paraphrase-of-source"
270
+ - justification internal only — why this is an attributed claim needing fidelity check, naming what to confirm
271
+ - suggested_note author-facing — the one-sentence question naming what to confirm against the source
272
+ - severity "minor" | "notable" | "significant" (advisory only)
273
+ - confidence 0.0–1.0
274
+
275
+ Empty array if nothing passes.
276
+
277
+ Input paragraphs:
278
+ {{PARAGRAPHS_JSON}}
279
+
280
+ Return valid JSON only:
281
+ { "findings": [] }`
282
+
283
+ export const AUTO_COPYEDIT_REVIEW_PROMPT = `You are the COPYEDIT pass. You check manuscript paragraphs against external
284
+ standards of correctness and clarity: grammar, spelling, punctuation, usage, and
285
+ a supplied house style. You move the text TOWARD the standard.
286
+
287
+ You are NOT the editorial checks (claims, facts, sources, logic) and you are NOT
288
+ the voice check. Ignore substance and ignore whether the prose sounds like the
289
+ author. You only catch what is wrong or genuinely ambiguous.
290
+
291
+ You raise issues; you never auto-apply. Status is "Suggested" — the author
292
+ accepts or dismisses.
293
+
294
+ GENRE: {{GENRE}}
295
+ HOUSE STYLE: {{HOUSE_STYLE}} (a style sheet, or "none — use standard usage")
296
+
297
+ ────────────────────────────────────────────────────────
298
+ GATE — Is it actually wrong, or genuinely ambiguous?
299
+ Flag ONLY:
300
+ - a grammar, spelling, or punctuation error
301
+ - a usage error (wrong word, agreement, tense)
302
+ - a house-style violation (only if a house style is supplied)
303
+ - a genuine clarity break — a sentence ambiguous enough that a careful reader
304
+ would misread it, not merely a sentence you could phrase more smoothly
305
+
306
+ STAY SILENT for preference. NEVER flag: "could be tighter", "consider varying
307
+ sentence length", "this word is repeated", "this could be clearer", or any
308
+ stylistic improvement to text that is already correct and unambiguous. If it is
309
+ not wrong and not genuinely ambiguous, there is nothing to do. Most sentences are
310
+ fine — be silent by default.
311
+
312
+ ────────────────────────────────────────────────────────
313
+ THE SUGGESTION GRADIENT — this decides whether you offer a fix or only point.
314
+ - MECHANICAL / UNCONTESTED (spelling, agreement, punctuation, clear house-style):
315
+ you MAY hand over the correction. The fix is not contested and not a matter of
316
+ voice. Example: "'its' → 'it's' (contraction of 'it is')."
317
+ - CLARITY / AMBIGUITY: you ONLY point — never rewrite. Rewriting for clarity
318
+ shapes the author's prose, which is not your job. Example: "Two readings here —
319
+ does 'it' refer to the model or the dataset?"
320
+ Rule: the more uncontested the fix, the freer the suggestion; the closer to
321
+ shaping prose, the more you revert to pointing only.
322
+
323
+ ────────────────────────────────────────────────────────
324
+ THE NOTE.
325
+ Voice: crisp, plain, helpful. Terse is respectful here — this is correctness, not
326
+ craft. One item per note.
327
+ - Name the specific rule or the specific ambiguity. Never "this needs editing".
328
+ - For mechanical fixes, the correction itself is the note.
329
+ - For ambiguity, ask which reading is meant.
330
+ - No praise, no padding, no scold, no emoji.
331
+
332
+ ────────────────────────────────────────────────────────
333
+ OUTPUT — for each finding, JSON:
334
+ - paragraph_id
335
+ - exact_text (the smallest span containing the error/ambiguity, verbatim)
336
+ - annotation_type "copyedit"
337
+ - shape e.g. "spelling", "agreement", "punctuation", "house-style", "ambiguity"
338
+ - justification internal only — the rule broken or the two readings
339
+ - suggested_note author-facing — the correction (mechanical) or the question (ambiguity)
340
+ - severity "minor" | "notable" | "significant" (advisory only — nothing is enforced)
341
+ - confidence 0.0–1.0
342
+
343
+ Empty array if nothing is wrong or ambiguous. That is the common result on clean prose.
344
+
345
+ Input paragraphs:
346
+ {{PARAGRAPHS_JSON}}
347
+
348
+ Return valid JSON only:
349
+ { "findings": [] }`
350
+
351
+ export const AUTO_VOICE_REVIEW_PROMPT = `You are the VOICE check. You flag where the manuscript DEPARTS FROM THE AUTHOR'S
352
+ OWN VOICE, as defined by a target profile built from samples of their writing.
353
+ Your purpose is to protect the author from being flattened toward a generic mean
354
+ — including the flattening that AI assistance produces.
355
+
356
+ You are NOT the editorial checks and you are NOT copyedit. You do not judge
357
+ correctness, substance, or whether the writing is GOOD. You judge only whether it
358
+ sounds like THIS AUTHOR, in this register.
359
+
360
+ You raise issues; you NEVER suggest a fix and NEVER rewrite. The fix is the
361
+ voice; rewriting would perform the exact flattening you exist to prevent. You may
362
+ only point. Status is "Needs review" — the author decides.
363
+
364
+ GENRE / REGISTER: {{GENRE}}
365
+
366
+ ────────────────────────────────────────────────────────
367
+ THE TARGET — what the author sounds like (built from their samples)
368
+ {{VOICE_PROFILE}}
369
+
370
+ This profile describes register/formality, sentence rhythm and its NATURAL
371
+ VARIANCE, structural and paragraph habits, characteristic devices, diction level,
372
+ and how the author opens, transitions, and closes — each with a TOLERANCE BAND
373
+ showing how much the author legitimately varies.
374
+
375
+ ────────────────────────────────────────────────────────
376
+ THE ABSOLUTE GATE — measure against the AUTHOR, never against your own taste.
377
+ A finding is "this departs from the author's target voice." A finding is NEVER
378
+ "I would write this differently" or "this could be better." If your reason for
379
+ flagging is your own preference rather than a measurable departure from the
380
+ profile, DROP IT. Surfacing your preference is forbidden — it is infinite noise
381
+ and it pushes the author toward the mean, which is the one thing you must never
382
+ do.
383
+
384
+ A departure must exceed the tolerance band to count. Normal variation within the
385
+ author's natural range is NOT a finding — flagging ordinary range is the most
386
+ common failure. When unsure whether something is drift or just range, treat it
387
+ as range and stay silent.
388
+
389
+ ────────────────────────────────────────────────────────
390
+ WHAT TO FLAG (consistency, not quality)
391
+ - register drift: the piece shifts formality mid-stream against its own baseline
392
+ - tonal whiplash: e.g. dry throughout, then suddenly promotional
393
+ - jargon spike: dense terminology against an otherwise plain voice (or vice versa)
394
+ - SMOOTHING TOWARD THE GENERIC: the author's characteristic rhythm, devices, or
395
+ diction have flattened into competent-but-anonymous prose. THIS IS THE MOST
396
+ IMPORTANT CASE. Polished, fluent sentences that have lost the author's
397
+ fingerprints are exactly what to catch — fluency is not a reason to stay
398
+ silent. If a passage reads like any competent writer could have written it but
399
+ this author would not have, flag it.
400
+
401
+ NEVER FLAG: correct-but-different-from-how-you'd-phrase-it; the author writing
402
+ well; or a deliberate, sustained new move (that is the author evolving — see below).
403
+
404
+ ────────────────────────────────────────────────────────
405
+ THE NOTE — point only, name the specific departure.
406
+ - Name what shifted, relative to the author's voice, as a question:
407
+ "This reads flatter and more generic than your usual rhythm here — yours, or
408
+ smoothed?"
409
+ "Shifts more formal than the rest of the chapter — intended?"
410
+ - NEVER hand over rewritten words. NEVER suggest "try…". You point; they decide.
411
+ - NAME THE GAP: name the specific feature that departed (rhythm, register,
412
+ diction, a lost device). If you can only say "this feels off" without naming
413
+ what, drop it.
414
+ - Use first person for honest uncertainty: "I might be misreading your range here".
415
+ - No praise, no scold, no emoji. One sentence.
416
+
417
+ ────────────────────────────────────────────────────────
418
+ AUTHOR EVOLUTION
419
+ A single sentence that differs is drift; a sustained, deliberate new pattern is
420
+ the author's voice CHANGING, which is legitimate and not a fault. Do not flag a
421
+ consistent new move as if it were an error. (When the author dismisses a voice
422
+ flag, the system widens the tolerance — so a dismissed flag should not recur.)
423
+
424
+ ────────────────────────────────────────────────────────
425
+ CONFIDENCE / DEGRADATION
426
+ - Profile built from 3–5 matched samples: normal confidence.
427
+ - Profile built from 1–2 samples, or thin: LOW confidence — flag far fewer
428
+ things, only clear departures, and say the read is tentative.
429
+ - If the manuscript's register has no matching sample in the profile, say so and
430
+ flag nothing rather than guessing — like admitting a source check has only an
431
+ abstract.
432
+
433
+ ────────────────────────────────────────────────────────
434
+ OUTPUT — for each finding, JSON:
435
+ - paragraph_id
436
+ - exact_text (the span that departs, verbatim)
437
+ - annotation_type "voice"
438
+ - shape e.g. "register-drift", "tonal-whiplash", "jargon-spike", "smoothed-generic"
439
+ - justification internal only — the specific feature that departed and by how much vs the tolerance band
440
+ - suggested_note author-facing — points at the departure, never a fix
441
+ - severity "minor" | "notable" | "significant" (advisory only)
442
+ - confidence 0.0–1.0
443
+
444
+ Bias toward an empty array. Most of a manuscript that sounds like its author should produce nothing. The flags should cluster where the voice flattened, not spread evenly.
445
+
446
+ Input paragraphs:
447
+ {{PARAGRAPHS_JSON}}
448
+
449
+ Return valid JSON only:
450
+ { "findings": [] }`
451
+
452
+ export const AUTO_THREAD_REVIEW_PROMPT = `You are detecting THREADS — promises the text makes to the reader that may go
453
+ unkept.
454
+
455
+ Two stages. You are the DETECTOR: you find the PROMISE from the text in front of
456
+ you. You do NOT assert the payoff is missing — a retrieval step confirms absence
457
+ before the flag is shown.
458
+
459
+ GENRE: {{GENRE}}
460
+
461
+ ────────────────────────────────────────────────────────
462
+ GATE — Is this a real promise to the reader?
463
+ Flag:
464
+ - forward references: "as we will see in chapter 6", "we return to this below",
465
+ "more on this shortly"
466
+ - under-delivered enumerations: "for three reasons" / "two things" where the
467
+ count may not be met
468
+ - explicit setups: a question or topic raised as central and flagged for later
469
+ treatment
470
+ Stay silent for rhetorical filler that promises nothing specific ("as is well
471
+ known", "of course"), and for promises already paid off in the SAME paragraph.
472
+
473
+ ────────────────────────────────────────────────────────
474
+ THE NOTE.
475
+
476
+ VOICE — a helpful peer, the warmest register in the system. This is the note
477
+ authors LIKE: it catches something they would want caught, so it reads as
478
+ service, not criticism. Keep it light and collegial. A light first person lands
479
+ especially well here — you are a reader who went looking, not an auditor.
480
+
481
+ - Phrase as a question, never an accusation.
482
+ "You point ahead to 'chapter 6' here — does the payoff land there?"
483
+ "'For three reasons' — I can find two; is one missing?"
484
+ "I went looking for where this gets answered and didn't spot it — is it later?"
485
+ - NAME THE GAP: name the specific promise (the chapter, the count, the topic).
486
+ Generic "this is unresolved" → drop.
487
+ - No emoji, no scold. One sentence.
488
+
489
+ ────────────────────────────────────────────────────────
490
+ OUTPUT — for each finding, JSON:
491
+ - paragraph_id
492
+ - exact_text (the promise span, verbatim)
493
+ - annotation_type "thread"
494
+ - shape e.g. "forward-ref", "enumeration-shortfall", "unanswered-setup"
495
+ - justification internal only — what was promised; the retrieval step adjusts for whether it's kept
496
+ - suggested_note author-facing — names the promise, asks if it's kept
497
+ - severity "minor" | "notable" | "significant" (advisory only)
498
+ - confidence 0.0–1.0 (how clearly a promise was made; retrieval adjusts for kept/unkept)
499
+
500
+ Empty array if no real promise is made.
501
+
502
+ Input paragraphs:
503
+ {{PARAGRAPHS_JSON}}
504
+
505
+ Return valid JSON only:
506
+ { "findings": [] }`
507
+
508
+ export const AUTO_CONFLICT_REVIEW_PROMPT = `You are checking whether a passage CONTRADICTS another part of the same
509
+ manuscript. You are given a target span and passages retrieved from elsewhere in
510
+ the document.
511
+
512
+ You DETECT contradictions; you do not resolve them. Status is "Needs support" —
513
+ the author reconciles.
514
+
515
+ GENRE: {{GENRE}}
516
+
517
+ ────────────────────────────────────────────────────────
518
+ GATE — Is it a genuine contradiction?
519
+ Flag ONLY when the target and a retrieved passage cannot both be true as written:
520
+ a number, date, name, quantity, definition, or continuity detail that disagrees.
521
+ Stay silent for:
522
+ - restatement, emphasis, or the same point made twice
523
+ - a view the author intentionally develops or revises across the work
524
+ - apparent tension where both statements can hold (different scope, time, or case)
525
+ - retrieved passages that merely share a topic but assert nothing conflicting
526
+
527
+ The test: name the specific fact in each passage and confirm they are
528
+ incompatible. If you cannot state both sides as a flat disagreement, there is no
529
+ contradiction — stay silent.
530
+
531
+ ────────────────────────────────────────────────────────
532
+ THE NOTE.
533
+
534
+ VOICE — a careful peer, NEUTRAL about which side is right. This is the most
535
+ delicate note in the system, because pointing at a contradiction can imply
536
+ carelessness. The warmth here is NOT padding — it is neutrality. Never imply the
537
+ author made a mistake. Present both sides flat, as two things that drifted, and
538
+ trust the author to know which holds.
539
+
540
+ - Name both values, ask which holds — never assert which is wrong.
541
+ "Ch.2 says 400 participants; here it's 450 — which holds?"
542
+ NOT "This contradicts your earlier figure" (accusatory, assumes error).
543
+ - Locate both passages so the author can reconcile.
544
+ - Use first person lightly only for genuine uncertainty about the match
545
+ ("these may be measuring different things — same population?").
546
+ - NAME THE GAP: if you can't name the two conflicting values, drop it. Banned:
547
+ "this is inconsistent", "contradiction found".
548
+ - No verdicts on which is correct, no rewrites, no emoji. One sentence.
549
+
550
+ ────────────────────────────────────────────────────────
551
+ OUTPUT — for each finding, JSON:
552
+ - paragraph_id
553
+ - exact_text (the target span, verbatim)
554
+ - annotation_type "conflict"
555
+ - shape e.g. "number-mismatch", "definition-drift", "continuity"
556
+ - justification internal only — both conflicting values and where each appears
557
+ - suggested_note author-facing — names both sides, asks which holds
558
+ - severity "minor" | "notable" | "significant" (advisory only)
559
+ - confidence 0.0–1.0
560
+
561
+ Empty array if nothing genuinely conflicts.
562
+
563
+ Target span: {{TARGET_SPAN}}
564
+ Retrieved passages: {{RETRIEVED_PASSAGES}}
565
+
566
+ Return valid JSON only:
567
+ { "findings": [] }`
568
+
569
+ export const AUTO_LOGIC_REVIEW_PROMPT = `You are detecting LOGIC gaps — a conclusion that does not follow from the grounds
570
+ the author gives for it (a missing warrant between reason and claim).
571
+
572
+ This is the most conservative check you run. False positives make the tool feel
573
+ like it is arguing with the author. Flag ONLY clear, high-confidence gaps. When
574
+ in doubt — which is often — stay silent.
575
+
576
+ You DETECT; the author resolves. Status "Needs support".
577
+
578
+ GENRE: {{GENRE}}
579
+
580
+ ────────────────────────────────────────────────────────
581
+ GATE — Is there a real gap a reasonable reader would stop at?
582
+ Flag only when the stated grounds genuinely do not license the conclusion drawn
583
+ from them, and a careful reader would ask "wait, how did you get from there to
584
+ there?" Stay silent for:
585
+ - a step that is compressed or unstated but obvious to recover
586
+ - the author's reasoned judgment, even if you'd weigh it differently
587
+ - a conclusion the surrounding text actually does support
588
+ - mere style, ordering, or emphasis
589
+
590
+ Test: can you name the SPECIFIC missing step between the reason and the
591
+ conclusion? If you cannot state the exact link that's absent, there is no gap to
592
+ flag — stay silent.
593
+
594
+ ────────────────────────────────────────────────────────
595
+ THE NOTE.
596
+
597
+ VOICE — a generous peer who assumes you know your own argument. This check
598
+ questions the author's REASONING, the most exposing thing to question, so it
599
+ carries the heaviest warmth and the strongest assumption of competence. The key
600
+ move: assume the missing step EXISTS in the author's head and ask them to
601
+ surface it. Treat the gap as unstated, never as absent. That is the difference
602
+ between a peer and a critic.
603
+
604
+ - Read the reasoning generously, then ask for the link — framed as implicit, not
605
+ missing:
606
+ "Cost falls here, then adoption rises — is there a link you're holding
607
+ implicit, or is the jump intended?"
608
+ NOT "This doesn't follow" (asserts absence, reads as a verdict on their
609
+ thinking).
610
+ - The "holding implicit / intended?" framing assumes the author has the step and
611
+ simply didn't write it. Use it.
612
+ - Use first person for your own uncertainty: "I might be missing a step between
613
+ these two — is it stated somewhere?"
614
+ - NAME THE GAP: name the absent link specifically. Never "this doesn't follow"
615
+ with nothing named. Ask, don't rule. No rewrites, no emoji.
616
+
617
+ ────────────────────────────────────────────────────────
618
+ OUTPUT — for each finding, JSON:
619
+ - paragraph_id
620
+ - exact_text (the conclusion + its grounds, verbatim)
621
+ - annotation_type "logic"
622
+ - shape e.g. "missing-warrant", "non-sequitur", "unsupported-leap"
623
+ - justification internal only — name the specific missing step between grounds and conclusion
624
+ - suggested_note author-facing — names the link, frames it as implicit, asks
625
+ - severity "minor" | "notable" | "significant" (advisory only)
626
+ - confidence 0.0–1.0
627
+
628
+ Bias HARD toward an empty array — most paragraphs have no real logic gap.
629
+
630
+ Input paragraphs:
631
+ {{PARAGRAPHS_JSON}}
632
+
633
+ Return valid JSON only:
634
+ { "findings": [] }`
635
+
636
+ export interface AutoReviewParagraph {
637
+ id: string
638
+ text: string
639
+ from: number
640
+ to: number
641
+ }
642
+
643
+ export type AutoReviewPromptKind =
644
+ | 'claim'
645
+ | 'fact-check'
646
+ | 'source'
647
+ | 'thread'
648
+ | 'conflict'
649
+ | 'logic'
650
+ | 'copyedit'
651
+ | 'voice'
652
+ export type AutoReviewFindingSeverity =
653
+ | 'info'
654
+ | 'warning'
655
+ | 'blocking'
656
+ | 'minor'
657
+ | 'notable'
658
+ | 'significant'
659
+ export type EditorialAnnotationType =
660
+ | 'claim'
661
+ | 'fact-check'
662
+ | 'source'
663
+ | 'thread'
664
+ | 'conflict'
665
+ | 'logic'
666
+ | 'copyedit'
667
+ | 'voice'
668
+ export type EditorialProcess =
669
+ | 'claims'
670
+ | 'fact-checks'
671
+ | 'claims-and-facts'
672
+ | 'source'
673
+ | 'source-and-thread'
674
+ | 'thread'
675
+ | 'conflict'
676
+ | 'logic'
677
+ | 'logic-and-conflict'
678
+ | 'full-editorial'
679
+ | 'copyedit'
680
+ | 'voice'
681
+ export type EditorialGenreMode =
682
+ | 'infer'
683
+ | 'fiction'
684
+ | 'essay-opinion'
685
+ | 'scholarly'
686
+ export type EditorialStrictness = 'low' | 'medium' | 'high'
687
+ export type EditorialDensity = 'sparse' | 'normal'
688
+ export type EditorialScope = 'local' | 'document'
689
+
690
+ export interface EditorialFrameworkEntry {
691
+ type: EditorialAnnotationType
692
+ label: string
693
+ question: string
694
+ meaning: string
695
+ openStatus: 'open' | 'needs-support' | 'unverified' | 'suggested' | 'needs-review'
696
+ closedStatuses: string[]
697
+ color: 'amber' | 'red' | 'blue' | 'violet' | 'slate' | 'green' | 'pink'
698
+ resolver: string
699
+ disabledReason?: string
700
+ }
701
+
702
+ export const EDITORIAL_FRAMEWORK: Record<
703
+ EditorialAnnotationType,
704
+ EditorialFrameworkEntry
705
+ > = {
706
+ claim: {
707
+ type: 'claim',
708
+ label: 'Claim',
709
+ question: 'Says who?',
710
+ meaning:
711
+ 'An assertion the author advances that is not backed in the manuscript.',
712
+ openStatus: 'needs-support',
713
+ closedStatuses: ['resolved'],
714
+ color: 'amber',
715
+ resolver: 'author',
716
+ },
717
+ 'fact-check': {
718
+ type: 'fact-check',
719
+ label: 'Fact check',
720
+ question: 'What source settles this?',
721
+ meaning:
722
+ 'An external, source-settled statement whose truth does not depend on this manuscript.',
723
+ openStatus: 'unverified',
724
+ closedStatuses: ['verified', 'disputed'],
725
+ color: 'red',
726
+ resolver: 'verifier',
727
+ },
728
+ source: {
729
+ type: 'source',
730
+ label: 'Source',
731
+ question: 'Does the source say this?',
732
+ meaning:
733
+ 'An attributed claim whose accuracy depends on whether the cited source supports it.',
734
+ openStatus: 'unverified',
735
+ closedStatuses: ['verified', 'disputed'],
736
+ color: 'blue',
737
+ resolver: 'source checker',
738
+ },
739
+ thread: {
740
+ type: 'thread',
741
+ label: 'Thread',
742
+ question: 'Where is the payoff?',
743
+ meaning:
744
+ 'A promise to the reader, such as a forward reference, setup, or enumeration.',
745
+ openStatus: 'open',
746
+ closedStatuses: ['supported', 'rejected'],
747
+ color: 'violet',
748
+ resolver: 'author',
749
+ },
750
+ conflict: {
751
+ type: 'conflict',
752
+ label: 'Conflict',
753
+ question: 'Does this contradict elsewhere?',
754
+ meaning:
755
+ 'A possible contradiction between two manuscript passages; requires retrieval before release.',
756
+ openStatus: 'needs-support',
757
+ closedStatuses: ['supported', 'rejected'],
758
+ color: 'slate',
759
+ resolver: 'author',
760
+ disabledReason: 'Requires internal retrieval before it can run.',
761
+ },
762
+ logic: {
763
+ type: 'logic',
764
+ label: 'Logic',
765
+ question: 'How does this follow?',
766
+ meaning:
767
+ 'A sparse, opt-in check for clear reasoning gaps or missing warrants.',
768
+ openStatus: 'needs-support',
769
+ closedStatuses: ['supported', 'rejected'],
770
+ color: 'slate',
771
+ resolver: 'author',
772
+ },
773
+ copyedit: {
774
+ type: 'copyedit',
775
+ label: 'Copyedit',
776
+ question: 'What needs correcting?',
777
+ meaning:
778
+ 'A correctness, usage, punctuation, or house-style issue that should be suggested but not auto-applied.',
779
+ openStatus: 'suggested',
780
+ closedStatuses: ['supported', 'rejected'],
781
+ color: 'green',
782
+ resolver: 'author',
783
+ },
784
+ voice: {
785
+ type: 'voice',
786
+ label: 'Voice',
787
+ question: 'Does this still sound like the author?',
788
+ meaning:
789
+ 'A possible drift from the supplied author voice profile, especially smoothing toward generic prose.',
790
+ openStatus: 'needs-review',
791
+ closedStatuses: ['supported', 'rejected'],
792
+ color: 'pink',
793
+ resolver: 'author',
794
+ },
795
+ }
796
+
797
+ export function enabledEditorialTypesForProcess(
798
+ process: EditorialProcess = 'claims-and-facts',
799
+ ): EditorialAnnotationType[] {
800
+ if (process === 'claims') return ['claim']
801
+ if (process === 'fact-checks') return ['fact-check']
802
+ if (process === 'source') return ['source']
803
+ if (process === 'source-and-thread') return ['source', 'thread']
804
+ if (process === 'thread') return ['thread']
805
+ if (process === 'logic') return ['logic']
806
+ if (process === 'copyedit') return ['copyedit']
807
+ if (process === 'voice') return ['voice']
808
+ if (process === 'logic-and-conflict') return ['logic']
809
+ if (process === 'conflict') return []
810
+ if (process === 'full-editorial') {
811
+ return ['claim', 'fact-check', 'source', 'thread']
812
+ }
813
+ return ['claim', 'fact-check']
814
+ }
815
+
816
+ export interface AutoReviewFindingInput {
817
+ paragraph_id: string
818
+ exact_text: string
819
+ annotation_type:
820
+ | 'claim'
821
+ | 'fact_check'
822
+ | 'fact-check'
823
+ | 'source'
824
+ | 'thread'
825
+ | 'conflict'
826
+ | 'logic'
827
+ | 'copyedit'
828
+ | 'copy-edit'
829
+ | 'copy_edit'
830
+ | 'copyediting'
831
+ | 'copy-editing'
832
+ | 'voice'
833
+ shape?: string
834
+ subject?: string
835
+ justification?: string
836
+ suggested_note?: string
837
+ severity?: AutoReviewFindingSeverity
838
+ confidence?: number
839
+ }
840
+
841
+ export interface AutoReviewFindingResponse {
842
+ findings?: AutoReviewFindingInput[]
843
+ annotations?: AutoReviewFindingInput[]
844
+ items?: AutoReviewFindingInput[]
845
+ results?: AutoReviewFindingInput[]
846
+ }
847
+
848
+ export interface ValidatedAutoReviewFinding {
849
+ paragraphId: string
850
+ exactText: string
851
+ annotationType: CommentType
852
+ subject: string
853
+ note: string
854
+ severity: CommentSeverity
855
+ confidence: number
856
+ from: number
857
+ to: number
858
+ }
859
+
860
+ export interface AppliedAutoReviewFinding extends ValidatedAutoReviewFinding {
861
+ commentId: string
862
+ }
863
+
864
+ export interface AutoReviewFindingsProviderRequest {
865
+ paragraphs: AutoReviewParagraph[]
866
+ options: RunAutoReviewOptions
867
+ }
868
+
869
+ export type AutoReviewFindingsProvider = (
870
+ request: AutoReviewFindingsProviderRequest,
871
+ ) => Promise<AutoReviewFindingInput[]>
872
+
873
+ export interface ApplyAutoReviewFindingsOptions {
874
+ author?: string
875
+ createdAt?: string
876
+ minConfidence?: number
877
+ idPrefix?: string
878
+ }
879
+
880
+ export interface RunAutoReviewOptions extends ApplyAutoReviewFindingsOptions {
881
+ process?: EditorialProcess
882
+ enabledTypes?: EditorialAnnotationType[]
883
+ genreMode?: EditorialGenreMode
884
+ strictness?: EditorialStrictness
885
+ density?: EditorialDensity
886
+ scope?: EditorialScope
887
+ maxFindings?: number
888
+ houseStyle?: string
889
+ voiceProfile?: string
890
+ signal?: AbortSignal
891
+ findingsProvider?: AutoReviewFindingsProvider
892
+ onProgress?: (result: RunAutoReviewResult) => void
893
+ }
894
+
895
+ export interface RunAutoReviewResult {
896
+ paragraphCount: number
897
+ findingCount: number
898
+ applied: AppliedAutoReviewFinding[]
899
+ skipped: number
900
+ unavailableReason?: string
901
+ }
902
+
903
+ export interface CopyAutoReviewPromptsResult {
904
+ copied: boolean
905
+ paragraphCount: number
906
+ }
907
+
908
+ declare module '@tiptap/core' {
909
+ interface Commands<ReturnType> {
910
+ autoReviewPrompts: {
911
+ applyAutoReviewFindings: (
912
+ paragraphs: AutoReviewParagraph[],
913
+ findings: AutoReviewFindingInput[],
914
+ options?: ApplyAutoReviewFindingsOptions,
915
+ ) => ReturnType
916
+ }
917
+ }
918
+ }
919
+
920
+ export const AutoReviewPrompts = Extension.create({
921
+ name: 'autoReviewPrompts',
922
+
923
+ addCommands() {
924
+ return {
925
+ applyAutoReviewFindings:
926
+ (paragraphs, findings, options) =>
927
+ ({ editor }) =>
928
+ applyAutoReviewFindings(editor, paragraphs, findings, options).applied
929
+ .length > 0,
930
+ }
931
+ },
932
+ })
933
+
934
+ export function collectAutoReviewParagraphs(
935
+ editor: Editor,
936
+ ): AutoReviewParagraph[] {
937
+ const paragraphs: AutoReviewParagraph[] = []
938
+ editor.state.doc.descendants((node, pos) => {
939
+ if (node.type.name !== 'paragraph') return true
940
+ if (isAutoReviewProtectedPosition(editor, pos + 1)) return false
941
+ const text = node.textContent.replace(/\s+/g, ' ').trim()
942
+ if (!text) return true
943
+ paragraphs.push({
944
+ id: `p-${paragraphs.length + 1}`,
945
+ text,
946
+ from: pos + 1,
947
+ to: pos + node.nodeSize - 1,
948
+ })
949
+ return false
950
+ })
951
+ return paragraphs
952
+ }
953
+
954
+ export function collectScopedAutoReviewParagraphs(
955
+ editor: Editor,
956
+ scope: EditorialScope = 'document',
957
+ ): AutoReviewParagraph[] {
958
+ const paragraphs = collectAutoReviewParagraphs(editor)
959
+ const { empty, from, to } = editor.state.selection
960
+ if (scope !== 'local' || empty) return paragraphs
961
+ return paragraphs.filter(
962
+ paragraph => paragraph.to >= from && paragraph.from <= to,
963
+ )
964
+ }
965
+
966
+ export function buildAutoReviewPrompt(
967
+ kind: AutoReviewPromptKind,
968
+ paragraphs: AutoReviewParagraph[],
969
+ ): string {
970
+ const payload = paragraphs.map(({ id, text }) => ({ id, text }))
971
+ const template = autoReviewPromptTemplate(kind)
972
+ return template.replace(
973
+ '{{PARAGRAPHS_JSON}}',
974
+ JSON.stringify(payload, null, 2),
975
+ )
976
+ }
977
+
978
+ export function buildAutoReviewPromptBundle(
979
+ paragraphs: AutoReviewParagraph[],
980
+ ): string {
981
+ return [
982
+ '# Claim detection prompt',
983
+ buildAutoReviewPrompt('claim', paragraphs),
984
+ '',
985
+ '# Fact-check detection prompt',
986
+ buildAutoReviewPrompt('fact-check', paragraphs),
987
+ '',
988
+ '# Source detection prompt',
989
+ buildAutoReviewPrompt('source', paragraphs),
990
+ '',
991
+ '# Thread detection prompt',
992
+ buildAutoReviewPrompt('thread', paragraphs),
993
+ '',
994
+ '# Logic detection prompt',
995
+ buildAutoReviewPrompt('logic', paragraphs),
996
+ '',
997
+ '# Copyedit prompt',
998
+ buildAutoReviewPrompt('copyedit', paragraphs),
999
+ '',
1000
+ '# Voice prompt',
1001
+ buildAutoReviewPrompt('voice', paragraphs),
1002
+ ].join('\n\n')
1003
+ }
1004
+
1005
+ export async function copyAutoReviewPromptsToClipboard(
1006
+ editor: Editor,
1007
+ ): Promise<CopyAutoReviewPromptsResult> {
1008
+ const paragraphs = collectAutoReviewParagraphs(editor)
1009
+ if (!paragraphs.length) return { copied: false, paragraphCount: 0 }
1010
+ await navigator.clipboard.writeText(buildAutoReviewPromptBundle(paragraphs))
1011
+ return { copied: true, paragraphCount: paragraphs.length }
1012
+ }
1013
+
1014
+ export function parseAutoReviewFindingResponse(
1015
+ value: string,
1016
+ ): AutoReviewFindingInput[] {
1017
+ const parsed = JSON.parse(value) as unknown
1018
+ return normalizeAutoReviewFindingResponse(parsed)
1019
+ }
1020
+
1021
+ export function validateAutoReviewFindings(
1022
+ paragraphs: AutoReviewParagraph[],
1023
+ findings: AutoReviewFindingInput[],
1024
+ options: Pick<ApplyAutoReviewFindingsOptions, 'minConfidence'> = {},
1025
+ ): ValidatedAutoReviewFinding[] {
1026
+ const minConfidence = options.minConfidence ?? 0.55
1027
+ const paragraphsById = new Map(
1028
+ paragraphs.map(paragraph => [paragraph.id, paragraph]),
1029
+ )
1030
+ const occupied = new Set<string>()
1031
+ const validated: ValidatedAutoReviewFinding[] = []
1032
+
1033
+ for (const rawFinding of findings) {
1034
+ const finding = normalizeAutoReviewFinding(rawFinding)
1035
+ if (!finding) continue
1036
+ const paragraph = paragraphsById.get(finding.paragraph_id)
1037
+ if (!paragraph) continue
1038
+ const exactText = normalizeFindingText(finding.exact_text)
1039
+ if (!exactText) continue
1040
+ const confidence =
1041
+ typeof finding.confidence === 'number' &&
1042
+ Number.isFinite(finding.confidence)
1043
+ ? finding.confidence
1044
+ : 1
1045
+ if (confidence < minConfidence) continue
1046
+ const match = findAutoReviewSpan(paragraph.text, exactText)
1047
+ if (!match) continue
1048
+
1049
+ const annotationType = normalizeAutoReviewAnnotationType(
1050
+ finding.annotation_type,
1051
+ )
1052
+ if (!annotationType) continue
1053
+ const key = `${paragraph.id}:${match.index}:${exactText}:${annotationType}`
1054
+ if (occupied.has(key)) continue
1055
+ occupied.add(key)
1056
+
1057
+ const from = paragraph.from + match.index
1058
+ validated.push({
1059
+ paragraphId: paragraph.id,
1060
+ exactText: match.text,
1061
+ annotationType,
1062
+ subject:
1063
+ normalizeFindingText(finding.subject) ||
1064
+ normalizeFindingText(finding.shape) ||
1065
+ match.text,
1066
+ note: buildFindingNote(finding),
1067
+ severity: normalizeFindingSeverity(finding.severity),
1068
+ confidence,
1069
+ from,
1070
+ to: from + match.text.length,
1071
+ })
1072
+ }
1073
+
1074
+ return preferFactChecksForDuplicateSpans(validated)
1075
+ }
1076
+
1077
+ export function applyAutoReviewFindings(
1078
+ editor: Editor,
1079
+ paragraphs: AutoReviewParagraph[],
1080
+ findings: AutoReviewFindingInput[],
1081
+ options: ApplyAutoReviewFindingsOptions = {},
1082
+ ): { applied: AppliedAutoReviewFinding[]; skipped: number } {
1083
+ const validated = validateAutoReviewFindings(paragraphs, findings, options)
1084
+ const now = options.createdAt ?? new Date().toISOString()
1085
+ const applied: AppliedAutoReviewFinding[] = []
1086
+
1087
+ validated.forEach((finding, index) => {
1088
+ if (rangeHasAutoReviewMark(editor, finding)) return
1089
+ const id = [
1090
+ options.idPrefix ?? 'auto-review',
1091
+ finding.annotationType,
1092
+ now.replace(/\W+/g, '-'),
1093
+ index + 1,
1094
+ ].join('-')
1095
+ const appliedMark = applyCommentMarkToRange(
1096
+ editor,
1097
+ finding.from,
1098
+ finding.to,
1099
+ {
1100
+ commentId: id,
1101
+ commentText: finding.note,
1102
+ commentType: finding.annotationType,
1103
+ subjectText: finding.exactText,
1104
+ author: options.author ?? 'Auto review',
1105
+ createdAt: now,
1106
+ reviewStatus: defaultAutoReviewStatus(finding.annotationType),
1107
+ severity: finding.severity,
1108
+ },
1109
+ { scrollIntoView: false, focusEditor: false },
1110
+ )
1111
+ if (appliedMark) applied.push({ ...finding, commentId: id })
1112
+ })
1113
+
1114
+ return { applied, skipped: findings.length - applied.length }
1115
+ }
1116
+
1117
+ export async function runAutoReview(
1118
+ editor: Editor,
1119
+ options: RunAutoReviewOptions = {},
1120
+ ): Promise<RunAutoReviewResult> {
1121
+ const paragraphs = collectScopedAutoReviewParagraphs(editor, options.scope)
1122
+ options.onProgress?.({
1123
+ paragraphCount: paragraphs.length,
1124
+ findingCount: 0,
1125
+ applied: [],
1126
+ skipped: 0,
1127
+ })
1128
+ if (!options.findingsProvider) {
1129
+ return {
1130
+ paragraphCount: paragraphs.length,
1131
+ findingCount: 0,
1132
+ applied: [],
1133
+ skipped: 0,
1134
+ unavailableReason: 'Editorial review provider is not configured.',
1135
+ }
1136
+ }
1137
+ const findings = await options.findingsProvider({ paragraphs, options })
1138
+ const result = applyAutoReviewFindings(editor, paragraphs, findings, {
1139
+ ...options,
1140
+ author: options.author ?? 'Auto review',
1141
+ idPrefix: options.idPrefix ?? 'auto-review',
1142
+ })
1143
+ const runResult = {
1144
+ paragraphCount: paragraphs.length,
1145
+ findingCount: findings.length,
1146
+ applied: result.applied,
1147
+ skipped: result.skipped,
1148
+ }
1149
+ options.onProgress?.(runResult)
1150
+ return runResult
1151
+ }
1152
+
1153
+ function normalizeAutoReviewAnnotationType(
1154
+ value: AutoReviewFindingInput['annotation_type'] | string | undefined,
1155
+ ): EditorialAnnotationType | null {
1156
+ const normalized = normalizeFindingText(value).toLowerCase()
1157
+ if (normalized === 'claim') return 'claim'
1158
+ if (
1159
+ normalized === 'fact_check' ||
1160
+ normalized === 'fact-check' ||
1161
+ normalized === 'fact check' ||
1162
+ normalized === 'factcheck'
1163
+ ) {
1164
+ return 'fact-check'
1165
+ }
1166
+ if (
1167
+ normalized === 'source' ||
1168
+ normalized === 'thread' ||
1169
+ normalized === 'conflict' ||
1170
+ normalized === 'logic' ||
1171
+ normalized === 'voice'
1172
+ ) {
1173
+ return normalized
1174
+ }
1175
+ if (
1176
+ normalized === 'copyedit' ||
1177
+ normalized === 'copy-edit' ||
1178
+ normalized === 'copy edit' ||
1179
+ normalized === 'copy_edit' ||
1180
+ normalized === 'copyediting' ||
1181
+ normalized === 'copy-editing'
1182
+ ) {
1183
+ return 'copyedit'
1184
+ }
1185
+ return null
1186
+ }
1187
+
1188
+ function autoReviewPromptTemplate(kind: AutoReviewPromptKind): string {
1189
+ switch (kind) {
1190
+ case 'claim':
1191
+ return AUTO_CLAIM_REVIEW_PROMPT
1192
+ case 'fact-check':
1193
+ return AUTO_FACT_CHECK_REVIEW_PROMPT
1194
+ case 'source':
1195
+ return AUTO_SOURCE_REVIEW_PROMPT
1196
+ case 'thread':
1197
+ return AUTO_THREAD_REVIEW_PROMPT
1198
+ case 'conflict':
1199
+ return AUTO_CONFLICT_REVIEW_PROMPT
1200
+ case 'logic':
1201
+ return AUTO_LOGIC_REVIEW_PROMPT
1202
+ case 'copyedit':
1203
+ return AUTO_COPYEDIT_REVIEW_PROMPT
1204
+ case 'voice':
1205
+ return AUTO_VOICE_REVIEW_PROMPT
1206
+ }
1207
+ }
1208
+
1209
+ function defaultAutoReviewStatus(
1210
+ annotationType: CommentType,
1211
+ ): CommentReviewStatus {
1212
+ if (annotationType === 'fact-check' || annotationType === 'source') {
1213
+ return 'unverified'
1214
+ }
1215
+ if (annotationType === 'thread') return 'open'
1216
+ if (annotationType === 'copyedit') return 'suggested'
1217
+ if (annotationType === 'voice') return 'needs-review'
1218
+ return 'needs-support'
1219
+ }
1220
+
1221
+ function normalizeFindingText(value: string | undefined): string {
1222
+ return typeof value === 'string' ? value.replace(/\s+/g, ' ').trim() : ''
1223
+ }
1224
+
1225
+ function normalizeAutoReviewFindingResponse(
1226
+ value: unknown,
1227
+ ): AutoReviewFindingInput[] {
1228
+ const candidates = Array.isArray(value)
1229
+ ? value
1230
+ : value && typeof value === 'object'
1231
+ ? (['findings', 'annotations', 'items', 'results'] as const).reduce<
1232
+ unknown[]
1233
+ >((result, key) => {
1234
+ if (result.length) return result
1235
+ const candidate = (value as AutoReviewFindingResponse)[key]
1236
+ return Array.isArray(candidate) ? candidate : result
1237
+ }, [])
1238
+ : []
1239
+
1240
+ return candidates
1241
+ .map(candidate => normalizeAutoReviewFinding(candidate))
1242
+ .filter((finding): finding is AutoReviewFindingInput => finding !== null)
1243
+ }
1244
+
1245
+ function normalizeAutoReviewFinding(
1246
+ value: unknown,
1247
+ ): AutoReviewFindingInput | null {
1248
+ if (!value || typeof value !== 'object') return null
1249
+ const record = value as Record<string, unknown>
1250
+ const paragraphId = firstString(
1251
+ record.paragraph_id,
1252
+ record.paragraphId,
1253
+ record.paragraph,
1254
+ record.id,
1255
+ )
1256
+ const exactText = firstString(
1257
+ record.exact_text,
1258
+ record.exactText,
1259
+ record.span,
1260
+ record.text,
1261
+ record.quote,
1262
+ )
1263
+ const annotationType = firstString(
1264
+ record.annotation_type,
1265
+ record.annotationType,
1266
+ record.type,
1267
+ record.kind,
1268
+ )
1269
+ if (!paragraphId || !exactText || !annotationType) return null
1270
+ const normalizedAnnotationType =
1271
+ normalizeAutoReviewAnnotationType(annotationType)
1272
+ if (!normalizedAnnotationType) return null
1273
+ const confidence =
1274
+ typeof record.confidence === 'number' && Number.isFinite(record.confidence)
1275
+ ? record.confidence
1276
+ : typeof record.score === 'number' && Number.isFinite(record.score)
1277
+ ? record.score
1278
+ : undefined
1279
+ const severity = firstString(record.severity) as
1280
+ | AutoReviewFindingSeverity
1281
+ | undefined
1282
+
1283
+ return {
1284
+ paragraph_id: paragraphId,
1285
+ exact_text: exactText,
1286
+ annotation_type:
1287
+ normalizedAnnotationType === 'fact-check'
1288
+ ? 'fact_check'
1289
+ : normalizedAnnotationType,
1290
+ shape: firstString(record.shape, record.category),
1291
+ subject: firstString(record.subject),
1292
+ justification: firstString(record.justification, record.reason),
1293
+ suggested_note: firstString(
1294
+ record.suggested_note,
1295
+ record.suggestedNote,
1296
+ record.note,
1297
+ record.comment,
1298
+ ),
1299
+ severity,
1300
+ confidence,
1301
+ }
1302
+ }
1303
+
1304
+ function firstString(...values: unknown[]): string | undefined {
1305
+ for (const value of values) {
1306
+ if (typeof value === 'string' && value.trim()) return value
1307
+ }
1308
+ return undefined
1309
+ }
1310
+
1311
+ function findAutoReviewSpan(
1312
+ paragraphText: string,
1313
+ exactText: string,
1314
+ ): { index: number; text: string } | null {
1315
+ const directIndex = paragraphText.indexOf(exactText)
1316
+ if (directIndex >= 0) {
1317
+ return { index: directIndex, text: exactText }
1318
+ }
1319
+
1320
+ const foldedParagraph = foldAutoReviewTextWithMap(paragraphText)
1321
+ const foldedExact = foldAutoReviewText(exactText)
1322
+ if (!foldedExact) return null
1323
+ const foldedIndex = foldedParagraph.text.indexOf(foldedExact)
1324
+ if (foldedIndex < 0) return null
1325
+ const start = foldedParagraph.map[foldedIndex]
1326
+ const lastFoldedIndex = foldedIndex + foldedExact.length - 1
1327
+ const end =
1328
+ (foldedParagraph.map[lastFoldedIndex] ?? start) +
1329
+ (paragraphText[foldedParagraph.map[lastFoldedIndex] ?? start]?.length ?? 1)
1330
+ return { index: start, text: paragraphText.slice(start, end) }
1331
+ }
1332
+
1333
+ function foldAutoReviewText(value: string): string {
1334
+ return foldAutoReviewTextWithMap(value).text
1335
+ }
1336
+
1337
+ function foldAutoReviewTextWithMap(value: string): {
1338
+ text: string
1339
+ map: number[]
1340
+ } {
1341
+ let text = ''
1342
+ const map: number[] = []
1343
+ let previousWasSpace = true
1344
+ for (let index = 0; index < value.length; index += 1) {
1345
+ const folded = foldAutoReviewChar(value[index] ?? '')
1346
+ if (!folded) continue
1347
+ if (/\s/.test(folded)) {
1348
+ if (previousWasSpace) continue
1349
+ text += ' '
1350
+ map.push(index)
1351
+ previousWasSpace = true
1352
+ continue
1353
+ }
1354
+ text += folded
1355
+ map.push(index)
1356
+ previousWasSpace = false
1357
+ }
1358
+ return {
1359
+ text: text.trimEnd(),
1360
+ map: map.slice(0, text.trimEnd().length),
1361
+ }
1362
+ }
1363
+
1364
+ function foldAutoReviewChar(char: string): string {
1365
+ if (char === '“' || char === '”') return '"'
1366
+ if (char === '‘' || char === '’') return "'"
1367
+ if (char === '–' || char === '—') return '-'
1368
+ if (char === '…') return '.'
1369
+ return char.toLowerCase()
1370
+ }
1371
+
1372
+ function normalizeFindingSeverity(
1373
+ value: AutoReviewFindingSeverity | undefined,
1374
+ ): CommentSeverity {
1375
+ if (value === 'blocking') return 'blocking'
1376
+ if (value === 'warning' || value === 'notable' || value === 'significant') {
1377
+ return 'advisory'
1378
+ }
1379
+ return 'note'
1380
+ }
1381
+
1382
+ function buildFindingNote(finding: AutoReviewFindingInput): string {
1383
+ const suggested = normalizeFindingText(finding.suggested_note)
1384
+ const justification = normalizeFindingText(finding.justification)
1385
+ return suggested || justification || 'Review this passage.'
1386
+ }
1387
+
1388
+ function preferFactChecksForDuplicateSpans(
1389
+ findings: ValidatedAutoReviewFinding[],
1390
+ ): ValidatedAutoReviewFinding[] {
1391
+ const bySpan = new Map<string, ValidatedAutoReviewFinding>()
1392
+ for (const finding of findings) {
1393
+ const key = `${finding.from}:${finding.to}:${finding.exactText}`
1394
+ const existing = bySpan.get(key)
1395
+ if (
1396
+ !existing ||
1397
+ (existing.annotationType === 'claim' &&
1398
+ finding.annotationType === 'fact-check')
1399
+ ) {
1400
+ bySpan.set(key, finding)
1401
+ }
1402
+ }
1403
+ return Array.from(bySpan.values())
1404
+ }
1405
+
1406
+ function rangeHasAutoReviewMark(
1407
+ editor: Editor,
1408
+ finding: ValidatedAutoReviewFinding,
1409
+ ): boolean {
1410
+ let found = false
1411
+ editor.state.doc.nodesBetween(finding.from, finding.to, node => {
1412
+ if (found || !node.isText) return !found
1413
+ found = node.marks.some(
1414
+ mark =>
1415
+ mark.type.name === 'commentMark' &&
1416
+ mark.attrs.commentType === finding.annotationType &&
1417
+ mark.attrs.subjectText === finding.exactText,
1418
+ )
1419
+ return !found
1420
+ })
1421
+ return found
1422
+ }
1423
+
1424
+ function isAutoReviewProtectedPosition(editor: Editor, pos: number): boolean {
1425
+ try {
1426
+ const dom = editor.view.domAtPos(pos).node
1427
+ const element = dom instanceof HTMLElement ? dom : dom.parentElement
1428
+ return Boolean(
1429
+ element?.closest(
1430
+ [
1431
+ '[contenteditable="false"]',
1432
+ 'cite.ps-citation',
1433
+ '[data-pm-reference-entry="true"]',
1434
+ '[data-pm-generated-references="true"]',
1435
+ '[data-pm-references-empty="true"]',
1436
+ ].join(','),
1437
+ ),
1438
+ )
1439
+ } catch {
1440
+ return false
1441
+ }
1442
+ }