agentforge-framework 0.2.0__py3-none-any.whl
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.
- agentforge_framework/.claude-plugin/plugin.json +4 -0
- agentforge_framework/__init__.py +3 -0
- agentforge_framework/agents/__init__.py +92 -0
- agentforge_framework/agents/architect.py +146 -0
- agentforge_framework/agents/implementer.py +162 -0
- agentforge_framework/agents/orchestrator.py +588 -0
- agentforge_framework/agents/reviewer.py +335 -0
- agentforge_framework/agents/security.py +138 -0
- agentforge_framework/agents/tester.py +125 -0
- agentforge_framework/cli.py +461 -0
- agentforge_framework/context/__init__.py +1 -0
- agentforge_framework/context/extractors/__init__.py +76 -0
- agentforge_framework/context/extractors/base.py +47 -0
- agentforge_framework/context/extractors/python.py +65 -0
- agentforge_framework/context/extractors/sql.py +121 -0
- agentforge_framework/context/extractors/yaml.py +59 -0
- agentforge_framework/context/prompt.py +104 -0
- agentforge_framework/context/resolver.py +185 -0
- agentforge_framework/core/__init__.py +1 -0
- agentforge_framework/core/commands.py +170 -0
- agentforge_framework/core/config.py +90 -0
- agentforge_framework/core/contracts.py +875 -0
- agentforge_framework/core/gates.py +333 -0
- agentforge_framework/core/issues.py +697 -0
- agentforge_framework/core/plan_format.py +272 -0
- agentforge_framework/core/process.py +141 -0
- agentforge_framework/core/project.py +262 -0
- agentforge_framework/core/registry.py +455 -0
- agentforge_framework/core/repo.py +185 -0
- agentforge_framework/core/router.py +1 -0
- agentforge_framework/core/runtime.py +639 -0
- agentforge_framework/core/skills.py +255 -0
- agentforge_framework/core/workflow.py +215 -0
- agentforge_framework/plugins/__init__.py +35 -0
- agentforge_framework/plugins/databricks/__init__.py +86 -0
- agentforge_framework/plugins/pyspark/__init__.py +57 -0
- agentforge_framework/plugins/python/__init__.py +45 -0
- agentforge_framework/plugins/sql/__init__.py +377 -0
- agentforge_framework/providers/__init__.py +48 -0
- agentforge_framework/providers/base.py +248 -0
- agentforge_framework/providers/claude.py +159 -0
- agentforge_framework/providers/codex.py +139 -0
- agentforge_framework/skills/MANIFEST.yaml +157 -0
- agentforge_framework/skills/NOTICE +49 -0
- agentforge_framework/skills/domain-modeling/ADR-FORMAT.md +47 -0
- agentforge_framework/skills/domain-modeling/CONTEXT-FORMAT.md +60 -0
- agentforge_framework/skills/domain-modeling/SKILL.md +74 -0
- agentforge_framework/skills/domain-modeling/agents/openai.yaml +3 -0
- agentforge_framework/skills/grill-with-docs/SKILL.md +76 -0
- agentforge_framework/skills/grilling/SKILL.md +28 -0
- agentforge_framework/skills/grilling/agents/openai.yaml +3 -0
- agentforge_framework/skills/to-spec/SKILL.md +75 -0
- agentforge_framework/skills/to-spec/agents/openai.yaml +5 -0
- agentforge_framework/skills/to-tickets/SKILL.md +105 -0
- agentforge_framework/skills/to-tickets/agents/openai.yaml +5 -0
- agentforge_framework/skills/unslop/SKILL.md +131 -0
- agentforge_framework/skills/unslop/evals/fixtures/silhouette/human_reference.json +66 -0
- agentforge_framework/skills/unslop/scripts/_lang.py +106 -0
- agentforge_framework/skills/unslop/scripts/banned_phrase_scan.py +784 -0
- agentforge_framework/skills/unslop/scripts/calibrate_pairs.py +580 -0
- agentforge_framework/skills/unslop/scripts/calibrate_score.py +273 -0
- agentforge_framework/skills/unslop/scripts/check_packs.py +80 -0
- agentforge_framework/skills/unslop/scripts/check_suggestions.py +225 -0
- agentforge_framework/skills/unslop/scripts/contribute.py +373 -0
- agentforge_framework/skills/unslop/scripts/diff_check.py +139 -0
- agentforge_framework/skills/unslop/scripts/extract_constraints.py +201 -0
- agentforge_framework/skills/unslop/scripts/harvest_classify.py +223 -0
- agentforge_framework/skills/unslop/scripts/harvest_samples.py +534 -0
- agentforge_framework/skills/unslop/scripts/readability_metrics.py +295 -0
- agentforge_framework/skills/unslop/scripts/refresh_status.py +154 -0
- agentforge_framework/skills/unslop/scripts/silhouette_scan.py +390 -0
- agentforge_framework/skills/unslop/scripts/structure_scan.py +322 -0
- agentforge_framework/skills/unslop/scripts/suggest.py +211 -0
- agentforge_framework/skills/unslop/scripts/validate_preservation.py +409 -0
- agentforge_framework/skills/unslop/scripts/voice_card.py +496 -0
- agentforge_framework/skills/unslop/scripts/voice_profile.py +194 -0
- agentforge_framework/skills/unslop/scripts/voice_score.py +271 -0
- agentforge_framework/skills/unslop/scripts/wiki_sync.py +479 -0
- agentforge_framework/skills/write-plainly/SKILL.md +94 -0
- agentforge_framework/workflows/bugfix.yaml +8 -0
- agentforge_framework/workflows/feature.yaml +16 -0
- agentforge_framework/workflows/review.yaml +10 -0
- agentforge_framework-0.2.0.dist-info/METADATA +321 -0
- agentforge_framework-0.2.0.dist-info/RECORD +89 -0
- agentforge_framework-0.2.0.dist-info/WHEEL +5 -0
- agentforge_framework-0.2.0.dist-info/entry_points.txt +3 -0
- agentforge_framework-0.2.0.dist-info/licenses/LICENSE +202 -0
- agentforge_framework-0.2.0.dist-info/licenses/src/agentforge_framework/skills/NOTICE +49 -0
- agentforge_framework-0.2.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,496 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Distill a voice profile + samples into a LAYERED, pack-sized style card.
|
|
3
|
+
|
|
4
|
+
Output layout (deterministic — same inputs give byte-identical files):
|
|
5
|
+
|
|
6
|
+
<out>/card.md core sheet, always loaded, <= 300 words, with an
|
|
7
|
+
index table "when writing X, read card/X.md"
|
|
8
|
+
<out>/card/<situation>.md one sheet per COVERED situation from the taxonomy
|
|
9
|
+
|
|
10
|
+
Only situations with real sample evidence get a sheet. Uncovered situations are
|
|
11
|
+
NAMED in card.md under "Uncovered" and never given a fabricated sheet — every
|
|
12
|
+
claim on every sheet is derived from a measurable profile/sample fact or a
|
|
13
|
+
verbatim sample snippet.
|
|
14
|
+
|
|
15
|
+
``--coverage`` emits the deterministic lexical coverage matrix (which taxonomy
|
|
16
|
+
dimensions the samples exercise) as JSON and writes nothing. The classifier is
|
|
17
|
+
intentionally coarse: it only DRIVES interactive teach prompts and the sheet
|
|
18
|
+
set. A misclassified sentence can add or drop a sheet, never a card claim, so
|
|
19
|
+
misclassification is low-stakes by construction.
|
|
20
|
+
|
|
21
|
+
``--provenance`` also writes <out>/provenance.json (per-sample sha256 + word
|
|
22
|
+
counts, doc count, genre note, low-confidence flag) so a teach run is auditable.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
import argparse
|
|
26
|
+
import hashlib
|
|
27
|
+
import json
|
|
28
|
+
import re
|
|
29
|
+
import statistics
|
|
30
|
+
import sys
|
|
31
|
+
from pathlib import Path
|
|
32
|
+
|
|
33
|
+
import voice_profile
|
|
34
|
+
|
|
35
|
+
# The situation taxonomy. openings/closings are STRUCTURAL (first/last sentence
|
|
36
|
+
# of every document); the rest are lexical. Order is fixed for determinism.
|
|
37
|
+
TAXONOMY = [
|
|
38
|
+
"explaining-technical",
|
|
39
|
+
"anecdote",
|
|
40
|
+
"argument",
|
|
41
|
+
"disagreement",
|
|
42
|
+
"praise",
|
|
43
|
+
"hedging-uncertainty",
|
|
44
|
+
"numbers-data",
|
|
45
|
+
"addressing-reader",
|
|
46
|
+
"openings",
|
|
47
|
+
"closings",
|
|
48
|
+
]
|
|
49
|
+
|
|
50
|
+
STRUCTURAL = {"openings", "closings"}
|
|
51
|
+
|
|
52
|
+
# Minimum classified sentences before a lexical dimension counts as covered.
|
|
53
|
+
COVER_THRESHOLD = 2
|
|
54
|
+
|
|
55
|
+
NUMBER_WORDS = {
|
|
56
|
+
"one", "two", "three", "four", "five", "six", "seven", "eight", "nine",
|
|
57
|
+
"ten", "eleven", "twelve", "dozen", "hundred", "thousand", "million",
|
|
58
|
+
"twenty", "thirty", "forty", "fifty", "sixty", "seventy", "eighty",
|
|
59
|
+
"ninety", "percent", "half", "quarter", "double", "triple", "nine",
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
# Lexical signals per dimension. Each entry is a set of lowercase substrings;
|
|
63
|
+
# a sentence matches the dimension if any signal is present (word-boundary
|
|
64
|
+
# aware for single tokens, plain substring for multiword phrases).
|
|
65
|
+
SIGNALS = {
|
|
66
|
+
"explaining-technical": {
|
|
67
|
+
"because", "so that", "which means", "the reason", "works by",
|
|
68
|
+
"depends on", "the way it", "in order to", "that's how", "this is how",
|
|
69
|
+
"the trick is", "you have to", "the point of",
|
|
70
|
+
},
|
|
71
|
+
"argument": {
|
|
72
|
+
"therefore", "thus", "consequently", "the point is", "i distrust",
|
|
73
|
+
"clearly", "obviously", "in fact", "the truth is", "matters because",
|
|
74
|
+
"that's the point", "either way", "that's rare", "nobody", "no one",
|
|
75
|
+
},
|
|
76
|
+
"disagreement": {
|
|
77
|
+
"but i", "i don't", "no one", "nobody", "wrong", "i distrust",
|
|
78
|
+
"rather than", "not because", "i hate", "don't trust", "disagree",
|
|
79
|
+
"however", "i wasn't", "makes sense", "i can't",
|
|
80
|
+
},
|
|
81
|
+
"praise": {
|
|
82
|
+
"extraordinary", "wonderful", "dependable", "lovely", "beautiful",
|
|
83
|
+
"kindly", "generous", "grateful", "delightful", "plenty", "good choice",
|
|
84
|
+
"with care", "extraordinary care", "almost pleasant", "i like",
|
|
85
|
+
},
|
|
86
|
+
"hedging-uncertainty": {
|
|
87
|
+
"maybe", "perhaps", "probably", "might", "i guess", "i think",
|
|
88
|
+
"seems", "sort of", "kind of", "possibly", "i suppose", "not sure",
|
|
89
|
+
"or won't", "or it won't", "i believed", "or maybe",
|
|
90
|
+
},
|
|
91
|
+
"addressing-reader": set(), # handled specially (2nd person / question)
|
|
92
|
+
"numbers-data": set(), # handled specially (digits / number words)
|
|
93
|
+
"anecdote": set(), # handled specially (1st person + past/time cue)
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
FIRST_PERSON = {"i", "we", "my", "me", "we've", "i've", "i'll", "i'd", "our"}
|
|
97
|
+
TIME_CUES = {
|
|
98
|
+
"today", "yesterday", "tonight", "morning", "evening", "night", "ago",
|
|
99
|
+
"monday", "tuesday", "wednesday", "thursday", "friday", "saturday", "sunday",
|
|
100
|
+
"last", "once", "then", "later", "week", "year",
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def sha256_file(path):
|
|
105
|
+
return hashlib.sha256(path.read_bytes()).hexdigest()
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def split_sentences_text(text):
|
|
109
|
+
"""Split into trimmed sentence strings, preserving original wording."""
|
|
110
|
+
out = []
|
|
111
|
+
for chunk in re.split(r"(?<=[.!?])\s+", text.strip()):
|
|
112
|
+
chunk = re.sub(r"\s+", " ", chunk).strip()
|
|
113
|
+
if chunk:
|
|
114
|
+
out.append(chunk)
|
|
115
|
+
return out
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def _has_word(sentence_low, token):
|
|
119
|
+
return re.search(r"\b" + re.escape(token) + r"\b", sentence_low) is not None
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def _matches(sentence_low, signals):
|
|
123
|
+
for sig in signals:
|
|
124
|
+
if " " in sig:
|
|
125
|
+
if sig in sentence_low:
|
|
126
|
+
return True
|
|
127
|
+
elif _has_word(sentence_low, sig):
|
|
128
|
+
return True
|
|
129
|
+
return False
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def _is_numbers(sentence_low):
|
|
133
|
+
if re.search(r"\d", sentence_low):
|
|
134
|
+
return True
|
|
135
|
+
return any(_has_word(sentence_low, w) for w in NUMBER_WORDS)
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def _is_anecdote(sentence_low):
|
|
139
|
+
toks = set(voice_profile.words(sentence_low))
|
|
140
|
+
if not (toks & FIRST_PERSON):
|
|
141
|
+
return False
|
|
142
|
+
if toks & TIME_CUES:
|
|
143
|
+
return True
|
|
144
|
+
return any(t.endswith("ed") and len(t) > 3 for t in toks)
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def _is_addressing(sentence):
|
|
148
|
+
low = sentence.lower()
|
|
149
|
+
if sentence.rstrip().endswith("?"):
|
|
150
|
+
return True
|
|
151
|
+
return _has_word(low, "you") or _has_word(low, "your") or _has_word(low, "you're")
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def classify_dimension(sentence):
|
|
155
|
+
"""Return the set of lexical dimensions a single sentence exercises."""
|
|
156
|
+
low = sentence.lower()
|
|
157
|
+
hit = set()
|
|
158
|
+
for dim, signals in SIGNALS.items():
|
|
159
|
+
if dim in ("addressing-reader", "numbers-data", "anecdote"):
|
|
160
|
+
continue
|
|
161
|
+
if _matches(low, signals):
|
|
162
|
+
hit.add(dim)
|
|
163
|
+
if _is_numbers(low):
|
|
164
|
+
hit.add("numbers-data")
|
|
165
|
+
if _is_addressing(sentence):
|
|
166
|
+
hit.add("addressing-reader")
|
|
167
|
+
if _is_anecdote(low):
|
|
168
|
+
hit.add("anecdote")
|
|
169
|
+
return hit
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def collect(samples_dir):
|
|
173
|
+
"""Return (docs, sentences) where each doc is a list of sentence strings."""
|
|
174
|
+
docs = []
|
|
175
|
+
for path in voice_profile.iter_docs(samples_dir):
|
|
176
|
+
sents = split_sentences_text(path.read_text(errors="replace"))
|
|
177
|
+
if sents:
|
|
178
|
+
docs.append(sents)
|
|
179
|
+
return docs
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def coverage_matrix(docs):
|
|
183
|
+
"""Deterministic coverage of the taxonomy over the sample sentences."""
|
|
184
|
+
buckets = {dim: [] for dim in TAXONOMY}
|
|
185
|
+
for doc in docs:
|
|
186
|
+
if doc:
|
|
187
|
+
buckets["openings"].append(doc[0])
|
|
188
|
+
buckets["closings"].append(doc[-1])
|
|
189
|
+
for sent in doc:
|
|
190
|
+
for dim in classify_dimension(sent):
|
|
191
|
+
buckets[dim].append(sent)
|
|
192
|
+
matrix = {}
|
|
193
|
+
for dim in TAXONOMY:
|
|
194
|
+
sents = buckets[dim]
|
|
195
|
+
if dim in STRUCTURAL:
|
|
196
|
+
covered = len(docs) >= 1
|
|
197
|
+
else:
|
|
198
|
+
covered = len(sents) >= COVER_THRESHOLD
|
|
199
|
+
matrix[dim] = {
|
|
200
|
+
"count": len(sents),
|
|
201
|
+
"covered": covered,
|
|
202
|
+
"structural": dim in STRUCTURAL,
|
|
203
|
+
}
|
|
204
|
+
return matrix, buckets
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def _shortest(sentences, k=3):
|
|
208
|
+
ordered = sorted(set(sentences), key=lambda s: (len(s), s))
|
|
209
|
+
return ordered[:k]
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def _contraction_examples(docs, k=3):
|
|
213
|
+
seen = {}
|
|
214
|
+
for doc in docs:
|
|
215
|
+
for sent in doc:
|
|
216
|
+
for tok in voice_profile.words(sent):
|
|
217
|
+
if "'" in tok:
|
|
218
|
+
seen[tok] = seen.get(tok, 0) + 1
|
|
219
|
+
ranked = sorted(seen.items(), key=lambda kv: (-kv[1], kv[0]))
|
|
220
|
+
return [w for w, _ in ranked[:k]]
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
def _opener_words(docs, k=5):
|
|
224
|
+
counts = {}
|
|
225
|
+
for doc in docs:
|
|
226
|
+
for sent in doc:
|
|
227
|
+
toks = voice_profile.words(sent)
|
|
228
|
+
if toks:
|
|
229
|
+
counts[toks[0]] = counts.get(toks[0], 0) + 1
|
|
230
|
+
ranked = sorted(counts.items(), key=lambda kv: (-kv[1], kv[0]))
|
|
231
|
+
return [w for w, _ in ranked[:k]]
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
def _burstiness(profile):
|
|
235
|
+
med = profile["sentence_lengths"]["median"]
|
|
236
|
+
iqr = profile["sentence_lengths"]["iqr"]
|
|
237
|
+
if med <= 0:
|
|
238
|
+
return "uneven"
|
|
239
|
+
ratio = iqr / med
|
|
240
|
+
if ratio < 0.5:
|
|
241
|
+
return "steady"
|
|
242
|
+
if ratio < 1.1:
|
|
243
|
+
return "moderately bursty"
|
|
244
|
+
return "very bursty"
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
def _numbers_tokens(sentences):
|
|
248
|
+
toks = set()
|
|
249
|
+
for sent in sentences:
|
|
250
|
+
for m in re.findall(r"\d[\d,:.]*", sent):
|
|
251
|
+
toks.add(m)
|
|
252
|
+
for w in voice_profile.words(sent.lower()):
|
|
253
|
+
if w in NUMBER_WORDS:
|
|
254
|
+
toks.add(w)
|
|
255
|
+
return sorted(toks)
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
def _sheet_markers(dim, sentences, profile):
|
|
259
|
+
lengths = [len(voice_profile.words(s)) for s in sentences]
|
|
260
|
+
med = statistics.median(lengths) if lengths else 0
|
|
261
|
+
lines = [f"- Sentences in samples exercising this: {len(sentences)}.",
|
|
262
|
+
f"- Median length of those sentences: {int(med)} words."]
|
|
263
|
+
if dim == "numbers-data":
|
|
264
|
+
toks = _numbers_tokens(sentences)
|
|
265
|
+
lines.append("- Numeric tokens actually used: " + ", ".join(toks[:12]) + ".")
|
|
266
|
+
elif dim == "addressing-reader":
|
|
267
|
+
q = sum(1 for s in sentences if s.rstrip().endswith("?"))
|
|
268
|
+
lines.append(f"- Direct questions to the reader: {q}.")
|
|
269
|
+
lines.append("- Second person appears; keep it plain, no salesy 'you'.")
|
|
270
|
+
elif dim == "openings":
|
|
271
|
+
openers = sorted({voice_profile.words(s)[0] for s in sentences if voice_profile.words(s)})
|
|
272
|
+
lines.append("- Documents open on: " + ", ".join(openers[:8]) + ".")
|
|
273
|
+
elif dim == "closings":
|
|
274
|
+
lines.append("- Endings land on a concrete image, not a moral recap.")
|
|
275
|
+
else:
|
|
276
|
+
cr = profile["contraction_rate"]
|
|
277
|
+
lines.append(f"- Overall contraction rate: {cr:.3f} (keep it consistent here).")
|
|
278
|
+
return lines
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
SHEET_HOWTO = {
|
|
282
|
+
"explaining-technical": "Explain by naming the concrete mechanism, not the abstraction. Short causal sentences; 'because' does the work.",
|
|
283
|
+
"anecdote": "Tell it first person, past tense, one scene at a time. Concrete nouns, sensory detail, no summarizing moral.",
|
|
284
|
+
"argument": "State the claim flat, then the reason. No hedging scaffold; the point lands in one line.",
|
|
285
|
+
"disagreement": "Disagree by contrast, not confrontation. 'I don't', 'rather than', a plain preference rather than a takedown.",
|
|
286
|
+
"praise": "Praise through specific, restrained detail. Understated approval, never gushing.",
|
|
287
|
+
"hedging-uncertainty": "Hold uncertainty lightly with 'maybe' / 'probably' / 'or it won't', not corporate qualifiers.",
|
|
288
|
+
"numbers-data": "Numbers stay small, concrete, woven into the scene rather than tabulated.",
|
|
289
|
+
"addressing-reader": "Address the reader sparingly and plainly; a direct question or a flat 'you can'.",
|
|
290
|
+
"openings": "Open cold on a concrete fact or action. No throat-clearing, no thesis statement.",
|
|
291
|
+
"closings": "Close on a small, specific image. No wrap-up, no 'ultimately'.",
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
|
|
295
|
+
def build_sheet(dim, sentences, profile):
|
|
296
|
+
title = dim.replace("-", " ")
|
|
297
|
+
lines = [f"# Voice sheet: {title}", ""]
|
|
298
|
+
lines.append(SHEET_HOWTO[dim])
|
|
299
|
+
lines.append("")
|
|
300
|
+
lines.append("## Sample snippets")
|
|
301
|
+
for snip in _shortest(sentences):
|
|
302
|
+
lines.append(f"> {snip}")
|
|
303
|
+
lines.append("")
|
|
304
|
+
lines.append("## Measured markers")
|
|
305
|
+
lines.extend(_sheet_markers(dim, sentences, profile))
|
|
306
|
+
lines.append("")
|
|
307
|
+
return "\n".join(lines) + "\n"
|
|
308
|
+
|
|
309
|
+
|
|
310
|
+
def _never_does(profile):
|
|
311
|
+
never = []
|
|
312
|
+
p = profile["punctuation"]
|
|
313
|
+
label = {";": "semicolons", "!": "exclamation points", ":": "colons",
|
|
314
|
+
"-": "hyphenated dashes", "(": "parentheticals"}
|
|
315
|
+
for mark, name in label.items():
|
|
316
|
+
if p.get(mark, 0.0) == 0.0:
|
|
317
|
+
never.append(name)
|
|
318
|
+
return never
|
|
319
|
+
|
|
320
|
+
|
|
321
|
+
def build_card(profile, docs, matrix, name):
|
|
322
|
+
med = int(profile["sentence_lengths"]["median"])
|
|
323
|
+
iqr = int(profile["sentence_lengths"]["iqr"])
|
|
324
|
+
contr = profile["contraction_rate"]
|
|
325
|
+
examples = _contraction_examples(docs)
|
|
326
|
+
openers = _opener_words(docs)
|
|
327
|
+
never = _never_does(profile)
|
|
328
|
+
covered = [d for d in TAXONOMY if matrix[d]["covered"]]
|
|
329
|
+
uncovered = [d for d in TAXONOMY if not matrix[d]["covered"]]
|
|
330
|
+
|
|
331
|
+
lines = [f"# Voice card: {name}", ""]
|
|
332
|
+
lines.append(
|
|
333
|
+
f"Rhythm: median sentence {med} words, IQR {iqr}, {_burstiness(profile)}. "
|
|
334
|
+
f"Paragraphs average {int(profile['paragraph_stats']['mean_words'])} words."
|
|
335
|
+
)
|
|
336
|
+
if examples:
|
|
337
|
+
lines.append(
|
|
338
|
+
f"Contractions: rate {contr:.3f}; e.g. " + ", ".join(examples) + "."
|
|
339
|
+
)
|
|
340
|
+
else:
|
|
341
|
+
lines.append(f"Contractions: rate {contr:.3f}; rarely contracts.")
|
|
342
|
+
if never:
|
|
343
|
+
lines.append("Never: " + "; ".join(never) + ".")
|
|
344
|
+
lines.append("Openers: " + ", ".join(openers) + ".")
|
|
345
|
+
lines.append("")
|
|
346
|
+
lines.append("Match rhythm and habits first; keep facts and meaning intact.")
|
|
347
|
+
lines.append("")
|
|
348
|
+
lines.append("## When writing, read the matching sheet")
|
|
349
|
+
lines.append("")
|
|
350
|
+
lines.append("| Situation | Sheet |")
|
|
351
|
+
lines.append("|-----------|-------|")
|
|
352
|
+
for dim in covered:
|
|
353
|
+
lines.append(f"| {dim.replace('-', ' ')} | card/{dim}.md |")
|
|
354
|
+
lines.append("")
|
|
355
|
+
if uncovered:
|
|
356
|
+
lines.append(
|
|
357
|
+
"Uncovered (no sample evidence — do not fabricate a voice for these): "
|
|
358
|
+
+ ", ".join(uncovered) + "."
|
|
359
|
+
)
|
|
360
|
+
return "\n".join(lines) + "\n"
|
|
361
|
+
|
|
362
|
+
|
|
363
|
+
def card_word_count(card_text):
|
|
364
|
+
return len(re.findall(r"[A-Za-z0-9']+", card_text))
|
|
365
|
+
|
|
366
|
+
|
|
367
|
+
def write_card(profile, samples_dir, out_dir, name):
|
|
368
|
+
docs = collect(samples_dir)
|
|
369
|
+
matrix, buckets = coverage_matrix(docs)
|
|
370
|
+
out = Path(out_dir)
|
|
371
|
+
(out / "card").mkdir(parents=True, exist_ok=True)
|
|
372
|
+
# Remove any stale sheets so uncovered dims never keep an old file.
|
|
373
|
+
for stale in (out / "card").glob("*.md"):
|
|
374
|
+
stale.unlink()
|
|
375
|
+
card = build_card(profile, docs, matrix, name)
|
|
376
|
+
(out / "card.md").write_text(card)
|
|
377
|
+
for dim in TAXONOMY:
|
|
378
|
+
if matrix[dim]["covered"]:
|
|
379
|
+
(out / "card" / f"{dim}.md").write_text(build_sheet(dim, buckets[dim], profile))
|
|
380
|
+
return matrix
|
|
381
|
+
|
|
382
|
+
|
|
383
|
+
def write_provenance(profile, samples_dir, out_dir):
|
|
384
|
+
samples = []
|
|
385
|
+
total = 0
|
|
386
|
+
for path in voice_profile.iter_docs(samples_dir):
|
|
387
|
+
text = path.read_text(errors="replace")
|
|
388
|
+
wc = len(voice_profile.words(text))
|
|
389
|
+
total += wc
|
|
390
|
+
samples.append({
|
|
391
|
+
"file": path.name,
|
|
392
|
+
"sha256": sha256_file(path),
|
|
393
|
+
"words": wc,
|
|
394
|
+
})
|
|
395
|
+
meta = profile.get("metadata", {})
|
|
396
|
+
prov = {
|
|
397
|
+
"doc_count": len(samples),
|
|
398
|
+
"total_words": total,
|
|
399
|
+
"samples": samples,
|
|
400
|
+
"genre_note": meta.get("genre_warning", "") or "same-genre samples assumed",
|
|
401
|
+
"low_confidence": bool(meta.get("low_confidence", total < 2000)),
|
|
402
|
+
}
|
|
403
|
+
Path(out_dir, "provenance.json").write_text(
|
|
404
|
+
json.dumps(prov, indent=2, sort_keys=True) + "\n"
|
|
405
|
+
)
|
|
406
|
+
return prov
|
|
407
|
+
|
|
408
|
+
|
|
409
|
+
def profile_mismatch(supplied, recomputed, path=""):
|
|
410
|
+
"""First named field where the supplied profile disagrees with one recomputed
|
|
411
|
+
from --samples, or None. Counts (ints) compare exactly; floats within 1e-6.
|
|
412
|
+
The function-word background is a normalizer (from --background, not the
|
|
413
|
+
sample content), so it is not part of the equality check."""
|
|
414
|
+
here = path or "<root>"
|
|
415
|
+
if isinstance(supplied, bool) or isinstance(recomputed, bool):
|
|
416
|
+
return None if supplied == recomputed else here
|
|
417
|
+
if isinstance(supplied, dict):
|
|
418
|
+
if not isinstance(recomputed, dict):
|
|
419
|
+
return here
|
|
420
|
+
for k in sorted(set(supplied) | set(recomputed)):
|
|
421
|
+
if k == "function_word_background":
|
|
422
|
+
continue
|
|
423
|
+
if k not in supplied or k not in recomputed:
|
|
424
|
+
return f"{path}.{k}".lstrip(".")
|
|
425
|
+
m = profile_mismatch(supplied[k], recomputed[k], f"{path}.{k}".lstrip("."))
|
|
426
|
+
if m:
|
|
427
|
+
return m
|
|
428
|
+
return None
|
|
429
|
+
if isinstance(supplied, list):
|
|
430
|
+
if not isinstance(recomputed, list) or len(supplied) != len(recomputed):
|
|
431
|
+
return here
|
|
432
|
+
for i, (x, y) in enumerate(zip(supplied, recomputed)):
|
|
433
|
+
m = profile_mismatch(x, y, f"{path}[{i}]")
|
|
434
|
+
if m:
|
|
435
|
+
return m
|
|
436
|
+
return None
|
|
437
|
+
if isinstance(supplied, int) and isinstance(recomputed, int):
|
|
438
|
+
return None if supplied == recomputed else here
|
|
439
|
+
if isinstance(supplied, (int, float)) and isinstance(recomputed, (int, float)):
|
|
440
|
+
return None if abs(supplied - recomputed) <= 1e-6 else here
|
|
441
|
+
return None if supplied == recomputed else here
|
|
442
|
+
|
|
443
|
+
|
|
444
|
+
def parse_args(argv):
|
|
445
|
+
parser = argparse.ArgumentParser(description=__doc__)
|
|
446
|
+
parser.add_argument("--profile", required=True)
|
|
447
|
+
parser.add_argument("--samples", required=True)
|
|
448
|
+
parser.add_argument("--out")
|
|
449
|
+
parser.add_argument("--name", default="voice")
|
|
450
|
+
parser.add_argument("--coverage", action="store_true",
|
|
451
|
+
help="print the coverage matrix as JSON and write nothing")
|
|
452
|
+
parser.add_argument("--provenance", action="store_true",
|
|
453
|
+
help="also write <out>/provenance.json")
|
|
454
|
+
return parser.parse_args(argv)
|
|
455
|
+
|
|
456
|
+
|
|
457
|
+
def main(argv):
|
|
458
|
+
args = parse_args(argv)
|
|
459
|
+
profile_path = Path(args.profile)
|
|
460
|
+
samples_dir = Path(args.samples)
|
|
461
|
+
if not profile_path.exists() or not samples_dir.is_dir():
|
|
462
|
+
print("missing profile or samples dir", file=sys.stderr)
|
|
463
|
+
return 2
|
|
464
|
+
if not list(voice_profile.iter_docs(samples_dir)):
|
|
465
|
+
print(f"no sample documents in {samples_dir}: teach reads only .txt and .md "
|
|
466
|
+
f"files (recursively). Rename samples to .txt/.md or point --samples at "
|
|
467
|
+
f"the right directory.", file=sys.stderr)
|
|
468
|
+
return 2
|
|
469
|
+
profile = json.loads(profile_path.read_text())
|
|
470
|
+
|
|
471
|
+
# Consistency gate: the supplied profile must describe these very samples.
|
|
472
|
+
recomputed = voice_profile.build_profile(samples_dir)
|
|
473
|
+
mismatch = profile_mismatch(profile, recomputed)
|
|
474
|
+
if mismatch is not None:
|
|
475
|
+
print(f"profile does not match --samples (recompute differs at '{mismatch}'); "
|
|
476
|
+
f"rebuild the profile from these samples with voice_profile.py",
|
|
477
|
+
file=sys.stderr)
|
|
478
|
+
return 2
|
|
479
|
+
|
|
480
|
+
if args.coverage:
|
|
481
|
+
docs = collect(samples_dir)
|
|
482
|
+
matrix, _ = coverage_matrix(docs)
|
|
483
|
+
print(json.dumps(matrix, indent=2, sort_keys=True))
|
|
484
|
+
return 0
|
|
485
|
+
|
|
486
|
+
if not args.out:
|
|
487
|
+
print("--out is required unless --coverage", file=sys.stderr)
|
|
488
|
+
return 2
|
|
489
|
+
write_card(profile, samples_dir, args.out, args.name)
|
|
490
|
+
if args.provenance:
|
|
491
|
+
write_provenance(profile, samples_dir, args.out)
|
|
492
|
+
return 0
|
|
493
|
+
|
|
494
|
+
|
|
495
|
+
if __name__ == "__main__":
|
|
496
|
+
raise SystemExit(main(sys.argv[1:]))
|
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Build deterministic stylometric voice profiles.
|
|
3
|
+
|
|
4
|
+
The built-in background table is a compact Writeprints-style English baseline:
|
|
5
|
+
common function words seeded from public-domain frequency lists used by classic
|
|
6
|
+
authorship-attribution examples, with broad fallback means/stddevs for words not
|
|
7
|
+
observed in a caller-supplied background corpus. It is meant only as a stable
|
|
8
|
+
normalizer; pass --background with same-genre documents for calibrated work.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
import argparse
|
|
12
|
+
import collections
|
|
13
|
+
import json
|
|
14
|
+
import math
|
|
15
|
+
import re
|
|
16
|
+
import statistics
|
|
17
|
+
import sys
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
|
|
20
|
+
FUNCTION_WORDS = """
|
|
21
|
+
the of and to in a is that it for as with was on be by he i this are or his from at
|
|
22
|
+
which but have an had they you were their one all we can her has there been if more
|
|
23
|
+
when will would who so no she about out up into do any your what than them some could
|
|
24
|
+
these other then its our two may first my now such like over only also after most did
|
|
25
|
+
many before must through back where much should well people down own just because good
|
|
26
|
+
each those how under see made very being make between both even another while last
|
|
27
|
+
might still same never every against since off though yet without within upon among
|
|
28
|
+
until during per either neither nor whether whose whom why again once here there
|
|
29
|
+
therefore however although nevertheless moreover instead indeed perhaps rather thus
|
|
30
|
+
else already almost around across behind beyond near toward towards above below beside
|
|
31
|
+
inside outside along plus minus except despite via versus including regarding concerning
|
|
32
|
+
am were does done doing having let lets cannot dont didn't doesn't isn't aren't wasn't
|
|
33
|
+
weren't haven't hasn't hadn't won't wouldn't shouldn't couldn't mightn't mustn't i'm
|
|
34
|
+
you're he's she's it's we're they're i've you've we've they've i'd you'd he'd she'd we'd
|
|
35
|
+
they'd i'll you'll he'll she'll we'll they'll me him us mine yours ours theirs myself
|
|
36
|
+
yourself himself herself itself ourselves yourselves themselves
|
|
37
|
+
""".split()
|
|
38
|
+
|
|
39
|
+
PUNCT = [",", ".", ";", ":", "?", "!", "-", "(", ")", '"', "'"]
|
|
40
|
+
WORD_RE = re.compile(r"[A-Za-z]+(?:'[A-Za-z]+)?|\d+")
|
|
41
|
+
SENT_RE = re.compile(r"[^.!?]+[.!?]?")
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def iter_docs(root):
|
|
45
|
+
for path in sorted(Path(root).rglob("*")):
|
|
46
|
+
if path.suffix.lower() in {".txt", ".md"} and path.is_file():
|
|
47
|
+
yield path
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def normalize(text):
|
|
51
|
+
return re.sub(r"\s+", " ", text.lower()).strip()
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def words(text):
|
|
55
|
+
return WORD_RE.findall(text.lower())
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def sentences(text):
|
|
59
|
+
out = []
|
|
60
|
+
for part in SENT_RE.findall(text):
|
|
61
|
+
toks = words(part)
|
|
62
|
+
if toks:
|
|
63
|
+
out.append(toks)
|
|
64
|
+
return out
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def char3_counts(text, limit=None):
|
|
68
|
+
norm = normalize(text)
|
|
69
|
+
grams = collections.Counter(norm[i:i + 3] for i in range(max(0, len(norm) - 2)))
|
|
70
|
+
items = sorted(grams.items(), key=lambda kv: (-kv[1], kv[0]))
|
|
71
|
+
if limit:
|
|
72
|
+
items = items[:limit]
|
|
73
|
+
return dict(items)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def function_freq(tokens):
|
|
77
|
+
total = max(1, len(tokens))
|
|
78
|
+
counts = collections.Counter(tokens)
|
|
79
|
+
return {w: counts[w] / total for w in FUNCTION_WORDS}
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def sentence_stats(text):
|
|
83
|
+
lengths = [len(s) for s in sentences(text)]
|
|
84
|
+
if not lengths:
|
|
85
|
+
return {"lengths": [], "median": 0.0, "iqr": 0.0}
|
|
86
|
+
ordered = sorted(lengths)
|
|
87
|
+
mid = statistics.median(ordered)
|
|
88
|
+
q1 = statistics.median(ordered[:len(ordered) // 2] or ordered)
|
|
89
|
+
q3 = statistics.median(ordered[(len(ordered) + 1) // 2:] or ordered)
|
|
90
|
+
return {"lengths": lengths, "median": mid, "iqr": q3 - q1}
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def mtld(tokens, threshold=0.72):
|
|
94
|
+
if len(tokens) < 20:
|
|
95
|
+
return 0.0
|
|
96
|
+
factors = 0.0
|
|
97
|
+
types = set()
|
|
98
|
+
count = 0
|
|
99
|
+
for tok in tokens:
|
|
100
|
+
count += 1
|
|
101
|
+
types.add(tok)
|
|
102
|
+
if len(types) / count <= threshold:
|
|
103
|
+
factors += 1
|
|
104
|
+
types.clear()
|
|
105
|
+
count = 0
|
|
106
|
+
if count:
|
|
107
|
+
ttr = len(types) / count
|
|
108
|
+
factors += (1 - ttr) / (1 - threshold) if threshold < 1 else 0
|
|
109
|
+
return len(tokens) / factors if factors else float(len(tokens))
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def feature_bundle(text):
|
|
113
|
+
toks = words(text)
|
|
114
|
+
total = max(1, len(toks))
|
|
115
|
+
punct_counts = collections.Counter(ch for ch in text if ch in PUNCT)
|
|
116
|
+
contractions = sum(1 for t in toks if "'" in t)
|
|
117
|
+
hist = collections.Counter(min(len(t), 15) for t in toks)
|
|
118
|
+
paragraphs = [p for p in re.split(r"\n\s*\n", text.strip()) if p.strip()]
|
|
119
|
+
return {
|
|
120
|
+
"char3": char3_counts(text, 2000),
|
|
121
|
+
"function_words": function_freq(toks),
|
|
122
|
+
"sentence_lengths": sentence_stats(text),
|
|
123
|
+
"punctuation": {p: punct_counts[p] / total for p in PUNCT},
|
|
124
|
+
"contraction_rate": contractions / total,
|
|
125
|
+
"mtld": mtld(toks),
|
|
126
|
+
"word_length_histogram": {str(i): hist[i] / total for i in range(1, 16)},
|
|
127
|
+
"paragraph_stats": {
|
|
128
|
+
"count": len(paragraphs),
|
|
129
|
+
"mean_words": (sum(len(words(p)) for p in paragraphs) / len(paragraphs)) if paragraphs else 0.0,
|
|
130
|
+
},
|
|
131
|
+
"total_words": len(toks),
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def background_stats(root=None):
|
|
136
|
+
docs = []
|
|
137
|
+
if root:
|
|
138
|
+
docs = [p.read_text(errors="replace") for p in iter_docs(root)]
|
|
139
|
+
if not docs:
|
|
140
|
+
return {w: {"mean": 0.0025 if w not in {"the", "of", "and", "to", "in", "a"} else 0.025,
|
|
141
|
+
"std": 0.006} for w in FUNCTION_WORDS}
|
|
142
|
+
rows = [function_freq(words(text)) for text in docs]
|
|
143
|
+
stats = {}
|
|
144
|
+
for w in FUNCTION_WORDS:
|
|
145
|
+
vals = [r[w] for r in rows]
|
|
146
|
+
stats[w] = {
|
|
147
|
+
"mean": statistics.mean(vals),
|
|
148
|
+
"std": statistics.pstdev(vals) or 0.0001,
|
|
149
|
+
}
|
|
150
|
+
return stats
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def build_profile(samples_dir, background=None):
|
|
154
|
+
paths = list(iter_docs(samples_dir))
|
|
155
|
+
text = "\n\n".join(p.read_text(errors="replace") for p in paths)
|
|
156
|
+
profile = feature_bundle(text)
|
|
157
|
+
profile["function_word_background"] = background_stats(background)
|
|
158
|
+
profile["metadata"] = {
|
|
159
|
+
"doc_count": len(paths),
|
|
160
|
+
"total_words": profile["total_words"],
|
|
161
|
+
"low_confidence": profile["total_words"] < 2000,
|
|
162
|
+
"genre_warning": "profile has fewer than 2000 words" if profile["total_words"] < 2000 else "",
|
|
163
|
+
}
|
|
164
|
+
return profile
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def parse_args(argv):
|
|
168
|
+
parser = argparse.ArgumentParser()
|
|
169
|
+
parser.add_argument("samples_dir")
|
|
170
|
+
parser.add_argument("-o", "--output", required=True)
|
|
171
|
+
parser.add_argument("--background")
|
|
172
|
+
return parser.parse_args(argv)
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def main(argv):
|
|
176
|
+
args = parse_args(argv)
|
|
177
|
+
root = Path(args.samples_dir)
|
|
178
|
+
if not root.is_dir():
|
|
179
|
+
print(f"missing samples dir: {root}", file=sys.stderr)
|
|
180
|
+
return 2
|
|
181
|
+
if not list(iter_docs(root)):
|
|
182
|
+
print(f"no sample documents in {root}: only .txt and .md files are read "
|
|
183
|
+
f"(recursively). Rename samples to .txt/.md or point at the right "
|
|
184
|
+
f"directory.", file=sys.stderr)
|
|
185
|
+
return 2
|
|
186
|
+
profile = build_profile(root, args.background)
|
|
187
|
+
if profile["metadata"]["low_confidence"]:
|
|
188
|
+
print(profile["metadata"]["genre_warning"], file=sys.stderr)
|
|
189
|
+
Path(args.output).write_text(json.dumps(profile, indent=2, sort_keys=True) + "\n")
|
|
190
|
+
return 0
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
if __name__ == "__main__":
|
|
194
|
+
raise SystemExit(main(sys.argv[1:]))
|