dravoice 0.1.2 → 0.1.4
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/LICENSE +21 -21
- package/README.md +126 -37
- package/bin/dravoice.js +11 -10
- package/package.json +47 -45
- package/src/index.js +967 -197
- package/src/v2/analyzers/discourse.js +69 -63
- package/src/v2/analyzers/evidence.js +82 -82
- package/src/v2/analyzers/lexical.js +114 -114
- package/src/v2/analyzers/register.js +70 -34
- package/src/v2/analyzers/rhetorical-shape.js +65 -59
- package/src/v2/analyzers/rhythm.js +39 -47
- package/src/v2/analyzers/structure.js +41 -24
- package/src/v2/benchmark.js +657 -568
- package/src/v2/brief.js +154 -146
- package/src/v2/config.js +78 -0
- package/src/v2/doctor.js +308 -0
- package/src/v2/document-model.js +422 -260
- package/src/v2/inspect.js +67 -67
- package/src/v2/io-utils.js +51 -0
- package/src/v2/profile.js +342 -203
- package/src/v2/prompt.js +65 -64
- package/src/v2/review.js +303 -173
- package/src/v2/revise-plan.js +540 -433
- package/src/v2/stylometry.js +346 -332
- package/src/v2/text-utils.js +123 -123
package/src/v2/prompt.js
CHANGED
|
@@ -1,64 +1,65 @@
|
|
|
1
|
-
import fs from "node:fs";
|
|
2
|
-
import path from "node:path";
|
|
3
|
-
import {
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
fs.
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
"
|
|
35
|
-
"",
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
"
|
|
39
|
-
"",
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
}
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { writeUtf8FileSafely } from "./io-utils.js";
|
|
4
|
+
import { loadVoicePackV2 } from "./profile.js";
|
|
5
|
+
|
|
6
|
+
export function voicePromptPackV2({ voice, format = "agents", outPath }) {
|
|
7
|
+
const profile = typeof voice === "string" ? loadVoicePackV2(voice) : voice;
|
|
8
|
+
if (format !== "agents" && format !== "claude" && format !== "system") {
|
|
9
|
+
throw new Error(`Unsupported prompt format: ${format}`);
|
|
10
|
+
}
|
|
11
|
+
const rendered = renderPrompt(profile, format);
|
|
12
|
+
if (outPath) {
|
|
13
|
+
fs.mkdirSync(path.dirname(outPath), { recursive: true });
|
|
14
|
+
writeUtf8FileSafely(outPath, rendered);
|
|
15
|
+
}
|
|
16
|
+
return rendered;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function renderPrompt(profile, format) {
|
|
20
|
+
const header = {
|
|
21
|
+
agents: "# Dravoice V2 Writing Guidance",
|
|
22
|
+
claude: "# CLAUDE.md guidance for Dravoice V2",
|
|
23
|
+
system: "System writing guidance: Dravoice V2",
|
|
24
|
+
}[format];
|
|
25
|
+
const preface = {
|
|
26
|
+
agents: "Use this as local, inspectable drafting guidance from the writer's own corpus. It is not an AI detector or a license to imitate a third party.",
|
|
27
|
+
claude: "Use these project-local voice notes when drafting or reviewing prose for this repository. Treat them as guidance, not identity proof.",
|
|
28
|
+
system: "Follow these local voice constraints when writing prose. Do not expose private source text or claim authorship identity from them.",
|
|
29
|
+
}[format];
|
|
30
|
+
const lines = [
|
|
31
|
+
header,
|
|
32
|
+
"",
|
|
33
|
+
preface,
|
|
34
|
+
"",
|
|
35
|
+
"## Summary",
|
|
36
|
+
"",
|
|
37
|
+
...profile.guidance.summary.map((item) => `- ${item}`),
|
|
38
|
+
"",
|
|
39
|
+
"## Feature Families",
|
|
40
|
+
"",
|
|
41
|
+
];
|
|
42
|
+
|
|
43
|
+
for (const [name, family] of Object.entries(profile.families)) {
|
|
44
|
+
lines.push(`- ${name}: ${family.confidence} confidence; ${family.revisionHandles[0]}`);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
lines.push("", "## Drafting Rules", "");
|
|
48
|
+
for (const rule of profile.guidance.draftingRules) {
|
|
49
|
+
lines.push(`- ${rule}`);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
lines.push("", "## Avoid", "");
|
|
53
|
+
for (const item of profile.guidance.avoid) {
|
|
54
|
+
lines.push(`- ${item}`);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
if (profile.guidance.examples.length) {
|
|
58
|
+
lines.push("", "## Source-Backed Examples", "");
|
|
59
|
+
for (const example of profile.guidance.examples) {
|
|
60
|
+
lines.push(`- ${example}`);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
return `${lines.join("\n")}\n`;
|
|
65
|
+
}
|
package/src/v2/review.js
CHANGED
|
@@ -1,173 +1,303 @@
|
|
|
1
|
-
import
|
|
2
|
-
import
|
|
3
|
-
import {
|
|
4
|
-
import { buildVoiceProfileV2, loadVoicePackV2 } from "./profile.js";
|
|
5
|
-
import { familyDiagnosticsFor, styleDistanceFromDiagnostics } from "./stylometry.js";
|
|
6
|
-
|
|
7
|
-
const REVIEW_MODES = {
|
|
8
|
-
loose: {
|
|
9
|
-
findingThresholds: null,
|
|
10
|
-
exitOnDrift: false,
|
|
11
|
-
},
|
|
12
|
-
balanced: {
|
|
13
|
-
findingThresholds: { evidence: 65, rhythm: 55, rhetoricalShape: 50 },
|
|
14
|
-
exitOnDrift: false,
|
|
15
|
-
},
|
|
16
|
-
strict: {
|
|
17
|
-
findingThresholds: { evidence: 75, rhythm: 70, rhetoricalShape: 65 },
|
|
18
|
-
exitOnDrift: true,
|
|
19
|
-
},
|
|
20
|
-
};
|
|
21
|
-
|
|
22
|
-
export function reviewVoiceDraftV2({ file, voice, cwd = process.cwd(), mode = "balanced" }) {
|
|
23
|
-
const reviewMode = normalizeReviewMode(mode);
|
|
24
|
-
const sourceProfile = typeof voice === "string" ? loadVoicePackV2(voice) : voice;
|
|
25
|
-
const filePath =
|
|
26
|
-
const draftDocument = parseDocument({
|
|
27
|
-
filePath,
|
|
28
|
-
rootDir: cwd,
|
|
29
|
-
contents:
|
|
30
|
-
});
|
|
31
|
-
const draftProfile = buildVoiceProfileV2({ documents: [draftDocument] });
|
|
32
|
-
|
|
33
|
-
if (
|
|
34
|
-
draftDocument.wordCount < sourceProfile.calibration.minimumDraftSize.words ||
|
|
35
|
-
draftDocument.sentences.length < sourceProfile.calibration.minimumDraftSize.sentences
|
|
36
|
-
) {
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
const
|
|
55
|
-
const
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
lines.push(
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
lines
|
|
101
|
-
lines.push(
|
|
102
|
-
lines.
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
}
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
if (
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
}
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
import { parseDocument } from "./document-model.js";
|
|
3
|
+
import { readUtf8FileBounded } from "./io-utils.js";
|
|
4
|
+
import { buildVoiceProfileV2, loadVoicePackV2 } from "./profile.js";
|
|
5
|
+
import { familyDiagnosticsFor, styleDistanceFromDiagnostics } from "./stylometry.js";
|
|
6
|
+
|
|
7
|
+
const REVIEW_MODES = {
|
|
8
|
+
loose: {
|
|
9
|
+
findingThresholds: null,
|
|
10
|
+
exitOnDrift: false,
|
|
11
|
+
},
|
|
12
|
+
balanced: {
|
|
13
|
+
findingThresholds: { evidence: 65, rhythm: 55, rhetoricalShape: 50, discourse: 55, lexical: 55, register: 55, structure: 55 },
|
|
14
|
+
exitOnDrift: false,
|
|
15
|
+
},
|
|
16
|
+
strict: {
|
|
17
|
+
findingThresholds: { evidence: 75, rhythm: 70, rhetoricalShape: 65, discourse: 65, lexical: 65, register: 70, structure: 91 },
|
|
18
|
+
exitOnDrift: true,
|
|
19
|
+
},
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
export function reviewVoiceDraftV2({ file, voice, cwd = process.cwd(), mode = "balanced" }) {
|
|
23
|
+
const reviewMode = normalizeReviewMode(mode);
|
|
24
|
+
const sourceProfile = typeof voice === "string" ? loadVoicePackV2(voice) : voice;
|
|
25
|
+
const filePath = resolvePath(cwd, file);
|
|
26
|
+
const draftDocument = parseDocument({
|
|
27
|
+
filePath,
|
|
28
|
+
rootDir: cwd,
|
|
29
|
+
contents: readUtf8FileBounded(filePath, { label: "Draft file", maxBytes: 2 * 1024 * 1024 }),
|
|
30
|
+
});
|
|
31
|
+
const draftProfile = buildVoiceProfileV2({ documents: [draftDocument] });
|
|
32
|
+
|
|
33
|
+
if (
|
|
34
|
+
draftDocument.wordCount < sourceProfile.calibration.minimumDraftSize.words ||
|
|
35
|
+
draftDocument.sentences.length < sourceProfile.calibration.minimumDraftSize.sentences
|
|
36
|
+
) {
|
|
37
|
+
const familyConfidence = familyConfidenceFor(sourceProfile, {});
|
|
38
|
+
return {
|
|
39
|
+
schemaVersion: 2,
|
|
40
|
+
file: displayPath(filePath, cwd),
|
|
41
|
+
summary: {
|
|
42
|
+
mode: reviewMode,
|
|
43
|
+
fit: { band: "insufficient-evidence", distance: 0 },
|
|
44
|
+
familyScores: {},
|
|
45
|
+
familyConfidence,
|
|
46
|
+
suppressedFindings: suppressedFindingsFor(sourceProfile, familyConfidence),
|
|
47
|
+
corpusConfidence: sourceProfile.source.confidence,
|
|
48
|
+
},
|
|
49
|
+
findings: [],
|
|
50
|
+
exitCode: 0,
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const familyDiagnostics = familyDiagnosticsFor(sourceProfile, draftProfile);
|
|
55
|
+
const familyScores = Object.fromEntries(Object.entries(familyDiagnostics).map(([family, item]) => [family, item.score]));
|
|
56
|
+
const distance = styleDistanceFromDiagnostics(familyDiagnostics);
|
|
57
|
+
const familyConfidence = familyConfidenceFor(sourceProfile, familyDiagnostics);
|
|
58
|
+
const findings = reviewFindings(sourceProfile, draftProfile, familyScores, reviewMode, familyConfidence);
|
|
59
|
+
const fit = {
|
|
60
|
+
band: fitBand(distance, findings, familyDiagnostics),
|
|
61
|
+
distance,
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
return {
|
|
65
|
+
schemaVersion: 2,
|
|
66
|
+
file: displayPath(filePath, cwd),
|
|
67
|
+
summary: {
|
|
68
|
+
mode: reviewMode,
|
|
69
|
+
fit,
|
|
70
|
+
familyScores,
|
|
71
|
+
familyConfidence,
|
|
72
|
+
suppressedFindings: suppressedFindingsFor(sourceProfile, familyConfidence),
|
|
73
|
+
familyDistances: Object.fromEntries(Object.entries(familyDiagnostics).map(([family, item]) => [family, item.distance])),
|
|
74
|
+
familyDrift: Object.fromEntries(Object.entries(familyDiagnostics).map(([family, item]) => [family, item.drift])),
|
|
75
|
+
thresholds: Object.fromEntries(Object.entries(familyDiagnostics).map(([family, item]) => [family, item.threshold])),
|
|
76
|
+
corpusConfidence: sourceProfile.source.confidence,
|
|
77
|
+
},
|
|
78
|
+
findings,
|
|
79
|
+
exitCode: REVIEW_MODES[reviewMode].exitOnDrift && fit.band === "drift" ? 1 : 0,
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export function renderVoiceReviewV2(result) {
|
|
84
|
+
const lines = [
|
|
85
|
+
"Voice review notes, not AI detection.",
|
|
86
|
+
"",
|
|
87
|
+
result.file,
|
|
88
|
+
`Fit: ${capitalize(result.summary.fit.band)} (${result.summary.fit.distance} style distance)`,
|
|
89
|
+
`Corpus confidence: ${capitalize(result.summary.corpusConfidence.band)} - ${result.summary.corpusConfidence.message}`,
|
|
90
|
+
"Family scores:",
|
|
91
|
+
];
|
|
92
|
+
|
|
93
|
+
for (const [family, score] of Object.entries(result.summary.familyScores)) {
|
|
94
|
+
lines.push(`- ${familyLabel(family)}: ${score}`);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
lines.push("");
|
|
98
|
+
if (result.findings.length === 0) {
|
|
99
|
+
lines.push("No high-confidence voice drift findings.");
|
|
100
|
+
appendSuppressedFindings(lines, result.summary.suppressedFindings);
|
|
101
|
+
lines.push("");
|
|
102
|
+
return lines.join("\n");
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
lines.push("Start here:");
|
|
106
|
+
result.findings.slice(0, 6).forEach((finding, index) => {
|
|
107
|
+
lines.push(`${index + 1}. ${priorityLabel(finding.priority)} ${familyLabel(finding.family)}`);
|
|
108
|
+
if (finding.confidence) {
|
|
109
|
+
lines.push(`Confidence: ${capitalize(finding.confidence.band)}; stability ${finding.confidence.stability}`);
|
|
110
|
+
}
|
|
111
|
+
lines.push(`Why: ${finding.why}`);
|
|
112
|
+
lines.push(`Do this: ${finding.action}`);
|
|
113
|
+
lines.push("");
|
|
114
|
+
});
|
|
115
|
+
appendSuppressedFindings(lines, result.summary.suppressedFindings);
|
|
116
|
+
return lines.join("\n");
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function reviewFindings(source, draft, scores, mode, familyConfidence) {
|
|
120
|
+
const modeConfig = REVIEW_MODES[mode];
|
|
121
|
+
if (source.source?.confidence?.band === "weak" || !modeConfig.findingThresholds) {
|
|
122
|
+
return [];
|
|
123
|
+
}
|
|
124
|
+
const findings = [];
|
|
125
|
+
if (shouldFlag("evidence", scores, modeConfig, familyConfidence)) {
|
|
126
|
+
findings.push({
|
|
127
|
+
id: "v2.evidence-drift",
|
|
128
|
+
family: "evidence",
|
|
129
|
+
priority: "review",
|
|
130
|
+
confidence: familyConfidence.evidence,
|
|
131
|
+
why: `Source evidence sentence rate is ${source.families.evidence.features.evidenceSentenceRate}; draft rate is ${draft.families.evidence.features.evidenceSentenceRate}.`,
|
|
132
|
+
action: "Add concrete support before broad claims: a scene, quote, number, citation, URL, sensory detail, or specific example.",
|
|
133
|
+
});
|
|
134
|
+
}
|
|
135
|
+
if (shouldFlag("rhythm", scores, modeConfig, familyConfidence)) {
|
|
136
|
+
findings.push({
|
|
137
|
+
id: "v2.rhythm-drift",
|
|
138
|
+
family: "rhythm",
|
|
139
|
+
priority: "consider",
|
|
140
|
+
confidence: familyConfidence.rhythm,
|
|
141
|
+
why: `Source median sentence/paragraph length is ${source.families.rhythm.features.sentenceWords.median}/${source.families.rhythm.features.paragraphWords.median}; draft is ${draft.families.rhythm.features.sentenceWords.median}/${draft.families.rhythm.features.paragraphWords.median}.`,
|
|
142
|
+
action: "Revise sentence and paragraph pacing toward the learned range.",
|
|
143
|
+
});
|
|
144
|
+
}
|
|
145
|
+
if (shouldFlag("rhetoricalShape", scores, modeConfig, familyConfidence)) {
|
|
146
|
+
findings.push({
|
|
147
|
+
id: "v2.shape-drift",
|
|
148
|
+
family: "rhetoricalShape",
|
|
149
|
+
priority: "consider",
|
|
150
|
+
confidence: familyConfidence.rhetoricalShape,
|
|
151
|
+
why: `Source opening shape is ${source.families.rhetoricalShape.features.openingMoves.slice(0, 3).join(" -> ")}; draft opening shape is ${draft.families.rhetoricalShape.features.openingMoves.slice(0, 3).join(" -> ")}.`,
|
|
152
|
+
action: "Rework the opening so it uses a compatible scene, claim, contrast, reflection, or example sequence.",
|
|
153
|
+
});
|
|
154
|
+
}
|
|
155
|
+
if (shouldFlag("discourse", scores, modeConfig, familyConfidence)) {
|
|
156
|
+
findings.push({
|
|
157
|
+
id: "v2.discourse-drift",
|
|
158
|
+
family: "discourse",
|
|
159
|
+
priority: "consider",
|
|
160
|
+
confidence: familyConfidence.discourse,
|
|
161
|
+
why: `Source transition rates are ${rateMapLabel(source.families.discourse.features.transitionRates)}; draft rates are ${rateMapLabel(draft.families.discourse.features.transitionRates)}.`,
|
|
162
|
+
action: "Revise repeated sentence turns, callbacks, and transitions so the draft does not lean on a different discourse pattern.",
|
|
163
|
+
});
|
|
164
|
+
}
|
|
165
|
+
if (shouldFlag("lexical", scores, modeConfig, familyConfidence)) {
|
|
166
|
+
findings.push({
|
|
167
|
+
id: "v2.lexical-drift",
|
|
168
|
+
family: "lexical",
|
|
169
|
+
priority: "consider",
|
|
170
|
+
confidence: familyConfidence.lexical,
|
|
171
|
+
why: `Function-word, masked character, punctuation, or boundary-token habits drift from the calibrated source profile.`,
|
|
172
|
+
action: "Revise diction and punctuation where it improves the article; do not stuff source topic words or add artificial imperfections.",
|
|
173
|
+
});
|
|
174
|
+
}
|
|
175
|
+
if (shouldFlag("register", scores, modeConfig, familyConfidence)) {
|
|
176
|
+
findings.push({
|
|
177
|
+
id: "v2.register-drift",
|
|
178
|
+
family: "register",
|
|
179
|
+
priority: "consider",
|
|
180
|
+
confidence: familyConfidence.register,
|
|
181
|
+
why: `Source primary register is ${source.families.register.features.primary.value}; draft primary register is ${draft.families.register.features.primary.value}.`,
|
|
182
|
+
action: "Bring the stance closer to the learned genre mix while preserving the draft's real subject and audience.",
|
|
183
|
+
});
|
|
184
|
+
}
|
|
185
|
+
if (shouldFlag("structure", scores, modeConfig, familyConfidence)) {
|
|
186
|
+
findings.push({
|
|
187
|
+
id: "v2.structure-drift",
|
|
188
|
+
family: "structure",
|
|
189
|
+
priority: "consider",
|
|
190
|
+
confidence: familyConfidence.structure,
|
|
191
|
+
why: `Source heading/list/quote and section-size patterns differ from the draft's document structure.`,
|
|
192
|
+
action: "Adjust section shape, opening order, list use, or quote placement only where the article benefits from that structure.",
|
|
193
|
+
});
|
|
194
|
+
}
|
|
195
|
+
return findings;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
function shouldFlag(family, scores, modeConfig, familyConfidence) {
|
|
199
|
+
return familyConfidence[family]?.usableForFindings &&
|
|
200
|
+
Number.isFinite(scores[family]) &&
|
|
201
|
+
scores[family] < modeConfig.findingThresholds[family];
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
function familyConfidenceFor(sourceProfile, familyDiagnostics) {
|
|
205
|
+
const profileDiagnostics = sourceProfile.calibration?.familyDiagnostics ?? {};
|
|
206
|
+
return Object.fromEntries(Object.keys(sourceProfile.families ?? {}).map((family) => {
|
|
207
|
+
const profile = profileDiagnostics[family] ?? {};
|
|
208
|
+
const runtime = familyDiagnostics[family] ?? {};
|
|
209
|
+
return [family, {
|
|
210
|
+
band: sourceProfile.families[family]?.confidence ?? "low",
|
|
211
|
+
stability: runtime.stability ?? profile.stability ?? 0.45,
|
|
212
|
+
observations: profile.observations ?? runtime.observations ?? 0,
|
|
213
|
+
usableForFindings: profile.usableForFindings ?? sourceProfile.source?.confidence?.band !== "weak",
|
|
214
|
+
minimumEvidence: profile.minimumEvidence ?? null,
|
|
215
|
+
}];
|
|
216
|
+
}));
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
function suppressedFindingsFor(sourceProfile, familyConfidence) {
|
|
220
|
+
return Object.entries(familyConfidence)
|
|
221
|
+
.filter(([, confidence]) => !confidence.usableForFindings)
|
|
222
|
+
.map(([family, confidence]) => ({
|
|
223
|
+
family,
|
|
224
|
+
reason: suppressedReasonFor(sourceProfile, confidence),
|
|
225
|
+
}));
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
function suppressedReasonFor(sourceProfile, confidence) {
|
|
229
|
+
if (sourceProfile.source?.confidence?.band === "weak") {
|
|
230
|
+
return "weak corpus confidence; add more representative source documents before trusting this family.";
|
|
231
|
+
}
|
|
232
|
+
const minimum = confidence.minimumEvidence;
|
|
233
|
+
if (minimum && (!minimum.documentsMet || !minimum.sentencesMet || !minimum.wordsMet)) {
|
|
234
|
+
return `minimum evidence not met; requires ${minimum.requiredDocuments} document(s), ${minimum.requiredSentences} sentence(s), and ${minimum.requiredWords} word(s).`;
|
|
235
|
+
}
|
|
236
|
+
return "calibration stability is too low for a high-confidence finding.";
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
function appendSuppressedFindings(lines, suppressedFindings = []) {
|
|
240
|
+
if (!suppressedFindings.length) {
|
|
241
|
+
return;
|
|
242
|
+
}
|
|
243
|
+
lines.push("");
|
|
244
|
+
lines.push("Suppressed findings:");
|
|
245
|
+
suppressedFindings.slice(0, 6).forEach((item) => {
|
|
246
|
+
lines.push(`- ${familyLabel(item.family)}: ${item.reason}`);
|
|
247
|
+
});
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
function rateMapLabel(value = {}) {
|
|
251
|
+
return Object.entries(value).map(([key, rate]) => `${key}:${rate}`).join(", ") || "none";
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
function normalizeReviewMode(mode) {
|
|
255
|
+
const normalized = String(mode ?? "balanced").toLowerCase();
|
|
256
|
+
if (!Object.hasOwn(REVIEW_MODES, normalized)) {
|
|
257
|
+
throw new Error(`Unsupported review mode: ${mode}. Expected loose, balanced, or strict.`);
|
|
258
|
+
}
|
|
259
|
+
return normalized;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
function fitBand(distance, findings, familyDiagnostics) {
|
|
263
|
+
const maxDrift = Math.max(0, ...Object.values(familyDiagnostics).map((item) => item.drift));
|
|
264
|
+
if (findings.some((finding) => finding.priority === "review") || distance >= 35 || maxDrift >= 1.25) {
|
|
265
|
+
return "drift";
|
|
266
|
+
}
|
|
267
|
+
if (findings.length > 0 || distance >= 20 || maxDrift > 0) {
|
|
268
|
+
return "watch";
|
|
269
|
+
}
|
|
270
|
+
return "close";
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
function displayPath(filePath, cwd) {
|
|
274
|
+
const relative = path.relative(cwd, filePath);
|
|
275
|
+
if (relative && !relative.startsWith("..") && !path.isAbsolute(relative)) {
|
|
276
|
+
return relative.split(path.sep).join("/");
|
|
277
|
+
}
|
|
278
|
+
return filePath.split(path.sep).join("/");
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
function resolvePath(cwd, value) {
|
|
282
|
+
return path.isAbsolute(value) ? value : path.resolve(cwd, value);
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
function capitalize(value) {
|
|
286
|
+
return value.charAt(0).toUpperCase() + value.slice(1);
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
function familyLabel(family) {
|
|
290
|
+
return {
|
|
291
|
+
evidence: "Evidence",
|
|
292
|
+
rhythm: "Rhythm",
|
|
293
|
+
rhetoricalShape: "Rhetorical shape",
|
|
294
|
+
discourse: "Discourse",
|
|
295
|
+
lexical: "Lexical style",
|
|
296
|
+
register: "Register",
|
|
297
|
+
structure: "Structure",
|
|
298
|
+
}[family] ?? capitalize(family);
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
function priorityLabel(priority) {
|
|
302
|
+
return priority === "review" ? "Review" : "Consider";
|
|
303
|
+
}
|