dravoice 0.1.0 → 0.1.1

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/src/v2/brief.js CHANGED
@@ -1,146 +1,146 @@
1
- import fs from "node:fs";
2
- import path from "node:path";
3
- import { evidenceTypes } from "./analyzers/evidence.js";
4
- import { parseDocument } from "./document-model.js";
5
- import { loadVoicePackV2 } from "./profile.js";
6
-
7
- export function voiceArticleBriefV2({ voice, topic, evidence, cwd = process.cwd() }) {
8
- const profile = typeof voice === "string" ? loadVoicePackV2(resolvePath(cwd, voice)) : voice;
9
- const evidenceResult = evidence ? evidenceAnchorsFromFile({ evidence, cwd }) : {
10
- source: null,
11
- anchors: [],
12
- };
13
-
14
- return {
15
- schemaVersion: 2,
16
- generatedBy: "dravoice-v2-brief",
17
- topic,
18
- voice: {
19
- corpusConfidence: profile.source.confidence,
20
- sourceFileCount: profile.source.documentCount,
21
- sourceWordCount: profile.source.wordCount,
22
- primaryRegister: profile.families.register.features.primary.value,
23
- evidenceSentenceRate: profile.families.evidence.features.evidenceSentenceRate,
24
- featureFamilies: Object.keys(profile.families),
25
- draftingRules: profile.guidance.draftingRules.slice(0, 5),
26
- },
27
- workingThesis: `Draft a grounded article about ${topic}. Let the supplied evidence set the size of each claim before broadening the lesson.`,
28
- evidence: evidenceResult,
29
- missingEvidence: missingEvidenceFor({ topic, evidenceAnchors: evidenceResult.anchors }),
30
- outline: outlineFor(profile),
31
- voiceCautions: [
32
- ...profile.guidance.avoid,
33
- "Mark unsupported claims as [specific evidence needed] instead of inventing proof.",
34
- ].slice(0, 5),
35
- };
36
- }
37
-
38
- export function renderVoiceBriefV2(brief) {
39
- const lines = [
40
- `# Article Brief: ${brief.topic}`,
41
- "",
42
- "## Voice Source",
43
- "",
44
- `- Corpus confidence: ${capitalize(brief.voice.corpusConfidence.band)} - ${brief.voice.corpusConfidence.message}`,
45
- `- Source files: ${brief.voice.sourceFileCount}`,
46
- `- Primary register: ${brief.voice.primaryRegister}`,
47
- `- Evidence sentence rate: ${brief.voice.evidenceSentenceRate}`,
48
- "",
49
- "## Working Thesis",
50
- "",
51
- `- ${brief.workingThesis}`,
52
- "",
53
- "## Evidence Anchors",
54
- "",
55
- ];
56
-
57
- if (brief.evidence.anchors.length) {
58
- for (const item of brief.evidence.anchors) {
59
- const typeList = item.types.length ? ` (${item.types.join(", ")})` : "";
60
- lines.push(`- ${brief.evidence.source}:${item.line}${typeList} - ${item.text}`);
61
- }
62
- } else {
63
- lines.push("- [specific evidence needed] Add notes, dates, quotes, examples, or source links before drafting broad claims.");
64
- }
65
-
66
- lines.push("", "## Missing Evidence", "");
67
- lines.push(...brief.missingEvidence.map((item) => `- ${item}`));
68
-
69
- lines.push("", "## Outline", "");
70
- lines.push(...brief.outline.map((item, index) => `${index + 1}. ${item}`));
71
-
72
- lines.push("", "## Voice Cautions", "");
73
- lines.push(...brief.voiceCautions.map((item) => `- ${item}`));
74
-
75
- lines.push(
76
- "",
77
- "## Drafting Prompt",
78
- "",
79
- `Write the article about ${brief.topic} using the evidence anchors above. Keep claims close to concrete support, follow the voice cautions, and write [specific evidence needed] anywhere the brief does not supply enough ground.`,
80
- "",
81
- );
82
-
83
- return lines.join("\n");
84
- }
85
-
86
- function evidenceAnchorsFromFile({ evidence, cwd }) {
87
- const evidencePath = resolvePath(cwd, evidence);
88
- const contents = fs.readFileSync(evidencePath, "utf8");
89
- const document = parseDocument({
90
- filePath: evidencePath,
91
- rootDir: cwd,
92
- contents,
93
- });
94
-
95
- return {
96
- source: displayPath(evidencePath, cwd),
97
- anchors: document.sentences
98
- .map((sentence) => ({
99
- line: sentence.line,
100
- text: sentence.text,
101
- types: evidenceTypes(sentence.text),
102
- }))
103
- .filter((sentence) => sentence.types.length > 0)
104
- .slice(0, 8),
105
- };
106
- }
107
-
108
- function missingEvidenceFor({ topic, evidenceAnchors }) {
109
- const items = [
110
- `Add [specific evidence needed] for the central claim about ${topic}.`,
111
- "Add [specific evidence needed] for any number, date, quote, source, or example the article depends on.",
112
- ];
113
- if (evidenceAnchors.length === 0) {
114
- items.unshift("No evidence anchors were detected; collect concrete notes before asking for a full draft.");
115
- }
116
- return items;
117
- }
118
-
119
- function outlineFor(profile) {
120
- const opening = profile.families.rhetoricalShape.features.openingMoves.slice(0, 3).join(" -> ");
121
- const sentenceMedian = profile.families.rhythm.features.sentenceWords.median;
122
- return [
123
- opening
124
- ? `Start from a concrete artifact or observation, keeping the opening shape compatible with: ${opening}.`
125
- : "Start from a concrete artifact or observation before making the larger claim.",
126
- "Name the pressure, question, or practical stakes that make the evidence matter.",
127
- `Develop the article in the learned register with sentence pacing near the ${sentenceMedian}-word median where it fits.`,
128
- "Close by returning to the evidence and leaving the reader with a practical handle, not a generic conclusion.",
129
- ];
130
- }
131
-
132
- function resolvePath(cwd, value) {
133
- return path.isAbsolute(value) ? value : path.join(cwd, value);
134
- }
135
-
136
- function displayPath(filePath, rootDir) {
137
- const relative = path.relative(rootDir, filePath);
138
- if (relative && !relative.startsWith("..") && !path.isAbsolute(relative)) {
139
- return relative.split(path.sep).join("/");
140
- }
141
- return filePath.split(path.sep).join("/");
142
- }
143
-
144
- function capitalize(value) {
145
- return String(value ?? "").charAt(0).toUpperCase() + String(value ?? "").slice(1);
146
- }
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import { evidenceTypes } from "./analyzers/evidence.js";
4
+ import { parseDocument } from "./document-model.js";
5
+ import { loadVoicePackV2 } from "./profile.js";
6
+
7
+ export function voiceArticleBriefV2({ voice, topic, evidence, cwd = process.cwd() }) {
8
+ const profile = typeof voice === "string" ? loadVoicePackV2(resolvePath(cwd, voice)) : voice;
9
+ const evidenceResult = evidence ? evidenceAnchorsFromFile({ evidence, cwd }) : {
10
+ source: null,
11
+ anchors: [],
12
+ };
13
+
14
+ return {
15
+ schemaVersion: 2,
16
+ generatedBy: "dravoice-v2-brief",
17
+ topic,
18
+ voice: {
19
+ corpusConfidence: profile.source.confidence,
20
+ sourceFileCount: profile.source.documentCount,
21
+ sourceWordCount: profile.source.wordCount,
22
+ primaryRegister: profile.families.register.features.primary.value,
23
+ evidenceSentenceRate: profile.families.evidence.features.evidenceSentenceRate,
24
+ featureFamilies: Object.keys(profile.families),
25
+ draftingRules: profile.guidance.draftingRules.slice(0, 5),
26
+ },
27
+ workingThesis: `Draft a grounded article about ${topic}. Let the supplied evidence set the size of each claim before broadening the lesson.`,
28
+ evidence: evidenceResult,
29
+ missingEvidence: missingEvidenceFor({ topic, evidenceAnchors: evidenceResult.anchors }),
30
+ outline: outlineFor(profile),
31
+ voiceCautions: [
32
+ ...profile.guidance.avoid,
33
+ "Mark unsupported claims as [specific evidence needed] instead of inventing proof.",
34
+ ].slice(0, 5),
35
+ };
36
+ }
37
+
38
+ export function renderVoiceBriefV2(brief) {
39
+ const lines = [
40
+ `# Article Brief: ${brief.topic}`,
41
+ "",
42
+ "## Voice Source",
43
+ "",
44
+ `- Corpus confidence: ${capitalize(brief.voice.corpusConfidence.band)} - ${brief.voice.corpusConfidence.message}`,
45
+ `- Source files: ${brief.voice.sourceFileCount}`,
46
+ `- Primary register: ${brief.voice.primaryRegister}`,
47
+ `- Evidence sentence rate: ${brief.voice.evidenceSentenceRate}`,
48
+ "",
49
+ "## Working Thesis",
50
+ "",
51
+ `- ${brief.workingThesis}`,
52
+ "",
53
+ "## Evidence Anchors",
54
+ "",
55
+ ];
56
+
57
+ if (brief.evidence.anchors.length) {
58
+ for (const item of brief.evidence.anchors) {
59
+ const typeList = item.types.length ? ` (${item.types.join(", ")})` : "";
60
+ lines.push(`- ${brief.evidence.source}:${item.line}${typeList} - ${item.text}`);
61
+ }
62
+ } else {
63
+ lines.push("- [specific evidence needed] Add notes, dates, quotes, examples, or source links before drafting broad claims.");
64
+ }
65
+
66
+ lines.push("", "## Missing Evidence", "");
67
+ lines.push(...brief.missingEvidence.map((item) => `- ${item}`));
68
+
69
+ lines.push("", "## Outline", "");
70
+ lines.push(...brief.outline.map((item, index) => `${index + 1}. ${item}`));
71
+
72
+ lines.push("", "## Voice Cautions", "");
73
+ lines.push(...brief.voiceCautions.map((item) => `- ${item}`));
74
+
75
+ lines.push(
76
+ "",
77
+ "## Drafting Prompt",
78
+ "",
79
+ `Write the article about ${brief.topic} using the evidence anchors above. Keep claims close to concrete support, follow the voice cautions, and write [specific evidence needed] anywhere the brief does not supply enough ground.`,
80
+ "",
81
+ );
82
+
83
+ return lines.join("\n");
84
+ }
85
+
86
+ function evidenceAnchorsFromFile({ evidence, cwd }) {
87
+ const evidencePath = resolvePath(cwd, evidence);
88
+ const contents = fs.readFileSync(evidencePath, "utf8");
89
+ const document = parseDocument({
90
+ filePath: evidencePath,
91
+ rootDir: cwd,
92
+ contents,
93
+ });
94
+
95
+ return {
96
+ source: displayPath(evidencePath, cwd),
97
+ anchors: document.sentences
98
+ .map((sentence) => ({
99
+ line: sentence.line,
100
+ text: sentence.text,
101
+ types: evidenceTypes(sentence.text),
102
+ }))
103
+ .filter((sentence) => sentence.types.length > 0)
104
+ .slice(0, 8),
105
+ };
106
+ }
107
+
108
+ function missingEvidenceFor({ topic, evidenceAnchors }) {
109
+ const items = [
110
+ `Add [specific evidence needed] for the central claim about ${topic}.`,
111
+ "Add [specific evidence needed] for any number, date, quote, source, or example the article depends on.",
112
+ ];
113
+ if (evidenceAnchors.length === 0) {
114
+ items.unshift("No evidence anchors were detected; collect concrete notes before asking for a full draft.");
115
+ }
116
+ return items;
117
+ }
118
+
119
+ function outlineFor(profile) {
120
+ const opening = profile.families.rhetoricalShape.features.openingMoves.slice(0, 3).join(" -> ");
121
+ const sentenceMedian = profile.families.rhythm.features.sentenceWords.median;
122
+ return [
123
+ opening
124
+ ? `Start from a concrete artifact or observation, keeping the opening shape compatible with: ${opening}.`
125
+ : "Start from a concrete artifact or observation before making the larger claim.",
126
+ "Name the pressure, question, or practical stakes that make the evidence matter.",
127
+ `Develop the article in the learned register with sentence pacing near the ${sentenceMedian}-word median where it fits.`,
128
+ "Close by returning to the evidence and leaving the reader with a practical handle, not a generic conclusion.",
129
+ ];
130
+ }
131
+
132
+ function resolvePath(cwd, value) {
133
+ return path.isAbsolute(value) ? value : path.join(cwd, value);
134
+ }
135
+
136
+ function displayPath(filePath, rootDir) {
137
+ const relative = path.relative(rootDir, filePath);
138
+ if (relative && !relative.startsWith("..") && !path.isAbsolute(relative)) {
139
+ return relative.split(path.sep).join("/");
140
+ }
141
+ return filePath.split(path.sep).join("/");
142
+ }
143
+
144
+ function capitalize(value) {
145
+ return String(value ?? "").charAt(0).toUpperCase() + String(value ?? "").slice(1);
146
+ }
package/src/v2/profile.js CHANGED
@@ -6,8 +6,14 @@ import { analyzeLexical } from "./analyzers/lexical.js";
6
6
  import { analyzeRegister } from "./analyzers/register.js";
7
7
  import { analyzeRhetoricalShape } from "./analyzers/rhetorical-shape.js";
8
8
  import { analyzeRhythm } from "./analyzers/rhythm.js";
9
- import { analyzeStructure } from "./analyzers/structure.js";
10
- import { loadDocuments } from "./document-model.js";
9
+ import { analyzeStructure } from "./analyzers/structure.js";
10
+ import { loadDocuments } from "./document-model.js";
11
+ import {
12
+ STYLOMETRIC_REFERENCES,
13
+ defaultStyleThresholds,
14
+ distanceByFamily,
15
+ percentile,
16
+ } from "./stylometry.js";
11
17
 
12
18
  export function learnVoicePackV2({ examplesDir, outDir }) {
13
19
  const documents = loadDocuments({ examplesDir });
@@ -30,38 +36,80 @@ export function loadVoicePackV2(voiceDir) {
30
36
  return profile;
31
37
  }
32
38
 
33
- export function buildVoiceProfileV2({ documents }) {
34
- const source = sourceSummary(documents);
35
- const families = {
36
- rhythm: analyzeRhythm(documents),
37
- lexical: analyzeLexical(documents),
38
- register: analyzeRegister(documents),
39
- discourse: analyzeDiscourse(documents),
40
- rhetoricalShape: analyzeRhetoricalShape(documents),
41
- evidence: analyzeEvidence(documents),
42
- structure: analyzeStructure(documents),
43
- };
44
-
45
- return {
39
+ export function buildVoiceProfileV2({ documents }) {
40
+ const source = sourceSummary(documents);
41
+ const families = analyzeFeatureFamilies(documents);
42
+
43
+ return {
46
44
  schemaVersion: 2,
47
45
  generatedBy: "dravoice-v2",
48
- tool: { name: "Dravoice", cli: "drav" },
46
+ tool: { name: "Dravoice", cli: "drav" },
49
47
  source,
50
48
  families,
51
49
  guidance: guidanceFor({ source, families }),
52
50
  calibration: {
53
51
  featureStability: Object.fromEntries(Object.entries(families).map(([name, family]) => [name, family.confidence])),
54
- tolerances: {
55
- rhythmMedianWords: toleranceFor(source.confidence.band, 5, 8, 12),
56
- evidenceRate: toleranceFor(source.confidence.band, 0.12, 0.18, 0.25),
57
- },
58
- minimumDraftSize: {
59
- words: source.confidence.band === "weak" ? 25 : 35,
60
- sentences: source.confidence.band === "weak" ? 3 : 4,
52
+ tolerances: {
53
+ rhythmMedianWords: toleranceFor(source.confidence.band, 5, 8, 12),
54
+ evidenceRate: toleranceFor(source.confidence.band, 0.12, 0.18, 0.25),
55
+ },
56
+ styleThresholds: styleThresholdsFor(documents, families),
57
+ minimumDraftSize: {
58
+ words: source.confidence.band === "weak" ? 25 : 35,
59
+ sentences: source.confidence.band === "weak" ? 3 : 4,
61
60
  },
62
61
  },
63
- };
64
- }
62
+ };
63
+ }
64
+
65
+ function analyzeFeatureFamilies(documents) {
66
+ return {
67
+ rhythm: analyzeRhythm(documents),
68
+ lexical: analyzeLexical(documents),
69
+ register: analyzeRegister(documents),
70
+ discourse: analyzeDiscourse(documents),
71
+ rhetoricalShape: analyzeRhetoricalShape(documents),
72
+ evidence: analyzeEvidence(documents),
73
+ structure: analyzeStructure(documents),
74
+ };
75
+ }
76
+
77
+ function styleThresholdsFor(documents, fallbackFamilies) {
78
+ const fallbackThresholds = defaultStyleThresholds();
79
+ const distancesByFamily = Object.fromEntries(Object.keys(fallbackFamilies).map((family) => [family, []]));
80
+
81
+ if (documents.length >= 2) {
82
+ for (let index = 0; index < documents.length; index += 1) {
83
+ const referenceDocuments = documents.filter((_, candidateIndex) => candidateIndex !== index);
84
+ const referenceFamilies = analyzeFeatureFamilies(referenceDocuments);
85
+ const heldoutFamilies = analyzeFeatureFamilies([documents[index]]);
86
+ for (const family of Object.keys(fallbackFamilies)) {
87
+ distancesByFamily[family].push(distanceByFamily(
88
+ family,
89
+ referenceFamilies[family].features,
90
+ heldoutFamilies[family].features,
91
+ ));
92
+ }
93
+ }
94
+ }
95
+
96
+ const families = {};
97
+ for (const family of Object.keys(fallbackFamilies)) {
98
+ const observations = distancesByFamily[family];
99
+ const observedThreshold = observations.length > 0 ? percentile(observations, 0.9) : 0;
100
+ const fallback = fallbackThresholds[family].threshold;
101
+ families[family] = {
102
+ threshold: Math.max(0.01, Math.min(0.95, observedThreshold || fallback)),
103
+ observations: observations.length,
104
+ };
105
+ }
106
+
107
+ return {
108
+ method: "leave-one-out-cosine-delta",
109
+ references: STYLOMETRIC_REFERENCES,
110
+ families,
111
+ };
112
+ }
65
113
 
66
114
  function writeVoicePackV2(outDir, profile) {
67
115
  fs.mkdirSync(outDir, { recursive: true });