@puredesktop/platform-editor 1.0.0-beta.1 → 2.1.0
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.
- package/package.json +1 -1
- package/src/CollapsePanel.tsx +35 -35
- package/src/DocumentDiffView.tsx +130 -130
- package/src/DocumentEditor.test.ts +118 -118
- package/src/DocumentEditor.tsx +2651 -2631
- package/src/alignedLineDiff.test.ts +52 -52
- package/src/alignedLineDiff.ts +67 -67
- package/src/collectionAssetSrc.ts +2 -2
- package/src/commentUtils.test.ts +350 -350
- package/src/commentUtils.ts +546 -546
- package/src/constants/toolKeys.ts +14 -14
- package/src/contentFormat.test.ts +27 -27
- package/src/contentFormat.ts +18 -18
- package/src/editorExtensions.ts +155 -155
- package/src/extensions/appliedChangeMark.ts +115 -115
- package/src/extensions/autoReviewPrompts.test.ts +529 -529
- package/src/extensions/autoReviewPrompts.ts +1442 -1442
- package/src/extensions/collectionImage.ts +26 -26
- package/src/extensions/collectionImagePaste.ts +215 -215
- package/src/extensions/commentMark.ts +223 -223
- package/src/extensions/documentAssetIdentity.ts +54 -54
- package/src/extensions/figure.ts +92 -92
- package/src/extensions/footnote.ts +186 -186
- package/src/extensions/indexMarker.ts +88 -88
- package/src/extensions/mathEditing.ts +239 -239
- package/src/extensions/mermaidBlock.tsx +297 -297
- package/src/extensions/slashCommands.test.ts +170 -170
- package/src/extensions/slashCommands.ts +746 -746
- package/src/extensions/smartTypography.test.ts +74 -74
- package/src/extensions/smartTypography.ts +120 -120
- package/src/index.ts +138 -137
- package/src/insertCollectionImage.test.ts +60 -60
- package/src/insertCollectionImage.ts +63 -63
- package/src/insertInlineAssetKind.test.ts +67 -67
- package/src/insertInlineAssetKind.ts +49 -49
- package/src/mermaidPreview.test.ts +54 -54
- package/src/mermaidPreview.ts +115 -115
- package/src/styled.ts +697 -676
- package/src/toolbar/ToolBtn.tsx +77 -63
- package/src/toolbar/Toolbar.test.tsx +325 -325
- package/src/toolbar/Toolbar.tsx +662 -624
- package/src/toolbar/groups/HeadingGroup.tsx +175 -153
- package/src/toolbar/overlayTypes.ts +28 -28
- package/src/useEditorExtensions.ts +22 -22
- 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
|
+
}
|