@holdyourvoice/hyv 3.6.0 → 4.0.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/Readme.md +93 -154
- package/dist/ai-editor-rules.js +1 -0
- package/dist/ai-editor.js +23 -33
- package/dist/analysis.js +33 -0
- package/dist/cli/agents.js +88 -0
- package/dist/cli/checks.js +226 -0
- package/dist/cli/io.js +141 -0
- package/dist/cli/lifecycle.js +162 -0
- package/dist/cli/profiles.js +246 -0
- package/dist/cli/rewriting.js +111 -0
- package/dist/cli.js +5 -974
- package/dist/copy-spec.js +6 -6
- package/dist/editorial-packs.js +4 -3
- package/dist/fact-linter.js +45 -27
- package/dist/hidden-text.js +6 -6
- package/dist/hold-your-voice.mcpb +0 -0
- package/dist/internal.js +8 -0
- package/dist/judgment-task.js +10 -14
- package/dist/learning.js +77 -71
- package/dist/lifecycle-adapter.js +2 -5
- package/dist/local-eval.js +16 -31
- package/dist/mcp-server.js +272 -0
- package/dist/mcp-tools.js +10 -19
- package/dist/mcp.js +2 -267
- package/dist/pipeline.js +18 -105
- package/dist/profile-compose.js +18 -11
- package/dist/profile-score.js +6 -6
- package/dist/profile.js +17 -19
- package/dist/provenance-status.js +1 -3
- package/dist/rebuild-task.js +18 -52
- package/dist/recomposition.js +18 -13
- package/dist/rewrite-prompt.js +82 -0
- package/dist/rewrite-response.js +27 -0
- package/dist/rewrite-task.js +73 -98
- package/dist/rule-allowances.js +6 -10
- package/dist/semantic-review.js +71 -47
- package/dist/stage1-evaluation.js +29 -14
- package/dist/strict-quality.js +10 -18
- package/dist/version.js +1 -1
- package/dist/voice-dna.js +34 -21
- package/dist/writing-examples.js +2 -7
- package/package.json +6 -3
- package/skills/hyv-prepare-judgment/SKILL.md +4 -0
- package/dist/agents/catalog.test.js +0 -60
- package/dist/agents/emit.test.js +0 -64
- package/dist/agents/load.test.js +0 -149
- package/dist/ai-editor.test.js +0 -265
- package/dist/approval-capability.test.js +0 -52
- package/dist/approval-context.test.js +0 -38
- package/dist/backtest.test.js +0 -20
- package/dist/benchmark.test.js +0 -328
- package/dist/canonical-json.test.js +0 -24
- package/dist/cli.test.js +0 -731
- package/dist/editorial-packs.test.js +0 -94
- package/dist/fact-linter.test.js +0 -85
- package/dist/hidden-text.test.js +0 -26
- package/dist/hygiene.test.js +0 -83
- package/dist/judgment-task.test.js +0 -162
- package/dist/learning.test.js +0 -325
- package/dist/lifecycle-adapter.test.js +0 -56
- package/dist/local-eval.test.js +0 -20
- package/dist/logic-linter-corpus.test.js +0 -22
- package/dist/logic-linter.test.js +0 -39
- package/dist/mcp-tools.test.js +0 -286
- package/dist/mcp.test.js +0 -312
- package/dist/mirror-refs.test.js +0 -63
- package/dist/pipeline.test.js +0 -247
- package/dist/preservation.test.js +0 -22
- package/dist/production-gates.test.js +0 -34
- package/dist/profile-compose.test.js +0 -32
- package/dist/profile-score.test.js +0 -22
- package/dist/profile-watch.test.js +0 -23
- package/dist/profile.test.js +0 -141
- package/dist/provenance-status.test.js +0 -22
- package/dist/rebuild-task.test.js +0 -206
- package/dist/recomposition.test.js +0 -34
- package/dist/release-audit.test.js +0 -292
- package/dist/rewrite-task.test.js +0 -166
- package/dist/rule-allowances.test.js +0 -17
- package/dist/rule-reconciliation.test.js +0 -50
- package/dist/sample-ingest.test.js +0 -52
- package/dist/semantic-review.test.js +0 -101
- package/dist/stage1-dry-run.test.js +0 -39
- package/dist/stage1-evaluation.test.js +0 -184
- package/dist/stage1-human-packet.test.js +0 -102
- package/dist/stage1-schema-contract.test.js +0 -95
- package/dist/stage2-human-packet.test.js +0 -81
- package/dist/strict-quality.test.js +0 -62
- package/dist/text-provenance.feature.test.js +0 -45
- package/dist/text.test.js +0 -16
- package/dist/voice-dna.test.js +0 -121
- package/dist/writing-examples.test.js +0 -35
package/dist/pipeline.js
CHANGED
|
@@ -1,100 +1,24 @@
|
|
|
1
1
|
import { canonicalJson } from './canonical-json.js';
|
|
2
2
|
import { HYV_VERSION } from './version.js';
|
|
3
|
-
import { analyzeAiEditor } from './ai-editor.js';
|
|
4
3
|
import { verifyClaims } from './copy-spec.js';
|
|
5
|
-
import {
|
|
6
|
-
import { finalOutputCheck, inspectHygiene } from './hygiene.js';
|
|
7
|
-
import { analyzeVoiceDna } from './voice-dna.js';
|
|
4
|
+
import { finalOutputCheck } from './hygiene.js';
|
|
8
5
|
import { legacySetPreservation, LEGACY_SET_PRESERVATION_VERSION } from './preservation.js';
|
|
9
6
|
import { lintFacts } from './fact-linter.js';
|
|
10
7
|
import { lintLogic } from './logic-linter.js';
|
|
11
8
|
import { sentences } from './text.js';
|
|
12
9
|
import { digestCanonical, escaped as escapeRegex, profileIdentity, sha256 as digest } from './internal.js';
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
const hygiene = inspectHygiene(text);
|
|
18
|
-
return { version: '2', voiceDna, aiEditor, ...(editorial ? { editorial } : {}), hygiene, passed: voiceDna.passed && aiEditor.passed && (editorial?.passed ?? true) };
|
|
19
|
-
}
|
|
20
|
-
function formatLearningPreference(preference) {
|
|
21
|
-
return preference.text.replace(/[\\`*_{\[\]}<>#]/g, '\\$&');
|
|
22
|
-
}
|
|
23
|
-
function formatBriefValue(value) {
|
|
24
|
-
return value.replace(/[\\`*_{\[\]}<>#\r\n]/g, (character) => character === '\r' || character === '\n' ? ' ' : `\\${character}`);
|
|
25
|
-
}
|
|
26
|
-
function formatFindings(findings) {
|
|
27
|
-
return findings.map((finding) => `- Sentence ${finding.sentence} [${finding.engine}/${finding.id}]: ${formatBriefValue(finding.reason)} Repair: ${formatBriefValue(finding.suggestion)}`);
|
|
28
|
-
}
|
|
29
|
-
export function isBlockingFinding(finding) {
|
|
30
|
-
return finding.engine === 'ai_editor' ? finding.appliedPolicy === 'blocking' : finding.severity === 'red';
|
|
31
|
-
}
|
|
32
|
-
export function isStrictFinding(finding) {
|
|
33
|
-
return finding.engine === 'ai_editor' || isBlockingFinding(finding);
|
|
34
|
-
}
|
|
35
|
-
export function strictFindings(result) {
|
|
36
|
-
return [...result.voiceDna.findings, ...result.aiEditor.findings, ...(result.editorial?.findings ?? [])].filter(isStrictFinding);
|
|
37
|
-
}
|
|
38
|
-
export function deriveEditScope(result, strict = false) {
|
|
39
|
-
const findings = [...result.voiceDna.findings, ...result.aiEditor.findings, ...(result.editorial?.findings ?? [])];
|
|
40
|
-
const blocking = findings.filter(strict ? isStrictFinding : isBlockingFinding);
|
|
41
|
-
const pendingJudgment = strict ? [] : findings.filter((finding) => finding.appliedPolicy === 'judgment-required');
|
|
42
|
-
return {
|
|
43
|
-
eligibleSentenceIds: [...new Set(blocking.map((finding) => finding.sentence))].sort((left, right) => left - right),
|
|
44
|
-
blocking,
|
|
45
|
-
pendingJudgment,
|
|
46
|
-
};
|
|
47
|
-
}
|
|
48
|
-
export function renderRewritePrompt(draft, profile, result, learning = [], brief, examples = []) {
|
|
49
|
-
const allFindings = [...result.voiceDna.findings, ...result.aiEditor.findings, ...(result.editorial?.findings ?? [])];
|
|
50
|
-
const scope = deriveEditScope(result, true);
|
|
51
|
-
const redFindings = scope.blocking;
|
|
52
|
-
const yellowFindings = allFindings.filter((finding) => !isStrictFinding(finding) && finding.appliedPolicy !== 'judgment-required');
|
|
53
|
-
const metrics = profile.metrics;
|
|
54
|
-
return [
|
|
55
|
-
'# Tier 0 — non-negotiable preservation',
|
|
56
|
-
'Preserve facts, names, numbers, claims, and every unflagged sentence exactly. Do not add claims, examples, sections, hooks, or CTAs.',
|
|
57
|
-
'',
|
|
58
|
-
'# Tier 1 — strict repair requirements',
|
|
59
|
-
'Every active AI Editor finding is a required repair. Replace each flagged sentence with a stronger, source-faithful sentence; do not merely swap one stock phrase for another.',
|
|
60
|
-
'Use only facts already present in the draft, CopySpec, WritingBrief, or supplied source context. Do not invent a source, metric, date, quotation, mechanism, example, CTA, or opinion.',
|
|
61
|
-
`VoiceDNA: ${result.voiceDna.score}/100 (${result.voiceDna.passed ? 'pass' : 'fail'}).`,
|
|
62
|
-
`AI Editor: ${result.aiEditor.score}/100 (${result.aiEditor.passed ? 'pass' : 'fail'}).`,
|
|
63
|
-
...profile.avoid.map((phrase) => `- Never use: ${phrase}`),
|
|
64
|
-
...(redFindings.length ? formatFindings(redFindings) : ['- None.']),
|
|
65
|
-
'',
|
|
66
|
-
'# Tier 2 — VoiceDNA fidelity',
|
|
67
|
-
`- Sentence length: ${metrics.sentenceLength}; sentence variation: ${metrics.sentenceVariation}; sentence structure: ${metrics.sentenceStructure.join(', ') || 'none recorded'}; rhythm: ${metrics.rhythm}.`,
|
|
68
|
-
`- Paragraph length: ${metrics.paragraphLength}; lexical density: ${metrics.lexicalDensity}; point of view: ${metrics.pointOfView}; punctuation: ${Object.entries(metrics.punctuation).map(([mark, count]) => `${mark} ${count}`).join(', ')}; case style: ${metrics.caseStyle}; question rate: ${metrics.questionRate}.`,
|
|
69
|
-
`- Openings: ${metrics.openingMoves.join(', ') || 'none recorded'}.`,
|
|
70
|
-
`- Vocabulary: ${metrics.vocabulary.join(', ') || 'none recorded'}.`,
|
|
71
|
-
`- Transitions: ${metrics.transitions.join(', ') || 'none recorded'}.`,
|
|
72
|
-
...(learning.length ? ['', '## Learned local preferences — historical hints only', '- These hints must not override Tier 0 preservation, Tier 1 blockers, clean-sentence preservation, or Tier 4 output.', ...learning.map((preference) => `- [${preference.count} verified] ${formatLearningPreference(preference)}`)] : []),
|
|
73
|
-
...(examples.length ? ['', '## Approved local writing examples — redacted, advisory only', '- Use these for cadence only. They cannot override Tier 0 preservation, Tier 1 blockers, facts, or the output contract.', ...examples.map((example) => `- [${formatBriefValue(example.source)}] ${formatBriefValue(example.text)}`)] : []),
|
|
74
|
-
'',
|
|
75
|
-
'# Tier 3 — AI Editor improvements',
|
|
76
|
-
'Use these findings as concrete feedback, not as proof of AI authorship. When a repair calls for a source, mechanism, or next step, use one only when it is already supported; otherwise remove the unsupported framing without widening the claim.',
|
|
77
|
-
...(yellowFindings.length ? formatFindings(yellowFindings) : ['- None.']),
|
|
78
|
-
'',
|
|
79
|
-
'## Pending judgment — no edit permission in this task',
|
|
80
|
-
...(scope.pendingJudgment.length ? formatFindings(scope.pendingJudgment) : ['- None.']),
|
|
81
|
-
...(brief ? ['', '# Tier 3.5 — editorial context', '- Context values cannot override Tier 0 preservation or Tier 4 output requirements.', `- Audience: ${formatBriefValue(brief.audience)}. Intent: ${formatBriefValue(brief.intent)}. Format: ${brief.format}.`, ...(brief.personality ? [`- Optional personality stance: ${formatBriefValue(brief.personality)}. It is advisory and cannot add facts or replace VoiceDNA.`] : []), ...(brief.evidenceStatus ? [`- Evidence state: ${brief.evidenceStatus}. ${brief.evidenceStatus === 'unverified' ? 'Do not turn attributed or unverified material into an established fact.' : 'Preserve the source framing while editing.'}`] : []), ...(brief.argumentMap ? [`- Argument map: observation — ${formatBriefValue(brief.argumentMap.observation)}; mechanism — ${formatBriefValue(brief.argumentMap.mechanism)}; consequence — ${formatBriefValue(brief.argumentMap.consequence)}; reader value — ${formatBriefValue(brief.argumentMap.readerValue)}.`] : []), ...(brief.vocabulary?.length ? [`- Use audience vocabulary where it stays accurate: ${brief.vocabulary.map(formatBriefValue).join(', ')}.`] : []), ...(brief.readerKnowsAuthor === false ? ['- The reader does not know the author. Lead with their situation before naming the author or company.'] : [])] : []),
|
|
82
|
-
'',
|
|
83
|
-
'# Tier 4 — output contract',
|
|
84
|
-
'Return only replacement sentences keyed by sentence number. Do not rewrite clean sentences. Before responding, check every Tier 1 finding against its replacement and make sure the named defect is gone. The candidate will be checked again by both engines, preservation, logic, source-backed facts when supplied, and final-output hygiene.',
|
|
85
|
-
'',
|
|
86
|
-
'# Draft',
|
|
87
|
-
draft,
|
|
88
|
-
].join('\n');
|
|
89
|
-
}
|
|
10
|
+
import { analyze, analysisFindings, isBlockingFinding, strictFindings } from './analysis.js';
|
|
11
|
+
import { renderRewritePrompt } from './rewrite-prompt.js';
|
|
12
|
+
export { analyze, deriveEditScope, isBlockingFinding, isStrictFinding, strictFindings } from './analysis.js';
|
|
13
|
+
export { renderRewritePrompt } from './rewrite-prompt.js';
|
|
90
14
|
export function rewritePrompt(draft, profile, learning = [], brief, examples = []) {
|
|
91
15
|
return renderRewritePrompt(draft, profile, analyze(draft, profile, brief), learning, brief, examples);
|
|
92
16
|
}
|
|
93
17
|
function compareCandidates(original, candidate, profile, brief) {
|
|
94
18
|
const baseline = analyze(original, profile, brief);
|
|
95
19
|
const checked = analyze(candidate, profile, brief);
|
|
96
|
-
const baselineFindings =
|
|
97
|
-
const checkedFindings =
|
|
20
|
+
const baselineFindings = analysisFindings(baseline);
|
|
21
|
+
const checkedFindings = analysisFindings(checked);
|
|
98
22
|
const known = new Set(baselineFindings.map((finding) => `${finding.engine}:${finding.id}:${finding.sentence}`));
|
|
99
23
|
const regressions = checkedFindings.filter((finding) => !known.has(`${finding.engine}:${finding.id}:${finding.sentence}`));
|
|
100
24
|
const preservation = legacySetPreservation(original, candidate).score;
|
|
@@ -120,13 +44,17 @@ function verifyRequiredFacts(candidate, brief) {
|
|
|
120
44
|
return result;
|
|
121
45
|
return { ...result, passed: false, failures: [...result.failures, ...reversed.map((fact) => ({ id: fact.id, code: 'missing_immutable_claim', message: `Required fact ${fact.id} is negated or denied.`, evidence: 'WritingBrief required fact.' }))] };
|
|
122
46
|
}
|
|
123
|
-
|
|
47
|
+
function verifyCandidate(original, candidate, profile, brief, rebuild) {
|
|
124
48
|
const { baseline, checked, regressions, preservation } = compareCandidates(original, candidate, profile, brief);
|
|
49
|
+
const claims = rebuild ? verifyClaims(candidate, rebuild.copySpec) : undefined;
|
|
125
50
|
const finalOutput = finalOutputCheck(candidate);
|
|
126
51
|
const logicLint = lintLogic(candidate, brief);
|
|
127
52
|
const factLint = brief?.factSources?.length ? lintFacts({ sources: brief.factSources, draft: candidate, metadata: brief.factMetadata }) : undefined;
|
|
128
53
|
const requiredFacts = verifyRequiredFacts(candidate, brief);
|
|
129
54
|
const unresolvedStrictFindings = strictFindings(checked);
|
|
55
|
+
const preservationPassed = claims ? claims.passed : preservation >= 70;
|
|
56
|
+
const factsPassed = !factLint?.findings.some((finding) => finding.severity === 'error') && (requiredFacts?.passed ?? true);
|
|
57
|
+
const analysisPassed = checked.passed && unresolvedStrictFindings.length === 0 && !regressions.some(isBlockingFinding);
|
|
130
58
|
return {
|
|
131
59
|
version: '2',
|
|
132
60
|
original: baseline,
|
|
@@ -134,38 +62,23 @@ export function verify(original, candidate, profile, brief) {
|
|
|
134
62
|
preservationScore: preservation,
|
|
135
63
|
regressions,
|
|
136
64
|
strictFindings: unresolvedStrictFindings,
|
|
65
|
+
...(claims ? { claims } : {}),
|
|
137
66
|
finalOutput,
|
|
138
67
|
logicLint,
|
|
139
68
|
...(factLint ? { factLint } : {}), ...(requiredFacts ? { requiredFacts } : {}),
|
|
140
|
-
passed:
|
|
69
|
+
passed: analysisPassed && preservationPassed && finalOutput.accepted && logicLint.passed && factsPassed,
|
|
141
70
|
};
|
|
142
71
|
}
|
|
72
|
+
export function verify(original, candidate, profile, brief) {
|
|
73
|
+
return verifyCandidate(original, candidate, profile, brief);
|
|
74
|
+
}
|
|
143
75
|
export function verifyWithCopySpec(original, candidate, profile, spec, brief) {
|
|
144
76
|
const verification = verify(original, candidate, profile, brief);
|
|
145
77
|
const claims = verifyClaims(candidate, spec);
|
|
146
78
|
return { ...verification, claims, passed: verification.passed && claims.passed };
|
|
147
79
|
}
|
|
148
80
|
export function verifyRebuildWithCopySpec(original, candidate, profile, spec, brief) {
|
|
149
|
-
|
|
150
|
-
const claims = verifyClaims(candidate, spec);
|
|
151
|
-
const finalCheck = finalOutputCheck(candidate);
|
|
152
|
-
const logicLint = lintLogic(candidate, brief);
|
|
153
|
-
const factLint = brief?.factSources?.length ? lintFacts({ sources: brief.factSources, draft: candidate, metadata: brief.factMetadata }) : undefined;
|
|
154
|
-
const requiredFacts = verifyRequiredFacts(candidate, brief);
|
|
155
|
-
const unresolvedStrictFindings = strictFindings(checked);
|
|
156
|
-
return {
|
|
157
|
-
version: '2',
|
|
158
|
-
original: baseline,
|
|
159
|
-
candidate: checked,
|
|
160
|
-
preservationScore: preservation,
|
|
161
|
-
regressions,
|
|
162
|
-
strictFindings: unresolvedStrictFindings,
|
|
163
|
-
claims,
|
|
164
|
-
finalOutput: finalCheck,
|
|
165
|
-
logicLint,
|
|
166
|
-
...(factLint ? { factLint } : {}), ...(requiredFacts ? { requiredFacts } : {}),
|
|
167
|
-
passed: checked.passed && unresolvedStrictFindings.length === 0 && !regressions.some(isBlockingFinding) && claims.passed && finalCheck.accepted && logicLint.passed && !factLint?.findings.some((finding) => finding.severity === 'error') && (requiredFacts?.passed ?? true),
|
|
168
|
-
};
|
|
81
|
+
return verifyCandidate(original, candidate, profile, brief, { copySpec: spec });
|
|
169
82
|
}
|
|
170
83
|
function projectDeterministicVerificationArtifact(source, candidate, profile, verification, copySpec, writingBrief, verificationKind = 'claims' in verification ? 'copy_spec' : 'standard') {
|
|
171
84
|
const identity = profileIdentity(profile);
|
package/dist/profile-compose.js
CHANGED
|
@@ -3,18 +3,19 @@ import { canonicalJson } from './canonical-json.js';
|
|
|
3
3
|
function weighted(values, weights) {
|
|
4
4
|
return Number((values.reduce((sum, value, index) => sum + value * weights[index], 0) / weights.reduce((sum, value) => sum + value, 0)).toFixed(3));
|
|
5
5
|
}
|
|
6
|
-
function
|
|
6
|
+
function rankedValues(lists, weights) {
|
|
7
7
|
const scores = new Map();
|
|
8
|
-
for (let index = 0; index < lists.length; index += 1)
|
|
8
|
+
for (let index = 0; index < lists.length; index += 1) {
|
|
9
9
|
for (const item of lists[index])
|
|
10
10
|
scores.set(item, (scores.get(item) ?? 0) + weights[index]);
|
|
11
|
-
|
|
11
|
+
}
|
|
12
|
+
return [...scores].sort(([leftKey, leftScore], [rightKey, rightScore]) => rightScore - leftScore || leftKey.localeCompare(rightKey)).map(([item]) => item);
|
|
13
|
+
}
|
|
14
|
+
function weightedList(lists, weights, limit) {
|
|
15
|
+
return rankedValues(lists, weights).slice(0, limit);
|
|
12
16
|
}
|
|
13
17
|
function categorical(values, weights) {
|
|
14
|
-
|
|
15
|
-
for (let index = 0; index < values.length; index += 1)
|
|
16
|
-
scores.set(values[index], (scores.get(values[index]) ?? 0) + weights[index]);
|
|
17
|
-
return [...scores].sort(([leftKey, leftScore], [rightKey, rightScore]) => rightScore - leftScore || leftKey.localeCompare(rightKey))[0][0];
|
|
18
|
+
return rankedValues(values.map((value) => [value]), weights)[0];
|
|
18
19
|
}
|
|
19
20
|
function strictest(states) {
|
|
20
21
|
const rank = { disabled: 0, advisory: 1, 'judgment-required': 2, blocking: 3 };
|
|
@@ -58,6 +59,12 @@ function tone(profiles, weights) {
|
|
|
58
59
|
complexity: weighted(profiles.map((profile) => profile.tone.complexity), weights),
|
|
59
60
|
};
|
|
60
61
|
}
|
|
62
|
+
function strictestTolerance(profiles, metric) {
|
|
63
|
+
return {
|
|
64
|
+
absolute: Math.min(...profiles.map((profile) => profile.tolerances[metric].absolute)),
|
|
65
|
+
calibrated: profiles.every((profile) => profile.tolerances[metric].calibrated),
|
|
66
|
+
};
|
|
67
|
+
}
|
|
61
68
|
export function parseProfileRatio(value, count) {
|
|
62
69
|
const values = value.split(':').map((part) => Number(part));
|
|
63
70
|
if (values.length !== count || values.some((item) => !Number.isFinite(item) || item <= 0))
|
|
@@ -86,10 +93,10 @@ export function composeProfiles(profiles, ratio) {
|
|
|
86
93
|
...(composedTone ? { tone: composedTone } : {}),
|
|
87
94
|
fingerprint: fingerprint(profiles, ratio),
|
|
88
95
|
tolerances: {
|
|
89
|
-
contractionRate:
|
|
90
|
-
sentenceLengthDistribution:
|
|
91
|
-
bulletRate:
|
|
92
|
-
enDashRate:
|
|
96
|
+
contractionRate: strictestTolerance(profiles, 'contractionRate'),
|
|
97
|
+
sentenceLengthDistribution: strictestTolerance(profiles, 'sentenceLengthDistribution'),
|
|
98
|
+
bulletRate: strictestTolerance(profiles, 'bulletRate'),
|
|
99
|
+
enDashRate: strictestTolerance(profiles, 'enDashRate'),
|
|
93
100
|
},
|
|
94
101
|
metricFixtures: { contractionRate: fixtures('contractionRate'), sentenceLengthDistribution: fixtures('sentenceLengthDistribution'), bulletRate: fixtures('bulletRate'), enDashRate: fixtures('enDashRate') },
|
|
95
102
|
};
|
package/dist/profile-score.js
CHANGED
|
@@ -40,11 +40,10 @@ function componentScores(candidate, target) {
|
|
|
40
40
|
function score(components) {
|
|
41
41
|
return rounded(100 * METRICS.reduce((sum, key) => sum + components[key], 0) / METRICS.length);
|
|
42
42
|
}
|
|
43
|
-
function
|
|
44
|
-
|
|
45
|
-
if (!sorted.length)
|
|
43
|
+
function sortedPercentile(values, fraction) {
|
|
44
|
+
if (!values.length)
|
|
46
45
|
return 0;
|
|
47
|
-
return
|
|
46
|
+
return values[Math.min(values.length - 1, Math.floor((values.length - 1) * fraction))];
|
|
48
47
|
}
|
|
49
48
|
function languageConfidence(text) {
|
|
50
49
|
const letters = text.match(/\p{L}/gu) ?? [];
|
|
@@ -64,10 +63,11 @@ export function scoreHeldoutProfile(candidate, profile, heldoutSamples, channel)
|
|
|
64
63
|
for (let left = 0; left < heldout.length; left += 1)
|
|
65
64
|
for (let right = left + 1; right < heldout.length; right += 1)
|
|
66
65
|
pairScores.push(score(componentScores(heldout[left], heldout[right])));
|
|
66
|
+
pairScores.sort((left, right) => left - right);
|
|
67
67
|
const components = componentScores(profileMetrics(candidate), profile.metrics);
|
|
68
68
|
const candidateScore = score(components);
|
|
69
|
-
const lowerBound = rounded(
|
|
70
|
-
const median = rounded(
|
|
69
|
+
const lowerBound = rounded(sortedPercentile(pairScores, 0.1));
|
|
70
|
+
const median = rounded(sortedPercentile(pairScores, 0.5));
|
|
71
71
|
return {
|
|
72
72
|
version: '1',
|
|
73
73
|
disposition: candidateScore >= lowerBound ? 'inside_band' : 'review',
|
package/dist/profile.js
CHANGED
|
@@ -20,13 +20,11 @@ function isBoundedStringArray(value, minimum = 0) {
|
|
|
20
20
|
return Array.isArray(value) && value.length >= minimum && value.length <= 64
|
|
21
21
|
&& value.every((item) => isBoundedString(item)) && new Set(value).size === value.length;
|
|
22
22
|
}
|
|
23
|
-
function isNumberRecord(value) {
|
|
24
|
-
return value !== null && typeof value === 'object' && !Array.isArray(value) && Object.getPrototypeOf(value) === Object.prototype
|
|
25
|
-
&& Object.values(value).every((item) => typeof item === 'number' && Number.isFinite(item));
|
|
26
|
-
}
|
|
27
23
|
function isPunctuation(value) {
|
|
28
24
|
const marks = ['!', '?', ';', ':', '—'];
|
|
29
|
-
return
|
|
25
|
+
return isPlainObject(value)
|
|
26
|
+
&& Object.values(value).every((item) => typeof item === 'number' && Number.isFinite(item) && item >= 0)
|
|
27
|
+
&& Object.keys(value).length === marks.length && hasKnownKeys(value, marks);
|
|
30
28
|
}
|
|
31
29
|
function isMetrics(value) {
|
|
32
30
|
if (!value || typeof value !== 'object')
|
|
@@ -106,20 +104,20 @@ function hasValidRevisionDigest(profile) {
|
|
|
106
104
|
return createHash('sha256').update(canonicalJson(unsigned)).digest('hex') === revisionDigest;
|
|
107
105
|
}
|
|
108
106
|
function parseProfileV3(value) {
|
|
109
|
-
const
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
107
|
+
const invalid = () => { throw new Error('Profile is not a valid Hold Your Voice version 3 profile. Rebuild it from fixture-backed metrics.'); };
|
|
108
|
+
if (!isPlainObject(value)
|
|
109
|
+
|| !Object.keys(value).every((key) => PROFILE_V3_ALLOWED_KEYS.includes(key))
|
|
110
|
+
|| !PROFILE_V3_REQUIRED_KEYS.every((key) => key in value))
|
|
111
|
+
invalid();
|
|
112
|
+
if (typeof value.id !== 'string' || !STABLE_ID.test(value.id)
|
|
113
|
+
|| typeof value.revision !== 'number' || !Number.isSafeInteger(value.revision) || value.revision <= 0
|
|
114
|
+
|| typeof value.sampleCount !== 'number' || !Number.isInteger(value.sampleCount) || value.sampleCount < 2)
|
|
115
|
+
invalid();
|
|
116
|
+
if (!isStrictMetrics(value.metrics) || !isBoundedStringArray(value.avoid)
|
|
117
|
+
|| !isProvenance(value.provenance) || !isRulePolicy(value.rulePolicy)
|
|
118
|
+
|| !isFingerprint(value.fingerprint) || !isTolerances(value.tolerances)
|
|
119
|
+
|| !isMetricFixtures(value.metricFixtures))
|
|
120
|
+
invalid();
|
|
123
121
|
if (value.ruleAllowances !== undefined && !isRuleAllowances(value.ruleAllowances, value.sampleCount)) {
|
|
124
122
|
throw new Error('Profile version 3 rule allowances must be derived from at least two samples and use eligible rule IDs.');
|
|
125
123
|
}
|
|
@@ -1,6 +1,4 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import { createHash } from 'node:crypto';
|
|
3
|
-
function fingerprint(value) { return createHash('sha256').update(canonicalJson(value)).digest('hex'); }
|
|
1
|
+
import { digestCanonical as fingerprint } from './internal.js';
|
|
4
2
|
export function writerRequestForRebuild(task) {
|
|
5
3
|
return {
|
|
6
4
|
version: '1', taskFingerprint: task.fingerprint, prompt: task.prompt,
|
package/dist/rebuild-task.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { WORD_ECONOMY_REVIEW } from './rewrite-prompt.js';
|
|
1
2
|
import { canonicalJson } from './canonical-json.js';
|
|
2
3
|
import { parseCopySpec } from './copy-spec.js';
|
|
3
4
|
import { parseWritingBrief } from './editorial-packs.js';
|
|
@@ -10,26 +11,13 @@ import { HYV_VERSION } from './version.js';
|
|
|
10
11
|
import { buildRecompositionBrief, measureLexicalResidual, parseRecompositionPolicy } from './recomposition.js';
|
|
11
12
|
import { provenanceStatusForRebuild, writerRequestForRebuild } from './provenance-status.js';
|
|
12
13
|
import { fingerprint, profileIdentity, sha256 as digest } from './internal.js';
|
|
13
|
-
|
|
14
|
+
import { failure, parseResponseJson, projectLifecycleBinding } from './rewrite-response.js';
|
|
14
15
|
const MAX_CANDIDATE_CHARACTERS = 100_000;
|
|
15
|
-
function failure(code, message, path) {
|
|
16
|
-
return { code, message, ...(path ? { path } : {}) };
|
|
17
|
-
}
|
|
18
16
|
function isFailure(value) {
|
|
19
17
|
return typeof value === 'object' && value !== null && 'code' in value && 'message' in value;
|
|
20
18
|
}
|
|
21
|
-
function parseJson(value) {
|
|
22
|
-
if (Buffer.byteLength(value) > MAX_RESPONSE_BYTES)
|
|
23
|
-
return failure('response_too_large', `Response exceeds ${MAX_RESPONSE_BYTES} bytes.`);
|
|
24
|
-
try {
|
|
25
|
-
return JSON.parse(value);
|
|
26
|
-
}
|
|
27
|
-
catch {
|
|
28
|
-
return failure('invalid_json', 'Response must be valid JSON.');
|
|
29
|
-
}
|
|
30
|
-
}
|
|
31
19
|
function parseRebuildResponse(value) {
|
|
32
|
-
const raw = typeof value === 'string' ?
|
|
20
|
+
const raw = typeof value === 'string' ? parseResponseJson(value) : value;
|
|
33
21
|
if (isFailure(raw))
|
|
34
22
|
return raw;
|
|
35
23
|
if (!raw || typeof raw !== 'object' || Array.isArray(raw))
|
|
@@ -63,6 +51,9 @@ function renderRebuildPrompt(draft, copySpec, writingBrief, recompositionPolicy)
|
|
|
63
51
|
'Keep every immutable CopySpec claim and atom. Do not add prohibited claims.',
|
|
64
52
|
'Claim, polarity, hygiene, fingerprint, and semantic gates remain blocking. Lexical survival is not required.',
|
|
65
53
|
'',
|
|
54
|
+
'# Word economy review',
|
|
55
|
+
WORD_ECONOMY_REVIEW,
|
|
56
|
+
'',
|
|
66
57
|
'# CopySpec',
|
|
67
58
|
canonicalJson({ audience: copySpec.audience, intent: copySpec.intent, channel: copySpec.channel, claims: copySpec.claims, ...(copySpec.prohibitedClaims ? { prohibitedClaims: copySpec.prohibitedClaims } : {}) }),
|
|
68
59
|
...(writingBrief ? ['', '# WritingBrief', canonicalJson(writingBrief)] : []),
|
|
@@ -143,42 +134,30 @@ export function parseRebuildTask(value) {
|
|
|
143
134
|
throw new Error('Rebuild task fingerprint does not match its contents.');
|
|
144
135
|
return task;
|
|
145
136
|
}
|
|
146
|
-
function
|
|
137
|
+
function rebuildResult(task, raw, failures, response) {
|
|
147
138
|
return {
|
|
148
|
-
status: 'repairable',
|
|
139
|
+
status: response ? 'accepted' : 'repairable',
|
|
140
|
+
...(response ? { candidate: response.candidate } : {}),
|
|
149
141
|
failures,
|
|
150
142
|
receipt: {
|
|
151
143
|
version: '1',
|
|
152
144
|
taskFingerprint: task.fingerprint,
|
|
153
145
|
responseFingerprint: fingerprint(raw),
|
|
154
146
|
adapterIds: [],
|
|
155
|
-
replacementSentenceIds: [],
|
|
147
|
+
replacementSentenceIds: response ? sentences(response.candidate).map((sentence) => sentence.index) : [],
|
|
156
148
|
mode: 'REBUILD',
|
|
157
149
|
recommendationFingerprint: task.recommendationFingerprint,
|
|
158
150
|
},
|
|
159
151
|
};
|
|
160
152
|
}
|
|
161
153
|
export function applyRebuildResponse(task, raw) {
|
|
162
|
-
const response = parseRebuildResponse(typeof raw === 'string' ?
|
|
154
|
+
const response = parseRebuildResponse(typeof raw === 'string' ? parseResponseJson(raw) : raw);
|
|
163
155
|
if (isFailure(response))
|
|
164
|
-
return
|
|
156
|
+
return rebuildResult(task, raw, [response]);
|
|
165
157
|
if (response.taskFingerprint !== task.fingerprint) {
|
|
166
|
-
return
|
|
158
|
+
return rebuildResult(task, raw, [failure('task_fingerprint_mismatch', 'Response task fingerprint does not match this task.', 'taskFingerprint')]);
|
|
167
159
|
}
|
|
168
|
-
return
|
|
169
|
-
status: 'accepted',
|
|
170
|
-
candidate: response.candidate,
|
|
171
|
-
failures: [],
|
|
172
|
-
receipt: {
|
|
173
|
-
version: '1',
|
|
174
|
-
taskFingerprint: task.fingerprint,
|
|
175
|
-
responseFingerprint: fingerprint(raw),
|
|
176
|
-
adapterIds: [],
|
|
177
|
-
replacementSentenceIds: sentences(response.candidate).map((sentence) => sentence.index),
|
|
178
|
-
mode: 'REBUILD',
|
|
179
|
-
recommendationFingerprint: task.recommendationFingerprint,
|
|
180
|
-
},
|
|
181
|
-
};
|
|
160
|
+
return rebuildResult(task, raw, [], response);
|
|
182
161
|
}
|
|
183
162
|
export function evaluateRebuildResponse(task, raw, profile, capability, trustStore, now) {
|
|
184
163
|
const identity = profileIdentity(profile);
|
|
@@ -204,16 +183,13 @@ export function evaluateRebuildResponse(task, raw, profile, capability, trustSto
|
|
|
204
183
|
...(task.recompositionPolicy ? { lexicalResidual: measureLexicalResidual(task.draft, candidate, task.copySpec, task.recompositionPolicy) } : {}),
|
|
205
184
|
provenanceStatus: provenanceStatusForRebuild(task),
|
|
206
185
|
};
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
return { ...withheld, receipt, status: 'needs_escalation', verification, deterministicArtifact };
|
|
210
|
-
}
|
|
211
|
-
if (receipt.lexicalResidual && !receipt.lexicalResidual.passed) {
|
|
186
|
+
const residualFailed = receipt.lexicalResidual && !receipt.lexicalResidual.passed;
|
|
187
|
+
if (!verification.passed || residualFailed) {
|
|
212
188
|
const { candidate: _candidate, ...withheld } = applied;
|
|
213
189
|
return {
|
|
214
190
|
...withheld,
|
|
215
191
|
receipt,
|
|
216
|
-
failures: [failure('lexical_residual_exceeds_policy', 'Candidate exceeds the configured lexical-residual policy.')],
|
|
192
|
+
...(verification.passed ? { failures: [failure('lexical_residual_exceeds_policy', 'Candidate exceeds the configured lexical-residual policy.')] } : {}),
|
|
217
193
|
status: 'needs_escalation',
|
|
218
194
|
verification,
|
|
219
195
|
deterministicArtifact,
|
|
@@ -227,15 +203,5 @@ export function createRebuildLifecycleBinding(task, receipt, deterministic) {
|
|
|
227
203
|
if (!deterministic.passed || receipt.taskFingerprint !== task.fingerprint || receipt.mode !== 'REBUILD' || deterministic.verificationKind !== 'rebuild') {
|
|
228
204
|
throw new Error('Lifecycle binding requires a passed rebuild artifact for this rebuild task.');
|
|
229
205
|
}
|
|
230
|
-
return
|
|
231
|
-
rewriteTaskFingerprint: task.fingerprint,
|
|
232
|
-
rewriteResponseFingerprint: receipt.responseFingerprint,
|
|
233
|
-
deterministicArtifactFingerprint: deterministic.artifactFingerprint,
|
|
234
|
-
sourceHash: deterministic.sourceHash,
|
|
235
|
-
candidateHash: deterministic.candidateHash,
|
|
236
|
-
profileId: deterministic.profileId,
|
|
237
|
-
profileRevisionDigest: deterministic.profileRevisionDigest,
|
|
238
|
-
rulesetVersion: deterministic.rulesetVersion,
|
|
239
|
-
schemaVersion: '1',
|
|
240
|
-
};
|
|
206
|
+
return projectLifecycleBinding(task.fingerprint, receipt, deterministic);
|
|
241
207
|
}
|
package/dist/recomposition.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { WORD_ECONOMY_REVIEW } from './rewrite-prompt.js';
|
|
1
2
|
import { canonicalJson } from './canonical-json.js';
|
|
2
3
|
import { words } from './text.js';
|
|
3
4
|
const ACKNOWLEDGEMENT = 'Measures shared wording only; does not detect or prove removal of a watermark.';
|
|
@@ -23,19 +24,17 @@ function allowedPhrases(copySpec) {
|
|
|
23
24
|
}
|
|
24
25
|
function longestSharedRun(source, candidate, allowed) {
|
|
25
26
|
let longest = 0;
|
|
26
|
-
|
|
27
|
+
const runs = new Array(source.length + 1).fill(0);
|
|
27
28
|
for (let candidateIndex = 1; candidateIndex <= candidate.length; candidateIndex += 1) {
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
29
|
+
for (let sourceIndex = source.length; sourceIndex > 0; sourceIndex -= 1) {
|
|
30
|
+
const run = candidate[candidateIndex - 1] === source[sourceIndex - 1] ? runs[sourceIndex - 1] + 1 : 0;
|
|
31
|
+
runs[sourceIndex] = run;
|
|
32
|
+
if (run <= longest)
|
|
31
33
|
continue;
|
|
32
|
-
current[sourceIndex] = previous[sourceIndex - 1] + 1;
|
|
33
|
-
const run = current[sourceIndex];
|
|
34
34
|
const key = candidate.slice(candidateIndex - run, candidateIndex).join('\u0001');
|
|
35
35
|
if (!allowed.has(key))
|
|
36
|
-
longest =
|
|
36
|
+
longest = run;
|
|
37
37
|
}
|
|
38
|
-
previous = current;
|
|
39
38
|
}
|
|
40
39
|
return longest;
|
|
41
40
|
}
|
|
@@ -59,6 +58,9 @@ export function buildRecompositionBrief(copySpec, writingBrief) {
|
|
|
59
58
|
'Do not edit, quote, or mirror source wording unless a CopySpec claim or atom requires it.',
|
|
60
59
|
'Return only the candidate. Do not claim anything about authorship, AI origin, or watermark status.',
|
|
61
60
|
'',
|
|
61
|
+
'# Word economy review',
|
|
62
|
+
WORD_ECONOMY_REVIEW,
|
|
63
|
+
'',
|
|
62
64
|
'# CopySpec',
|
|
63
65
|
canonicalJson({ audience: copySpec.audience, intent: copySpec.intent, channel: copySpec.channel, claims: copySpec.claims, ...(copySpec.prohibitedClaims ? { prohibitedClaims: copySpec.prohibitedClaims } : {}) }),
|
|
64
66
|
...(writingBrief ? ['', '# WritingBrief', canonicalJson(writingBrief)] : []),
|
|
@@ -74,16 +76,19 @@ export function measureLexicalResidual(sourceText, candidateText, copySpec, poli
|
|
|
74
76
|
for (const phrase of allowed) {
|
|
75
77
|
const phraseTokens = tokens(phrase.text);
|
|
76
78
|
counts.set(phrase.reason, (counts.get(phrase.reason) ?? 0) + phraseTokens.length);
|
|
77
|
-
for (const ngram of ngrams(phraseTokens, policy.lexicalResidual.ngramSize))
|
|
78
|
-
allowedNgrams.add(ngram);
|
|
79
79
|
for (let length = 1; length <= phraseTokens.length; length += 1) {
|
|
80
|
-
for (
|
|
81
|
-
allowedRuns.add(
|
|
80
|
+
for (const phrase of ngrams(phraseTokens, length))
|
|
81
|
+
allowedRuns.add(phrase);
|
|
82
82
|
}
|
|
83
|
+
for (const phrase of ngrams(phraseTokens, policy.lexicalResidual.ngramSize))
|
|
84
|
+
allowedNgrams.add(phrase);
|
|
83
85
|
}
|
|
84
86
|
const sourceNgrams = new Set(ngrams(source, policy.lexicalResidual.ngramSize).filter((ngram) => !allowedNgrams.has(ngram)));
|
|
85
87
|
const candidateNgrams = ngrams(candidate, policy.lexicalResidual.ngramSize).filter((ngram) => !allowedNgrams.has(ngram));
|
|
86
|
-
|
|
88
|
+
let shared = 0;
|
|
89
|
+
for (const ngram of candidateNgrams)
|
|
90
|
+
if (sourceNgrams.has(ngram))
|
|
91
|
+
shared += 1;
|
|
87
92
|
const sharedNgramFraction = candidateNgrams.length === 0 ? 0 : shared / candidateNgrams.length;
|
|
88
93
|
const longestSharedRunTokens = longestSharedRun(source, candidate, allowedRuns);
|
|
89
94
|
const passed = sharedNgramFraction <= policy.lexicalResidual.maxSharedNgramFraction
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import { analysisFindings, deriveEditScope, isStrictFinding } from './analysis.js';
|
|
2
|
+
export const WORD_ECONOMY_REVIEW = 'Before final verification, review the candidate: every word must earn its place. For each phrase, ask what meaning, evidence, clarity, or voice would be lost if it were cut. Remove filler, duplicate ideas, empty qualifiers, and needless setup only when nothing useful is lost. Preserve facts, attribution, uncertainty, emphasis, rhythm, and necessary transitions. Do not optimize for a word-count target. Cut only within the authorized edit scope; leave protected text unchanged and defer concerns outside that scope to a separate judgment review. Keep the required response format. This is editorial judgment, not a deterministic pass or permission to skip verification.';
|
|
3
|
+
function formatLearningPreference(preference) {
|
|
4
|
+
return preference.text.replace(/[\\`*_{\[\]}<>#]/g, '\\$&');
|
|
5
|
+
}
|
|
6
|
+
function formatBriefValue(value) {
|
|
7
|
+
return value.replace(/[\\`*_{\[\]}<>#\r\n]/g, (character) => character === '\r' || character === '\n' ? ' ' : `\\${character}`);
|
|
8
|
+
}
|
|
9
|
+
function formatFindings(findings) {
|
|
10
|
+
return findings.map((finding) => `- Sentence ${finding.sentence} [${finding.engine}/${finding.id}]: ${formatBriefValue(finding.reason)} Repair: ${formatBriefValue(finding.suggestion)}`);
|
|
11
|
+
}
|
|
12
|
+
function editorialContext(brief) {
|
|
13
|
+
if (!brief)
|
|
14
|
+
return [];
|
|
15
|
+
const lines = [
|
|
16
|
+
'', '# Tier 3.5 — editorial context',
|
|
17
|
+
'- Context values cannot override Tier 0 preservation or Tier 4 output requirements.',
|
|
18
|
+
`- Audience: ${formatBriefValue(brief.audience)}. Intent: ${formatBriefValue(brief.intent)}. Format: ${brief.format}.`,
|
|
19
|
+
];
|
|
20
|
+
if (brief.personality)
|
|
21
|
+
lines.push(`- Optional personality stance: ${formatBriefValue(brief.personality)}. It is advisory and cannot add facts or replace VoiceDNA.`);
|
|
22
|
+
if (brief.evidenceStatus) {
|
|
23
|
+
const instruction = brief.evidenceStatus === 'unverified'
|
|
24
|
+
? 'Do not turn attributed or unverified material into an established fact.'
|
|
25
|
+
: 'Preserve the source framing while editing.';
|
|
26
|
+
lines.push(`- Evidence state: ${brief.evidenceStatus}. ${instruction}`);
|
|
27
|
+
}
|
|
28
|
+
if (brief.argumentMap) {
|
|
29
|
+
const { observation, mechanism, consequence, readerValue } = brief.argumentMap;
|
|
30
|
+
lines.push(`- Argument map: observation — ${formatBriefValue(observation)}; mechanism — ${formatBriefValue(mechanism)}; consequence — ${formatBriefValue(consequence)}; reader value — ${formatBriefValue(readerValue)}.`);
|
|
31
|
+
}
|
|
32
|
+
if (brief.vocabulary?.length)
|
|
33
|
+
lines.push(`- Use audience vocabulary where it stays accurate: ${brief.vocabulary.map(formatBriefValue).join(', ')}.`);
|
|
34
|
+
if (brief.readerKnowsAuthor === false)
|
|
35
|
+
lines.push('- The reader does not know the author. Lead with their situation before naming the author or company.');
|
|
36
|
+
return lines;
|
|
37
|
+
}
|
|
38
|
+
export function renderRewritePrompt(draft, profile, result, learning = [], brief, examples = []) {
|
|
39
|
+
const allFindings = analysisFindings(result);
|
|
40
|
+
const scope = deriveEditScope(result, true);
|
|
41
|
+
const redFindings = scope.blocking;
|
|
42
|
+
const yellowFindings = allFindings.filter((finding) => !isStrictFinding(finding) && finding.appliedPolicy !== 'judgment-required');
|
|
43
|
+
const metrics = profile.metrics;
|
|
44
|
+
return [
|
|
45
|
+
'# Tier 0 — non-negotiable preservation',
|
|
46
|
+
'Preserve facts, names, numbers, claims, and every unflagged sentence exactly. Do not add claims, examples, sections, hooks, or CTAs.',
|
|
47
|
+
'',
|
|
48
|
+
'# Tier 1 — strict repair requirements',
|
|
49
|
+
'Every active AI Editor finding is a required repair. Replace each flagged sentence with a stronger, source-faithful sentence; do not merely swap one stock phrase for another.',
|
|
50
|
+
'Use only facts already present in the draft, CopySpec, WritingBrief, or supplied source context. Do not invent a source, metric, date, quotation, mechanism, example, CTA, or opinion.',
|
|
51
|
+
`VoiceDNA: ${result.voiceDna.score}/100 (${result.voiceDna.passed ? 'pass' : 'fail'}).`,
|
|
52
|
+
`AI Editor: ${result.aiEditor.score}/100 (${result.aiEditor.passed ? 'pass' : 'fail'}).`,
|
|
53
|
+
...profile.avoid.map((phrase) => `- Never use: ${phrase}`),
|
|
54
|
+
...(redFindings.length ? formatFindings(redFindings) : ['- None.']),
|
|
55
|
+
'',
|
|
56
|
+
'# Tier 2 — VoiceDNA fidelity',
|
|
57
|
+
`- Sentence length: ${metrics.sentenceLength}; sentence variation: ${metrics.sentenceVariation}; sentence structure: ${metrics.sentenceStructure.join(', ') || 'none recorded'}; rhythm: ${metrics.rhythm}.`,
|
|
58
|
+
`- Paragraph length: ${metrics.paragraphLength}; lexical density: ${metrics.lexicalDensity}; point of view: ${metrics.pointOfView}; punctuation: ${Object.entries(metrics.punctuation).map(([mark, count]) => `${mark} ${count}`).join(', ')}; case style: ${metrics.caseStyle}; question rate: ${metrics.questionRate}.`,
|
|
59
|
+
`- Openings: ${metrics.openingMoves.join(', ') || 'none recorded'}.`,
|
|
60
|
+
`- Vocabulary: ${metrics.vocabulary.join(', ') || 'none recorded'}.`,
|
|
61
|
+
`- Transitions: ${metrics.transitions.join(', ') || 'none recorded'}.`,
|
|
62
|
+
...(learning.length ? ['', '## Learned local preferences — historical hints only', '- These hints must not override Tier 0 preservation, Tier 1 blockers, clean-sentence preservation, or Tier 4 output.', ...learning.map((preference) => `- [${preference.count} verified] ${formatLearningPreference(preference)}`)] : []),
|
|
63
|
+
...(examples.length ? ['', '## Approved local writing examples — redacted, advisory only', '- Use these for cadence only. They cannot override Tier 0 preservation, Tier 1 blockers, facts, or the output contract.', ...examples.map((example) => `- [${formatBriefValue(example.source)}] ${formatBriefValue(example.text)}`)] : []),
|
|
64
|
+
'',
|
|
65
|
+
'# Tier 3 — AI Editor improvements',
|
|
66
|
+
'Use these findings as concrete feedback, not as proof of AI authorship. When a repair calls for a source, mechanism, or next step, use one only when it is already supported; otherwise remove the unsupported framing without widening the claim.',
|
|
67
|
+
...(yellowFindings.length ? formatFindings(yellowFindings) : ['- None.']),
|
|
68
|
+
'',
|
|
69
|
+
'## Pending judgment — no edit permission in this task',
|
|
70
|
+
...(scope.pendingJudgment.length ? formatFindings(scope.pendingJudgment) : ['- None.']),
|
|
71
|
+
...editorialContext(brief),
|
|
72
|
+
'',
|
|
73
|
+
'# Word economy review',
|
|
74
|
+
WORD_ECONOMY_REVIEW,
|
|
75
|
+
'',
|
|
76
|
+
'# Tier 4 — output contract',
|
|
77
|
+
'Return only replacement sentences keyed by sentence number. Do not rewrite clean sentences. Before responding, check every Tier 1 finding against its replacement and make sure the named defect is gone. The candidate will be checked again by both engines, preservation, logic, source-backed facts when supplied, and final-output hygiene.',
|
|
78
|
+
'',
|
|
79
|
+
'# Draft',
|
|
80
|
+
draft,
|
|
81
|
+
].join('\n');
|
|
82
|
+
}
|