dravoice 0.1.2 → 0.1.3

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.
@@ -1,433 +1,437 @@
1
- import fs from "node:fs";
2
- import path from "node:path";
3
- import { evidenceTypes, isAbstractClaim } from "./analyzers/evidence.js";
4
- import { transitionLabel } from "./analyzers/discourse.js";
5
- import { moveFor } from "./analyzers/rhetorical-shape.js";
6
- import { parseDocument } from "./document-model.js";
7
- import { buildVoiceProfileV2, loadVoicePackV2 } from "./profile.js";
8
- import { clampScore, round } from "./text-utils.js";
9
- import {
10
- STYLOMETRIC_REFERENCES,
11
- familyDiagnosticsFor,
12
- familyWeight,
13
- styleDistanceFromDiagnostics,
14
- } from "./stylometry.js";
15
-
16
- const MAX_ACTIONS = 8;
17
-
18
- const EDITABILITY = {
19
- evidence: 1.00,
20
- rhetoricalShape: 0.90,
21
- rhythm: 0.80,
22
- discourse: 0.75,
23
- lexical: 0.55,
24
- register: 0.50,
25
- structure: 0.60,
26
- };
27
-
28
- export function revisePlanDraftV2({ file, voice, cwd = process.cwd(), maxActions = MAX_ACTIONS }) {
29
- const sourceProfile = typeof voice === "string" ? loadVoicePackV2(voice) : voice;
30
- const filePath = path.resolve(file);
31
- const draftDocument = parseDocument({
32
- filePath,
33
- rootDir: cwd,
34
- contents: fs.readFileSync(filePath, "utf8"),
35
- });
36
- const draftProfile = buildVoiceProfileV2({ documents: [draftDocument] });
37
- const familyDiagnostics = familyDiagnosticsFor(sourceProfile, draftProfile);
38
- const rollingWindows = rollingWindowsFor({ sourceProfile, draftDocument });
39
- const actions = rankedActions({
40
- sourceProfile,
41
- draftDocument,
42
- familyDiagnostics,
43
- rollingWindows,
44
- maxActions,
45
- });
46
-
47
- return {
48
- schemaVersion: 2,
49
- generatedBy: "dravoice-v2-revise-plan",
50
- file: displayPath(filePath, cwd),
51
- method: {
52
- name: "calibrated-stylometric-revision-plan",
53
- references: STYLOMETRIC_REFERENCES,
54
- caution: "Revision planning only; not AI detection and not proof of authorship.",
55
- },
56
- summary: {
57
- corpusConfidence: sourceProfile.source.confidence,
58
- distance: styleDistanceFromDiagnostics(familyDiagnostics),
59
- familyScores: Object.fromEntries(Object.entries(familyDiagnostics).map(([family, item]) => [family, item.score])),
60
- familyDistances: Object.fromEntries(Object.entries(familyDiagnostics).map(([family, item]) => [family, item.distance])),
61
- familyDrift: Object.fromEntries(Object.entries(familyDiagnostics).map(([family, item]) => [family, item.drift])),
62
- thresholds: Object.fromEntries(Object.entries(familyDiagnostics).map(([family, item]) => [family, item.threshold])),
63
- rollingWindows,
64
- },
65
- actions,
66
- };
67
- }
68
-
69
- export function renderRevisePlanV2(plan) {
70
- const lines = [
71
- "# Revision Plan",
72
- "",
73
- "Voice revision guidance, not AI detection.",
74
- `Method: calibrated stylometric distance using Burrows Delta, Cosine Delta, function-word stylometry, and discourse features.`,
75
- "",
76
- plan.file,
77
- `Corpus confidence: ${capitalize(plan.summary.corpusConfidence.band)} - ${plan.summary.corpusConfidence.message}`,
78
- `Style distance: ${plan.summary.distance}`,
79
- "Family scores:",
80
- ];
81
-
82
- for (const [family, score] of Object.entries(plan.summary.familyScores)) {
83
- const drift = plan.summary.familyDrift[family];
84
- lines.push(`- ${family}: ${score} (drift ${drift})`);
85
- }
86
-
87
- lines.push("");
88
- if (plan.actions.length === 0) {
89
- lines.push("No calibrated revision actions exceeded the writer's normal style variance.");
90
- lines.push("");
91
- return lines.join("\n");
92
- }
93
-
94
- lines.push("Start here:");
95
- plan.actions.forEach((action, index) => {
96
- lines.push(`${index + 1}. ${action.priority} ${action.family} ${action.id}`);
97
- lines.push(` Unit: ${action.unit.type} at line ${action.unit.line}`);
98
- lines.push(` Score: ${action.actionScore}`);
99
- lines.push(` Why flagged: ${action.why}`);
100
- lines.push(` Revise by: ${action.reviseBy}`);
101
- });
102
- lines.push("");
103
- return lines.join("\n");
104
- }
105
-
106
- function rankedActions({ sourceProfile, draftDocument, familyDiagnostics, rollingWindows, maxActions }) {
107
- const confidence = confidenceWeight(sourceProfile.source.confidence.band);
108
- const actions = [
109
- ...evidenceActions({ sourceProfile, draftDocument, familyDiagnostics, confidence }),
110
- ...rhythmActions({ sourceProfile, draftDocument, familyDiagnostics, confidence }),
111
- ...shapeActions({ sourceProfile, draftDocument, familyDiagnostics, confidence }),
112
- ...discourseActions({ sourceProfile, draftDocument, familyDiagnostics, confidence }),
113
- ...rollingWindowActions({ rollingWindows, confidence }),
114
- ...documentLevelActions({ sourceProfile, draftDocument, familyDiagnostics, confidence }),
115
- ].filter((action) => action.actionScore > 0);
116
-
117
- return actions
118
- .sort((left, right) => right.actionScore - left.actionScore || left.id.localeCompare(right.id))
119
- .slice(0, maxActions)
120
- .map((action, index) => ({ ...action, rank: index + 1 }));
121
- }
122
-
123
- function evidenceActions({ sourceProfile, draftDocument, familyDiagnostics, confidence }) {
124
- const family = "evidence";
125
- const drift = familyDiagnostics[family]?.drift ?? 0;
126
- if (drift <= 0) {
127
- return [];
128
- }
129
- const sourceRate = sourceProfile.families.evidence.features.evidenceSentenceRate;
130
- return draftDocument.sentences.flatMap((sentence, index) => {
131
- const types = evidenceTypes(sentence.text);
132
- const claim = isAbstractClaim(sentence.text);
133
- const localMismatch = claim && types.length === 0
134
- ? 1
135
- : types.length === 0 && sourceRate >= 0.25
136
- ? 0.65
137
- : 0;
138
- if (localMismatch <= 0) {
139
- return [];
140
- }
141
- return [makeAction({
142
- family,
143
- ordinal: index + 1,
144
- priority: claim ? "review" : "consider",
145
- unit: { type: "sentence", line: sentence.line },
146
- confidence,
147
- drift,
148
- stability: familyDiagnostics[family]?.stability,
149
- localMismatch,
150
- why: "This sentence carries a broad claim pattern without the concrete support rate learned from the source corpus.",
151
- reviseBy: "Add concrete support: a scene, quote, number, date, citation, URL, sensory detail, or specific example the writer can verify.",
152
- })];
153
- });
154
- }
155
-
156
- function rhythmActions({ sourceProfile, draftDocument, familyDiagnostics, confidence }) {
157
- const family = "rhythm";
158
- const drift = familyDiagnostics[family]?.drift ?? 0;
159
- if (drift <= 0) {
160
- return [];
161
- }
162
- const source = sourceProfile.families.rhythm.features.sentenceWords;
163
- return draftDocument.sentences.flatMap((sentence, index) => {
164
- const wordCount = sentence.tokens.length;
165
- const nearestBound = wordCount < source.p25 ? source.p25 : wordCount > source.p75 ? source.p75 : wordCount;
166
- const localMismatch = Math.min(1, Math.abs(wordCount - nearestBound) / Math.max(1, source.median));
167
- if (localMismatch <= 0) {
168
- return [];
169
- }
170
- const direction = wordCount > source.p75 ? "longer" : "shorter";
171
- return [makeAction({
172
- family,
173
- ordinal: index + 1,
174
- priority: "consider",
175
- unit: { type: "sentence", line: sentence.line },
176
- confidence,
177
- drift,
178
- stability: familyDiagnostics[family]?.stability,
179
- localMismatch,
180
- why: `This sentence is ${direction} than the learned sentence-length band (${source.p25}-${source.p75} words).`,
181
- reviseBy: "Adjust sentence pacing toward the learned range by splitting, tightening, or pairing it with a deliberately shorter sentence.",
182
- })];
183
- });
184
- }
185
-
186
- function shapeActions({ sourceProfile, draftDocument, familyDiagnostics, confidence }) {
187
- const family = "rhetoricalShape";
188
- const drift = familyDiagnostics[family]?.drift ?? 0;
189
- if (drift <= 0 || draftDocument.sentences.length === 0) {
190
- return [];
191
- }
192
- const sourceOpening = sourceProfile.families.rhetoricalShape.features.openingMoves.slice(0, 3);
193
- const draftOpening = draftDocument.sentences.slice(0, 3).map((sentence) => moveFor(sentence.text));
194
- const mismatches = draftOpening.filter((move, index) => move !== sourceOpening[index]).length;
195
- const localMismatch = mismatches / Math.max(1, Math.min(3, draftOpening.length));
196
- if (localMismatch <= 0) {
197
- return [];
198
- }
199
- return [makeAction({
200
- family,
201
- ordinal: 1,
202
- priority: "consider",
203
- unit: { type: "opening", line: draftDocument.sentences[0].line },
204
- confidence,
205
- drift,
206
- stability: familyDiagnostics[family]?.stability,
207
- localMismatch,
208
- why: `Draft opening moves (${draftOpening.join(" -> ")}) drift from the learned opening pattern (${sourceOpening.join(" -> ")}).`,
209
- reviseBy: "Rework the opening toward a compatible scene, claim, contrast, reflection, or evidence sequence without inventing new facts.",
210
- })];
211
- }
212
-
213
- function discourseActions({ sourceProfile, draftDocument, familyDiagnostics, confidence }) {
214
- const family = "discourse";
215
- const drift = familyDiagnostics[family]?.drift ?? 0;
216
- if (drift <= 0) {
217
- return [];
218
- }
219
- const sourceTransitions = sourceProfile.families.discourse.features.transitionRates;
220
- return draftDocument.sentences.flatMap((sentence, index) => {
221
- const label = transitionLabel(sentence.text);
222
- if (label === "plain") {
223
- return [];
224
- }
225
- const sourceRate = sourceTransitions[label] ?? 0;
226
- const draftRate = draftDocument.sentences.filter((candidate) => transitionLabel(candidate.text) === label).length / Math.max(1, draftDocument.sentences.length);
227
- const localMismatch = Math.max(0, draftRate - sourceRate);
228
- if (localMismatch <= 0.1) {
229
- return [];
230
- }
231
- return [makeAction({
232
- family,
233
- ordinal: index + 1,
234
- priority: "consider",
235
- unit: { type: "sentence", line: sentence.line },
236
- confidence,
237
- drift,
238
- stability: familyDiagnostics[family]?.stability,
239
- localMismatch: Math.min(1, localMismatch),
240
- why: `The draft overuses ${label} transitions compared with the source corpus.`,
241
- reviseBy: "Vary the sentence turn: replace a repeated transition with a callback, concrete example, or direct continuation where it fits.",
242
- })];
243
- });
244
- }
245
-
246
- function documentLevelActions({ sourceProfile, draftDocument, familyDiagnostics, confidence }) {
247
- const result = [];
248
- for (const family of ["lexical", "register", "structure"]) {
249
- const drift = familyDiagnostics[family]?.drift ?? 0;
250
- if (drift <= 0) {
251
- continue;
252
- }
253
- result.push(makeAction({
254
- family,
255
- ordinal: 1,
256
- priority: "consider",
257
- unit: { type: "document", line: draftDocument.sentences[0]?.line ?? 1 },
258
- confidence,
259
- drift,
260
- stability: familyDiagnostics[family]?.stability,
261
- localMismatch: 0.7,
262
- why: documentLevelWhy(sourceProfile, family),
263
- reviseBy: documentLevelReviseBy(family),
264
- }));
265
- }
266
- return result;
267
- }
268
-
269
- function documentLevelWhy(sourceProfile, family) {
270
- if (family === "lexical") {
271
- return "Function words, character trigrams, punctuation, or word-length habits drift from the calibrated source profile.";
272
- }
273
- if (family === "register") {
274
- return `The draft register differs from the learned primary register (${sourceProfile.families.register.features.primary.value}).`;
275
- }
276
- return "The document-level opening structure drifts from the source corpus.";
277
- }
278
-
279
- function documentLevelReviseBy(family) {
280
- if (family === "lexical") {
281
- return "Revise diction and punctuation only where it improves the article; avoid topic-word stuffing or random imperfections.";
282
- }
283
- if (family === "register") {
284
- return "Bring the paragraph stance closer to the learned genre mix without pretending to be someone else.";
285
- }
286
- return "Reorder the first section so the piece starts with a structure the source corpus actually uses.";
287
- }
288
-
289
- function rollingWindowsFor({ sourceProfile, draftDocument }) {
290
- const sentences = draftDocument.sentences;
291
- if (sentences.length < 5) {
292
- return [];
293
- }
294
- const windowSize = sentences.length < 8 ? 3 : 4;
295
- const windowStarts = rollingWindowStarts(sentences.length, windowSize, 2);
296
- const result = [];
297
- for (const start of windowStarts) {
298
- const windowSentences = sentences.slice(start, start + windowSize);
299
- const windowProfile = buildVoiceProfileV2({ documents: [documentForSentences(draftDocument, windowSentences, start)] });
300
- const diagnostics = familyDiagnosticsFor(sourceProfile, windowProfile);
301
- const ranked = ["evidence", "rhythm", "discourse", "rhetoricalShape", "lexical"]
302
- .map((family) => ({ family, ...diagnostics[family] }))
303
- .sort((left, right) => right.drift - left.drift || (100 - right.score) - (100 - left.score));
304
- const best = ranked[0];
305
- if (best?.drift > 0) {
306
- result.push({
307
- family: best.family,
308
- startSentence: start + 1,
309
- endSentence: start + windowSentences.length,
310
- startLine: windowSentences[0].line,
311
- endLine: windowSentences.at(-1).line,
312
- distance: best.distance,
313
- drift: best.drift,
314
- score: best.score,
315
- threshold: best.threshold,
316
- stability: best.stability,
317
- });
318
- }
319
- }
320
- return result
321
- .sort((left, right) => right.drift - left.drift || left.startLine - right.startLine)
322
- .slice(0, 4);
323
- }
324
-
325
- function rollingWindowStarts(sentenceCount, windowSize, stride) {
326
- const starts = [];
327
- for (let start = 0; start <= sentenceCount - windowSize; start += stride) {
328
- starts.push(start);
329
- }
330
- const finalStart = Math.max(0, sentenceCount - windowSize);
331
- if (!starts.includes(finalStart)) {
332
- starts.push(finalStart);
333
- }
334
- return starts;
335
- }
336
-
337
- function documentForSentences(draftDocument, sentences, windowIndex) {
338
- const text = sentences.map((sentence) => sentence.text).join(" ");
339
- const block = {
340
- type: "paragraph",
341
- line: sentences[0]?.line ?? 1,
342
- heading: null,
343
- headingId: null,
344
- headingDepth: 0,
345
- lines: [text],
346
- };
347
- return {
348
- file: `${draftDocument.file ?? "draft"}#window-${windowIndex + 1}`,
349
- path: draftDocument.path,
350
- headings: [],
351
- sections: [{ heading: null, blocks: [block] }],
352
- blocks: [block],
353
- paragraphs: [{
354
- type: "paragraph",
355
- line: block.line,
356
- heading: null,
357
- headingId: null,
358
- text,
359
- }],
360
- sentences,
361
- wordCount: sentences.reduce((sum, sentence) => sum + sentence.tokens.length, 0),
362
- text,
363
- };
364
- }
365
-
366
- function rollingWindowActions({ rollingWindows, confidence }) {
367
- return rollingWindows.map((window, index) => makeAction({
368
- family: window.family,
369
- ordinal: `window-${index + 1}`,
370
- priority: window.family === "evidence" ? "review" : "consider",
371
- unit: { type: "window", line: window.startLine, endLine: window.endLine },
372
- confidence,
373
- drift: window.drift,
374
- stability: window.stability,
375
- localMismatch: Math.min(1, window.drift / Math.max(1, window.drift + 0.5)),
376
- why: `Sentences ${window.startSentence}-${window.endSentence} show localized ${window.family} drift beyond the writer's calibrated range.`,
377
- reviseBy: rollingWindowReviseBy(window.family),
378
- }));
379
- }
380
-
381
- function rollingWindowReviseBy(family) {
382
- if (family === "evidence") {
383
- return "Add or move concrete support into this local passage, or narrow the unsupported claims in the same window.";
384
- }
385
- if (family === "rhythm") {
386
- return "Revise this passage's sentence and paragraph pacing before changing the whole draft.";
387
- }
388
- if (family === "discourse") {
389
- return "Vary the local sentence turns, callbacks, and transitions in this passage.";
390
- }
391
- if (family === "rhetoricalShape") {
392
- return "Adjust this passage's move sequence so the local claim, turn, evidence, and implication pattern is less abrupt.";
393
- }
394
- return "Revise this local passage for style fit before making document-wide lexical changes.";
395
- }
396
-
397
- function makeAction({ family, ordinal, priority, unit, confidence, drift, stability = 0.7, localMismatch, why, reviseBy }) {
398
- return {
399
- id: `v2.revise-plan.${family}.${ordinal}`,
400
- family,
401
- priority,
402
- unit,
403
- actionScore: clampScore(100 * confidence * familyWeight(family) * (EDITABILITY[family] ?? 0.6) * Math.max(0.35, stability) * drift * localMismatch),
404
- localMismatch: round(localMismatch, 3),
405
- why,
406
- reviseBy,
407
- };
408
- }
409
-
410
- function confidenceWeight(band) {
411
- if (band === "deep") {
412
- return 1;
413
- }
414
- if (band === "strong") {
415
- return 0.9;
416
- }
417
- if (band === "usable") {
418
- return 0.75;
419
- }
420
- return 0.45;
421
- }
422
-
423
- function displayPath(filePath, cwd) {
424
- const relative = path.relative(cwd, filePath);
425
- if (relative && !relative.startsWith("..") && !path.isAbsolute(relative)) {
426
- return relative.split(path.sep).join("/");
427
- }
428
- return filePath.split(path.sep).join("/");
429
- }
430
-
431
- function capitalize(value) {
432
- return value.charAt(0).toUpperCase() + value.slice(1);
433
- }
1
+ import path from "node:path";
2
+ import { evidenceTypes, isAbstractClaim } from "./analyzers/evidence.js";
3
+ import { transitionLabel } from "./analyzers/discourse.js";
4
+ import { moveFor } from "./analyzers/rhetorical-shape.js";
5
+ import { parseDocument } from "./document-model.js";
6
+ import { readUtf8FileBounded } from "./io-utils.js";
7
+ import { buildVoiceProfileV2, loadVoicePackV2 } from "./profile.js";
8
+ import { clampScore, round } from "./text-utils.js";
9
+ import {
10
+ STYLOMETRIC_REFERENCES,
11
+ familyDiagnosticsFor,
12
+ familyWeight,
13
+ styleDistanceFromDiagnostics,
14
+ } from "./stylometry.js";
15
+
16
+ const MAX_ACTIONS = 8;
17
+
18
+ const EDITABILITY = {
19
+ evidence: 1.00,
20
+ rhetoricalShape: 0.90,
21
+ rhythm: 0.80,
22
+ discourse: 0.75,
23
+ lexical: 0.55,
24
+ register: 0.50,
25
+ structure: 0.60,
26
+ };
27
+
28
+ export function revisePlanDraftV2({ file, voice, cwd = process.cwd(), maxActions = MAX_ACTIONS }) {
29
+ const sourceProfile = typeof voice === "string" ? loadVoicePackV2(voice) : voice;
30
+ const filePath = resolvePath(cwd, file);
31
+ const draftDocument = parseDocument({
32
+ filePath,
33
+ rootDir: cwd,
34
+ contents: readUtf8FileBounded(filePath, { label: "Draft file", maxBytes: 2 * 1024 * 1024 }),
35
+ });
36
+ const draftProfile = buildVoiceProfileV2({ documents: [draftDocument] });
37
+ const familyDiagnostics = familyDiagnosticsFor(sourceProfile, draftProfile);
38
+ const rollingWindows = rollingWindowsFor({ sourceProfile, draftDocument });
39
+ const actions = rankedActions({
40
+ sourceProfile,
41
+ draftDocument,
42
+ familyDiagnostics,
43
+ rollingWindows,
44
+ maxActions,
45
+ });
46
+
47
+ return {
48
+ schemaVersion: 2,
49
+ generatedBy: "dravoice-v2-revise-plan",
50
+ file: displayPath(filePath, cwd),
51
+ method: {
52
+ name: "calibrated-stylometric-revision-plan",
53
+ references: STYLOMETRIC_REFERENCES,
54
+ caution: "Revision planning only; not AI detection and not proof of authorship.",
55
+ },
56
+ summary: {
57
+ corpusConfidence: sourceProfile.source.confidence,
58
+ distance: styleDistanceFromDiagnostics(familyDiagnostics),
59
+ familyScores: Object.fromEntries(Object.entries(familyDiagnostics).map(([family, item]) => [family, item.score])),
60
+ familyDistances: Object.fromEntries(Object.entries(familyDiagnostics).map(([family, item]) => [family, item.distance])),
61
+ familyDrift: Object.fromEntries(Object.entries(familyDiagnostics).map(([family, item]) => [family, item.drift])),
62
+ thresholds: Object.fromEntries(Object.entries(familyDiagnostics).map(([family, item]) => [family, item.threshold])),
63
+ rollingWindows,
64
+ },
65
+ actions,
66
+ };
67
+ }
68
+
69
+ export function renderRevisePlanV2(plan) {
70
+ const lines = [
71
+ "# Revision Plan",
72
+ "",
73
+ "Voice revision guidance, not AI detection.",
74
+ `Method: calibrated stylometric distance using Burrows Delta, Cosine Delta, function-word stylometry, and discourse features.`,
75
+ "",
76
+ plan.file,
77
+ `Corpus confidence: ${capitalize(plan.summary.corpusConfidence.band)} - ${plan.summary.corpusConfidence.message}`,
78
+ `Style distance: ${plan.summary.distance}`,
79
+ "Family scores:",
80
+ ];
81
+
82
+ for (const [family, score] of Object.entries(plan.summary.familyScores)) {
83
+ const drift = plan.summary.familyDrift[family];
84
+ lines.push(`- ${family}: ${score} (drift ${drift})`);
85
+ }
86
+
87
+ lines.push("");
88
+ if (plan.actions.length === 0) {
89
+ lines.push("No calibrated revision actions exceeded the writer's normal style variance.");
90
+ lines.push("");
91
+ return lines.join("\n");
92
+ }
93
+
94
+ lines.push("Start here:");
95
+ plan.actions.forEach((action, index) => {
96
+ lines.push(`${index + 1}. ${action.priority} ${action.family} ${action.id}`);
97
+ lines.push(` Unit: ${action.unit.type} at line ${action.unit.line}`);
98
+ lines.push(` Score: ${action.actionScore}`);
99
+ lines.push(` Why flagged: ${action.why}`);
100
+ lines.push(` Revise by: ${action.reviseBy}`);
101
+ });
102
+ lines.push("");
103
+ return lines.join("\n");
104
+ }
105
+
106
+ function rankedActions({ sourceProfile, draftDocument, familyDiagnostics, rollingWindows, maxActions }) {
107
+ const confidence = confidenceWeight(sourceProfile.source.confidence.band);
108
+ const actions = [
109
+ ...evidenceActions({ sourceProfile, draftDocument, familyDiagnostics, confidence }),
110
+ ...rhythmActions({ sourceProfile, draftDocument, familyDiagnostics, confidence }),
111
+ ...shapeActions({ sourceProfile, draftDocument, familyDiagnostics, confidence }),
112
+ ...discourseActions({ sourceProfile, draftDocument, familyDiagnostics, confidence }),
113
+ ...rollingWindowActions({ rollingWindows, confidence }),
114
+ ...documentLevelActions({ sourceProfile, draftDocument, familyDiagnostics, confidence }),
115
+ ].filter((action) => action.actionScore > 0);
116
+
117
+ return actions
118
+ .sort((left, right) => right.actionScore - left.actionScore || left.id.localeCompare(right.id))
119
+ .slice(0, maxActions)
120
+ .map((action, index) => ({ ...action, rank: index + 1 }));
121
+ }
122
+
123
+ function evidenceActions({ sourceProfile, draftDocument, familyDiagnostics, confidence }) {
124
+ const family = "evidence";
125
+ const drift = familyDiagnostics[family]?.drift ?? 0;
126
+ if (drift <= 0) {
127
+ return [];
128
+ }
129
+ const sourceRate = sourceProfile.families.evidence.features.evidenceSentenceRate;
130
+ return draftDocument.sentences.flatMap((sentence, index) => {
131
+ const types = evidenceTypes(sentence.text);
132
+ const claim = isAbstractClaim(sentence.text);
133
+ const localMismatch = claim && types.length === 0
134
+ ? 1
135
+ : types.length === 0 && sourceRate >= 0.25
136
+ ? 0.65
137
+ : 0;
138
+ if (localMismatch <= 0) {
139
+ return [];
140
+ }
141
+ return [makeAction({
142
+ family,
143
+ ordinal: index + 1,
144
+ priority: claim ? "review" : "consider",
145
+ unit: { type: "sentence", line: sentence.line },
146
+ confidence,
147
+ drift,
148
+ stability: familyDiagnostics[family]?.stability,
149
+ localMismatch,
150
+ why: "This sentence carries a broad claim pattern without the concrete support rate learned from the source corpus.",
151
+ reviseBy: "Add concrete support: a scene, quote, number, date, citation, URL, sensory detail, or specific example the writer can verify.",
152
+ })];
153
+ });
154
+ }
155
+
156
+ function rhythmActions({ sourceProfile, draftDocument, familyDiagnostics, confidence }) {
157
+ const family = "rhythm";
158
+ const drift = familyDiagnostics[family]?.drift ?? 0;
159
+ if (drift <= 0) {
160
+ return [];
161
+ }
162
+ const source = sourceProfile.families.rhythm.features.sentenceWords;
163
+ return draftDocument.sentences.flatMap((sentence, index) => {
164
+ const wordCount = sentence.tokens.length;
165
+ const nearestBound = wordCount < source.p25 ? source.p25 : wordCount > source.p75 ? source.p75 : wordCount;
166
+ const localMismatch = Math.min(1, Math.abs(wordCount - nearestBound) / Math.max(1, source.median));
167
+ if (localMismatch <= 0) {
168
+ return [];
169
+ }
170
+ const direction = wordCount > source.p75 ? "longer" : "shorter";
171
+ return [makeAction({
172
+ family,
173
+ ordinal: index + 1,
174
+ priority: "consider",
175
+ unit: { type: "sentence", line: sentence.line },
176
+ confidence,
177
+ drift,
178
+ stability: familyDiagnostics[family]?.stability,
179
+ localMismatch,
180
+ why: `This sentence is ${direction} than the learned sentence-length band (${source.p25}-${source.p75} words).`,
181
+ reviseBy: "Adjust sentence pacing toward the learned range by splitting, tightening, or pairing it with a deliberately shorter sentence.",
182
+ })];
183
+ });
184
+ }
185
+
186
+ function shapeActions({ sourceProfile, draftDocument, familyDiagnostics, confidence }) {
187
+ const family = "rhetoricalShape";
188
+ const drift = familyDiagnostics[family]?.drift ?? 0;
189
+ if (drift <= 0 || draftDocument.sentences.length === 0) {
190
+ return [];
191
+ }
192
+ const sourceOpening = sourceProfile.families.rhetoricalShape.features.openingMoves.slice(0, 3);
193
+ const draftOpening = draftDocument.sentences.slice(0, 3).map((sentence) => moveFor(sentence.text));
194
+ const mismatches = draftOpening.filter((move, index) => move !== sourceOpening[index]).length;
195
+ const localMismatch = mismatches / Math.max(1, Math.min(3, draftOpening.length));
196
+ if (localMismatch <= 0) {
197
+ return [];
198
+ }
199
+ return [makeAction({
200
+ family,
201
+ ordinal: 1,
202
+ priority: "consider",
203
+ unit: { type: "opening", line: draftDocument.sentences[0].line },
204
+ confidence,
205
+ drift,
206
+ stability: familyDiagnostics[family]?.stability,
207
+ localMismatch,
208
+ why: `Draft opening moves (${draftOpening.join(" -> ")}) drift from the learned opening pattern (${sourceOpening.join(" -> ")}).`,
209
+ reviseBy: "Rework the opening toward a compatible scene, claim, contrast, reflection, or evidence sequence without inventing new facts.",
210
+ })];
211
+ }
212
+
213
+ function discourseActions({ sourceProfile, draftDocument, familyDiagnostics, confidence }) {
214
+ const family = "discourse";
215
+ const drift = familyDiagnostics[family]?.drift ?? 0;
216
+ if (drift <= 0) {
217
+ return [];
218
+ }
219
+ const sourceTransitions = sourceProfile.families.discourse.features.transitionRates;
220
+ return draftDocument.sentences.flatMap((sentence, index) => {
221
+ const label = transitionLabel(sentence.text);
222
+ if (label === "plain") {
223
+ return [];
224
+ }
225
+ const sourceRate = sourceTransitions[label] ?? 0;
226
+ const draftRate = draftDocument.sentences.filter((candidate) => transitionLabel(candidate.text) === label).length / Math.max(1, draftDocument.sentences.length);
227
+ const localMismatch = Math.max(0, draftRate - sourceRate);
228
+ if (localMismatch <= 0.1) {
229
+ return [];
230
+ }
231
+ return [makeAction({
232
+ family,
233
+ ordinal: index + 1,
234
+ priority: "consider",
235
+ unit: { type: "sentence", line: sentence.line },
236
+ confidence,
237
+ drift,
238
+ stability: familyDiagnostics[family]?.stability,
239
+ localMismatch: Math.min(1, localMismatch),
240
+ why: `The draft overuses ${label} transitions compared with the source corpus.`,
241
+ reviseBy: "Vary the sentence turn: replace a repeated transition with a callback, concrete example, or direct continuation where it fits.",
242
+ })];
243
+ });
244
+ }
245
+
246
+ function documentLevelActions({ sourceProfile, draftDocument, familyDiagnostics, confidence }) {
247
+ const result = [];
248
+ for (const family of ["lexical", "register", "structure"]) {
249
+ const drift = familyDiagnostics[family]?.drift ?? 0;
250
+ if (drift <= 0) {
251
+ continue;
252
+ }
253
+ result.push(makeAction({
254
+ family,
255
+ ordinal: 1,
256
+ priority: "consider",
257
+ unit: { type: "document", line: draftDocument.sentences[0]?.line ?? 1 },
258
+ confidence,
259
+ drift,
260
+ stability: familyDiagnostics[family]?.stability,
261
+ localMismatch: 0.7,
262
+ why: documentLevelWhy(sourceProfile, family),
263
+ reviseBy: documentLevelReviseBy(family),
264
+ }));
265
+ }
266
+ return result;
267
+ }
268
+
269
+ function documentLevelWhy(sourceProfile, family) {
270
+ if (family === "lexical") {
271
+ return "Function words, character trigrams, punctuation, or word-length habits drift from the calibrated source profile.";
272
+ }
273
+ if (family === "register") {
274
+ return `The draft register differs from the learned primary register (${sourceProfile.families.register.features.primary.value}).`;
275
+ }
276
+ return "The document-level opening structure drifts from the source corpus.";
277
+ }
278
+
279
+ function documentLevelReviseBy(family) {
280
+ if (family === "lexical") {
281
+ return "Revise diction and punctuation only where it improves the article; avoid topic-word stuffing or random imperfections.";
282
+ }
283
+ if (family === "register") {
284
+ return "Bring the paragraph stance closer to the learned genre mix without pretending to be someone else.";
285
+ }
286
+ return "Reorder the first section so the piece starts with a structure the source corpus actually uses.";
287
+ }
288
+
289
+ function rollingWindowsFor({ sourceProfile, draftDocument }) {
290
+ const sentences = draftDocument.sentences;
291
+ if (sentences.length < 5) {
292
+ return [];
293
+ }
294
+ const windowSize = sentences.length < 8 ? 3 : 4;
295
+ const windowStarts = rollingWindowStarts(sentences.length, windowSize, 2);
296
+ const result = [];
297
+ for (const start of windowStarts) {
298
+ const windowSentences = sentences.slice(start, start + windowSize);
299
+ const windowProfile = buildVoiceProfileV2({ documents: [documentForSentences(draftDocument, windowSentences, start)] });
300
+ const diagnostics = familyDiagnosticsFor(sourceProfile, windowProfile);
301
+ const ranked = ["evidence", "rhythm", "discourse", "rhetoricalShape", "lexical"]
302
+ .map((family) => ({ family, ...diagnostics[family] }))
303
+ .sort((left, right) => right.drift - left.drift || (100 - right.score) - (100 - left.score));
304
+ const best = ranked[0];
305
+ if (best?.drift > 0) {
306
+ result.push({
307
+ family: best.family,
308
+ startSentence: start + 1,
309
+ endSentence: start + windowSentences.length,
310
+ startLine: windowSentences[0].line,
311
+ endLine: windowSentences.at(-1).line,
312
+ distance: best.distance,
313
+ drift: best.drift,
314
+ score: best.score,
315
+ threshold: best.threshold,
316
+ stability: best.stability,
317
+ });
318
+ }
319
+ }
320
+ return result
321
+ .sort((left, right) => right.drift - left.drift || left.startLine - right.startLine)
322
+ .slice(0, 4);
323
+ }
324
+
325
+ function rollingWindowStarts(sentenceCount, windowSize, stride) {
326
+ const starts = [];
327
+ for (let start = 0; start <= sentenceCount - windowSize; start += stride) {
328
+ starts.push(start);
329
+ }
330
+ const finalStart = Math.max(0, sentenceCount - windowSize);
331
+ if (!starts.includes(finalStart)) {
332
+ starts.push(finalStart);
333
+ }
334
+ return starts;
335
+ }
336
+
337
+ function documentForSentences(draftDocument, sentences, windowIndex) {
338
+ const text = sentences.map((sentence) => sentence.text).join(" ");
339
+ const block = {
340
+ type: "paragraph",
341
+ line: sentences[0]?.line ?? 1,
342
+ heading: null,
343
+ headingId: null,
344
+ headingDepth: 0,
345
+ lines: [text],
346
+ };
347
+ return {
348
+ file: `${draftDocument.file ?? "draft"}#window-${windowIndex + 1}`,
349
+ path: draftDocument.path,
350
+ headings: [],
351
+ sections: [{ heading: null, blocks: [block] }],
352
+ blocks: [block],
353
+ paragraphs: [{
354
+ type: "paragraph",
355
+ line: block.line,
356
+ heading: null,
357
+ headingId: null,
358
+ text,
359
+ }],
360
+ sentences,
361
+ wordCount: sentences.reduce((sum, sentence) => sum + sentence.tokens.length, 0),
362
+ text,
363
+ };
364
+ }
365
+
366
+ function rollingWindowActions({ rollingWindows, confidence }) {
367
+ return rollingWindows.map((window, index) => makeAction({
368
+ family: window.family,
369
+ ordinal: `window-${index + 1}`,
370
+ priority: window.family === "evidence" ? "review" : "consider",
371
+ unit: { type: "window", line: window.startLine, endLine: window.endLine },
372
+ confidence,
373
+ drift: window.drift,
374
+ stability: window.stability,
375
+ localMismatch: Math.min(1, window.drift / Math.max(1, window.drift + 0.5)),
376
+ why: `Sentences ${window.startSentence}-${window.endSentence} show localized ${window.family} drift beyond the writer's calibrated range.`,
377
+ reviseBy: rollingWindowReviseBy(window.family),
378
+ }));
379
+ }
380
+
381
+ function rollingWindowReviseBy(family) {
382
+ if (family === "evidence") {
383
+ return "Add or move concrete support into this local passage, or narrow the unsupported claims in the same window.";
384
+ }
385
+ if (family === "rhythm") {
386
+ return "Revise this passage's sentence and paragraph pacing before changing the whole draft.";
387
+ }
388
+ if (family === "discourse") {
389
+ return "Vary the local sentence turns, callbacks, and transitions in this passage.";
390
+ }
391
+ if (family === "rhetoricalShape") {
392
+ return "Adjust this passage's move sequence so the local claim, turn, evidence, and implication pattern is less abrupt.";
393
+ }
394
+ return "Revise this local passage for style fit before making document-wide lexical changes.";
395
+ }
396
+
397
+ function makeAction({ family, ordinal, priority, unit, confidence, drift, stability = 0.7, localMismatch, why, reviseBy }) {
398
+ return {
399
+ id: `v2.revise-plan.${family}.${ordinal}`,
400
+ family,
401
+ priority,
402
+ unit,
403
+ actionScore: clampScore(100 * confidence * familyWeight(family) * (EDITABILITY[family] ?? 0.6) * Math.max(0.35, stability) * drift * localMismatch),
404
+ localMismatch: round(localMismatch, 3),
405
+ why,
406
+ reviseBy,
407
+ };
408
+ }
409
+
410
+ function confidenceWeight(band) {
411
+ if (band === "deep") {
412
+ return 1;
413
+ }
414
+ if (band === "strong") {
415
+ return 0.9;
416
+ }
417
+ if (band === "usable") {
418
+ return 0.75;
419
+ }
420
+ return 0.45;
421
+ }
422
+
423
+ function displayPath(filePath, cwd) {
424
+ const relative = path.relative(cwd, filePath);
425
+ if (relative && !relative.startsWith("..") && !path.isAbsolute(relative)) {
426
+ return relative.split(path.sep).join("/");
427
+ }
428
+ return filePath.split(path.sep).join("/");
429
+ }
430
+
431
+ function resolvePath(cwd, value) {
432
+ return path.isAbsolute(value) ? value : path.resolve(cwd, value);
433
+ }
434
+
435
+ function capitalize(value) {
436
+ return value.charAt(0).toUpperCase() + value.slice(1);
437
+ }