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,390 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Scan prose for discourse-level silhouette AI-writing patterns.
|
|
3
|
+
|
|
4
|
+
This is the macro-structure scanner one level above scripts/structure_scan.py.
|
|
5
|
+
structure_scan measures SURFACE structure (sentence cadence, opener words,
|
|
6
|
+
listicle formatting). silhouette_scan measures IDEA ARRANGEMENT: does the
|
|
7
|
+
document follow a template-shaped outline, preview-then-fulfill its own points,
|
|
8
|
+
open body paragraphs with rotating discourse cues, and close with a recap loop?
|
|
9
|
+
|
|
10
|
+
Five validated one-sided tells (all AI-high; humans cluster at zero):
|
|
11
|
+
|
|
12
|
+
scaffold_opener_share body paragraphs opening with a discourse-cue class
|
|
13
|
+
callback_content early vocab absent mid-document, returning at the end
|
|
14
|
+
(the recap loop; strongest single tell)
|
|
15
|
+
role_entropy_bits count of distinct cue-opener classes (a template rotates
|
|
16
|
+
"However / In addition / Ultimately" openers)
|
|
17
|
+
preview_fulfillment intro content words reappearing as body-paragraph heads
|
|
18
|
+
heading_preview heading head-nouns previewed in the intro (outline follow)
|
|
19
|
+
|
|
20
|
+
Composite:
|
|
21
|
+
|
|
22
|
+
silhouette_penalty = sum_i weight_i * relu((m_i - median_i) / scale_i)
|
|
23
|
+
|
|
24
|
+
scored against a committed HUMAN reference distribution
|
|
25
|
+
(evals/fixtures/silhouette/human_reference.json), negative side clipped. The
|
|
26
|
+
document flags when silhouette_penalty >= 1.0.
|
|
27
|
+
|
|
28
|
+
Scale note (deviation from a naive sample-IQR denominator, stated on purpose):
|
|
29
|
+
these five metrics are degenerate at zero across the human corpus -- the sample
|
|
30
|
+
IQR is 0.0 for every one, so the literal (m - median)/IQR with a 0.05 floor
|
|
31
|
+
reduces to m/0.05 and produces human false positives (a human doc with one
|
|
32
|
+
genuine callback at 0.17 lands at penalty > 1.0). The reference therefore scales
|
|
33
|
+
each metric by its human UPPER FENCE -- the validated activation threshold past
|
|
34
|
+
which a human essentially never scores. The scorer uses
|
|
35
|
+
denom = max(sample_iqr, fence) so a future per-author profile with a real,
|
|
36
|
+
non-degenerate IQR widens the scale naturally. This keeps the exact weighted
|
|
37
|
+
relu-of-z shape, reproduces the research's struct01/03/09/11 signature, and holds
|
|
38
|
+
0 / 8 human false positives on the validation corpus.
|
|
39
|
+
|
|
40
|
+
Voice fingerprint (not implemented here): the same per-metric median/scale
|
|
41
|
+
machinery generalizes to a PER-AUTHOR reference stored in a voice profile, so
|
|
42
|
+
mimic mode can penalize |draft - author-median| instead of generic-human. That
|
|
43
|
+
per-author integration lands with the teach/mimic branch (WP10b); this scanner
|
|
44
|
+
ships the generic-human reference only.
|
|
45
|
+
|
|
46
|
+
stdlib only. JSON to stdout. Exit 1 on flag, 0 clean, 2 on a missing file.
|
|
47
|
+
"""
|
|
48
|
+
|
|
49
|
+
from __future__ import annotations
|
|
50
|
+
|
|
51
|
+
import argparse
|
|
52
|
+
import json
|
|
53
|
+
import math
|
|
54
|
+
import re
|
|
55
|
+
import sys
|
|
56
|
+
from collections import Counter
|
|
57
|
+
from pathlib import Path
|
|
58
|
+
|
|
59
|
+
HERE = Path(__file__).resolve().parent
|
|
60
|
+
sys.path.insert(0, str(HERE))
|
|
61
|
+
|
|
62
|
+
from structure_scan import STOPWORDS as _STRUCTURE_STOPWORDS # noqa: E402
|
|
63
|
+
from _lang import ( # noqa: E402
|
|
64
|
+
is_probably_english,
|
|
65
|
+
paragraphs as _prose_paragraphs,
|
|
66
|
+
words,
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
REFERENCE_PATH = (
|
|
71
|
+
Path(__file__).resolve().parent.parent
|
|
72
|
+
/ "evals" / "fixtures" / "silhouette" / "human_reference.json"
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
# Flag the document when the composite reaches this penalty (research-validated).
|
|
76
|
+
PENALTY_THRESHOLD = 1.0
|
|
77
|
+
|
|
78
|
+
# Minimum prose paragraphs before the silhouette metrics are meaningful.
|
|
79
|
+
MIN_PARAGRAPHS = 3
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
# silhouette_scan's stopword list is a pure superset of structure_scan's: same
|
|
83
|
+
# core function words plus pronouns/quantifiers that matter for content-bigram
|
|
84
|
+
# and callback-content comparisons but that structure_scan doesn't need.
|
|
85
|
+
SILHOUETTE_STOPWORDS = _STRUCTURE_STOPWORDS | frozenset({
|
|
86
|
+
"our", "your", "their", "my", "me", "us", "them", "his", "her", "what",
|
|
87
|
+
"which", "who", "when", "where", "how", "why", "there", "here", "about",
|
|
88
|
+
"just", "more", "most", "some", "all", "also", "out", "up", "one", "two",
|
|
89
|
+
"get", "got", "like", "much", "many", "very", "every", "only",
|
|
90
|
+
})
|
|
91
|
+
|
|
92
|
+
# Discourse cue classes for paragraph-opener roles. Copied verbatim from the
|
|
93
|
+
# validated research prototype (scratchpad/research-silhouette/silhouette_probe.py).
|
|
94
|
+
ROLE_CUES = {
|
|
95
|
+
"contrast": r"^(on the other hand|on one hand|however|conversely|in contrast|"
|
|
96
|
+
r"yet|but |perhaps most|that said|still,)",
|
|
97
|
+
"addition": r"^(moreover|furthermore|additionally|in addition|also,|"
|
|
98
|
+
r"another|second|third|next,|finally,|besides)",
|
|
99
|
+
"conclusion": r"^(in conclusion|ultimately|overall|in the end|to sum|"
|
|
100
|
+
r"in summary|as we|remember|the future)",
|
|
101
|
+
"enumeration": r"^(first,|firstly|1\.|step \d|there are)",
|
|
102
|
+
"cause": r"^(therefore|thus|consequently|as a result|because of)",
|
|
103
|
+
}
|
|
104
|
+
ROLE_RE = {k: re.compile(v, re.I) for k, v in ROLE_CUES.items()}
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def content(text: str) -> list[str]:
|
|
108
|
+
return [w for w in words(text) if len(w) > 3 and w not in SILHOUETTE_STOPWORDS]
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def paragraphs(text: str) -> list[str]:
|
|
112
|
+
return _prose_paragraphs(text, strip_bold=True)
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
# ---------------- METRICS (verbatim from the validated prototype) ----------------
|
|
116
|
+
|
|
117
|
+
def m_scaffold_opener_share(paras: list[str]):
|
|
118
|
+
"""Share of body paragraphs opening with a discourse-connective/scaffold cue."""
|
|
119
|
+
body = paras[1:] if len(paras) > 1 else paras
|
|
120
|
+
if not body:
|
|
121
|
+
return 0.0
|
|
122
|
+
hits = 0
|
|
123
|
+
for p in body:
|
|
124
|
+
for rx in ROLE_RE.values():
|
|
125
|
+
if rx.search(p):
|
|
126
|
+
hits += 1
|
|
127
|
+
break
|
|
128
|
+
return round(hits / len(body), 3)
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def m_role_entropy(paras: list[str]):
|
|
132
|
+
"""Shannon entropy (bits) over opener role classes incl. 'topic' (none).
|
|
133
|
+
A human never opens paragraphs with cue classes -> entropy 0; a template
|
|
134
|
+
rotates several distinct cue classes -> positive entropy."""
|
|
135
|
+
if len(paras) < 3:
|
|
136
|
+
return None
|
|
137
|
+
roles = []
|
|
138
|
+
for p in paras:
|
|
139
|
+
r = "topic"
|
|
140
|
+
for name, rx in ROLE_RE.items():
|
|
141
|
+
if rx.search(p):
|
|
142
|
+
r = name
|
|
143
|
+
break
|
|
144
|
+
roles.append(r)
|
|
145
|
+
counts = Counter(roles)
|
|
146
|
+
n = len(roles)
|
|
147
|
+
ent = -sum((c / n) * math.log2(c / n) for c in counts.values())
|
|
148
|
+
return round(ent, 3)
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def m_preview_fulfillment(paras: list[str]):
|
|
152
|
+
"""Share of body paragraphs whose opening content word appears in the
|
|
153
|
+
intro paragraph (outline-following / preview-then-fulfill tell)."""
|
|
154
|
+
if len(paras) < 4:
|
|
155
|
+
return None
|
|
156
|
+
intro = set(content(paras[0]))
|
|
157
|
+
if not intro:
|
|
158
|
+
return 0.0
|
|
159
|
+
body = paras[1:-1] if len(paras) > 2 else paras[1:]
|
|
160
|
+
hits = tot = 0
|
|
161
|
+
for p in body:
|
|
162
|
+
cs = content(p)
|
|
163
|
+
if not cs:
|
|
164
|
+
continue
|
|
165
|
+
tot += 1
|
|
166
|
+
if cs[0] in intro:
|
|
167
|
+
hits += 1
|
|
168
|
+
return round(hits / tot, 3) if tot else 0.0
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def m_callback_content(paras: list[str]):
|
|
172
|
+
"""Content words introduced in the first third, ABSENT from the middle,
|
|
173
|
+
reappearing in the last third: a recap loop, not a sustained topic."""
|
|
174
|
+
n = len(paras)
|
|
175
|
+
if n < 5:
|
|
176
|
+
return None
|
|
177
|
+
third = max(1, n // 3)
|
|
178
|
+
early = set().union(*[set(content(paras[i])) for i in range(third)])
|
|
179
|
+
mid = (set().union(*[set(content(paras[i])) for i in range(third, n - third)])
|
|
180
|
+
if n - 2 * third > 0 else set())
|
|
181
|
+
late = set().union(*[set(content(paras[i])) for i in range(n - third, n)])
|
|
182
|
+
cb = (early & late) - mid
|
|
183
|
+
return round(len(cb) / n, 3)
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def m_heading_preview(text: str):
|
|
187
|
+
"""Share of ## heading head-nouns whose key word also appears in the intro
|
|
188
|
+
paragraph = outline preview-then-fulfill. Measures outline-following, not
|
|
189
|
+
heading presence, so it is retained under --genre docs."""
|
|
190
|
+
heads = re.findall(r"(?m)^\s{0,3}#{2,3}\s+(.*)$", text)
|
|
191
|
+
if len(heads) < 3:
|
|
192
|
+
return None
|
|
193
|
+
paras = paragraphs(text)
|
|
194
|
+
intro = set(content(paras[0])) if paras else set()
|
|
195
|
+
if not intro:
|
|
196
|
+
return 0.0
|
|
197
|
+
hit = 0
|
|
198
|
+
for h in heads:
|
|
199
|
+
hc = set(content(h))
|
|
200
|
+
if hc & intro:
|
|
201
|
+
hit += 1
|
|
202
|
+
return round(hit / len(heads), 3)
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
# Metric registry: name -> (paragraph-based? / text-based?, function).
|
|
206
|
+
PARA_METRICS = {
|
|
207
|
+
"scaffold_opener_share": m_scaffold_opener_share,
|
|
208
|
+
"role_entropy_bits": m_role_entropy,
|
|
209
|
+
"preview_fulfillment": m_preview_fulfillment,
|
|
210
|
+
"callback_content": m_callback_content,
|
|
211
|
+
}
|
|
212
|
+
TEXT_METRICS = {
|
|
213
|
+
"heading_preview": m_heading_preview,
|
|
214
|
+
}
|
|
215
|
+
METRIC_ORDER = [
|
|
216
|
+
"scaffold_opener_share",
|
|
217
|
+
"role_entropy_bits",
|
|
218
|
+
"heading_preview",
|
|
219
|
+
"preview_fulfillment",
|
|
220
|
+
"callback_content",
|
|
221
|
+
]
|
|
222
|
+
|
|
223
|
+
SUGGESTIONS = {
|
|
224
|
+
"scaffold_opener_share":
|
|
225
|
+
"Open body paragraphs on their own specific claim, not a discourse cue.",
|
|
226
|
+
"role_entropy_bits":
|
|
227
|
+
"Stop rotating 'However / In addition / Ultimately' scaffold openers.",
|
|
228
|
+
"heading_preview":
|
|
229
|
+
"Headings restate the intro's outline; let sections carry new ground.",
|
|
230
|
+
"preview_fulfillment":
|
|
231
|
+
"The body just fulfills an outline previewed in the intro; drop the preview.",
|
|
232
|
+
"callback_content":
|
|
233
|
+
"The ending loops back to opening vocabulary; end on a concrete final point.",
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
def compute_metrics(text: str, paras: list[str]) -> dict:
|
|
238
|
+
row = {}
|
|
239
|
+
for name in METRIC_ORDER:
|
|
240
|
+
if name in PARA_METRICS:
|
|
241
|
+
row[name] = PARA_METRICS[name](paras)
|
|
242
|
+
else:
|
|
243
|
+
row[name] = TEXT_METRICS[name](text)
|
|
244
|
+
return row
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
def load_reference(path: Path) -> dict:
|
|
248
|
+
data = json.loads(path.read_text())
|
|
249
|
+
return data["metrics"]
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
def relu(x: float) -> float:
|
|
253
|
+
return x if x > 0 else 0.0
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
def flag(metric, value, threshold, detail, suggestion) -> dict:
|
|
257
|
+
return {
|
|
258
|
+
"metric": metric,
|
|
259
|
+
"value": value,
|
|
260
|
+
"threshold": threshold,
|
|
261
|
+
"severity": "soft",
|
|
262
|
+
"detail": detail,
|
|
263
|
+
"suggestion": suggestion,
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
# Metrics a given --genre suppresses outright. See the docstring in scan()
|
|
268
|
+
# for why only callback_content is suppressed under --genre docs.
|
|
269
|
+
GENRE_SUPPRESSIONS = {
|
|
270
|
+
"docs": {"callback_content"},
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
def scan(text: str, reference: dict, genre: str = "prose") -> dict:
|
|
275
|
+
# Genre is a passthrough echoed for parity with structure_scan.
|
|
276
|
+
# --genre docs suppresses ONLY callback_content: reference docs, specs, and
|
|
277
|
+
# doctrine conventionally reprise opening themes at the end, which is not
|
|
278
|
+
# the essay recap coda the metric exists to catch (SIL rows pin both
|
|
279
|
+
# directions). heading_preview is deliberately retained under docs because
|
|
280
|
+
# it measures outline-following (a tell even in reference docs), and the
|
|
281
|
+
# metric already clears legitimate academic roadmaps (struct17) on its own.
|
|
282
|
+
# --genre social documents that loose social copy rarely has the
|
|
283
|
+
# >=3-paragraph structure these metrics need.
|
|
284
|
+
paras = paragraphs(text)
|
|
285
|
+
base = {
|
|
286
|
+
"genre": genre,
|
|
287
|
+
"prose_paragraphs": len(paras),
|
|
288
|
+
}
|
|
289
|
+
if len(paras) < MIN_PARAGRAPHS:
|
|
290
|
+
base.update({
|
|
291
|
+
"flags": [],
|
|
292
|
+
"flagged": {},
|
|
293
|
+
"metrics": None,
|
|
294
|
+
"penalty": None,
|
|
295
|
+
"note": f"fewer than {MIN_PARAGRAPHS} prose paragraphs; "
|
|
296
|
+
"silhouette metrics not scored",
|
|
297
|
+
})
|
|
298
|
+
return base
|
|
299
|
+
|
|
300
|
+
metrics = compute_metrics(text, paras)
|
|
301
|
+
contributions = {}
|
|
302
|
+
penalty = 0.0
|
|
303
|
+
flags = []
|
|
304
|
+
for name in METRIC_ORDER:
|
|
305
|
+
if name in GENRE_SUPPRESSIONS.get(genre, set()):
|
|
306
|
+
contributions[name] = 0.0
|
|
307
|
+
continue
|
|
308
|
+
ref = reference[name]
|
|
309
|
+
value = metrics[name]
|
|
310
|
+
if not isinstance(value, (int, float)):
|
|
311
|
+
contributions[name] = None
|
|
312
|
+
continue
|
|
313
|
+
median = ref["median"]
|
|
314
|
+
scale = max(ref["iqr"], ref["fence"])
|
|
315
|
+
weight = ref["weight"]
|
|
316
|
+
contribution = round(weight * relu((value - median) / scale), 3)
|
|
317
|
+
contributions[name] = contribution
|
|
318
|
+
penalty += contribution
|
|
319
|
+
# A metric individually clears the human fence when value >= fence,
|
|
320
|
+
# i.e. its contribution reaches its full weight.
|
|
321
|
+
if value >= ref["fence"]:
|
|
322
|
+
flags.append(flag(
|
|
323
|
+
name,
|
|
324
|
+
value,
|
|
325
|
+
f"human fence {ref['fence']} (weight {weight})",
|
|
326
|
+
f"{name} at {value} clears the human upper fence "
|
|
327
|
+
f"{ref['fence']}.",
|
|
328
|
+
SUGGESTIONS[name],
|
|
329
|
+
))
|
|
330
|
+
penalty = round(penalty, 3)
|
|
331
|
+
|
|
332
|
+
if penalty >= PENALTY_THRESHOLD:
|
|
333
|
+
flags.insert(0, flag(
|
|
334
|
+
"silhouette_penalty",
|
|
335
|
+
penalty,
|
|
336
|
+
f">= {PENALTY_THRESHOLD}",
|
|
337
|
+
"The document's idea arrangement matches a templated AI silhouette "
|
|
338
|
+
"(preview-then-fulfill, rotating scaffold openers, recap loop).",
|
|
339
|
+
"Rearrange around the actual argument instead of a symmetric outline; "
|
|
340
|
+
"cut previews and the closing recap.",
|
|
341
|
+
))
|
|
342
|
+
|
|
343
|
+
base.update({
|
|
344
|
+
"flags": flags,
|
|
345
|
+
"flagged": {f["metric"]: True for f in flags},
|
|
346
|
+
"metrics": metrics,
|
|
347
|
+
"contributions": contributions,
|
|
348
|
+
"penalty": penalty,
|
|
349
|
+
})
|
|
350
|
+
return base
|
|
351
|
+
|
|
352
|
+
|
|
353
|
+
def parse_args(argv: list[str]) -> argparse.Namespace:
|
|
354
|
+
parser = argparse.ArgumentParser(description=__doc__)
|
|
355
|
+
parser.add_argument("path", nargs="?")
|
|
356
|
+
parser.add_argument("--genre", choices=["prose", "docs", "social"], default="prose")
|
|
357
|
+
return parser.parse_args(argv)
|
|
358
|
+
|
|
359
|
+
|
|
360
|
+
def main(argv: list[str]) -> int:
|
|
361
|
+
args = parse_args(argv)
|
|
362
|
+
if not REFERENCE_PATH.exists():
|
|
363
|
+
print(f"Missing reference: {REFERENCE_PATH}", file=sys.stderr)
|
|
364
|
+
return 2
|
|
365
|
+
reference = load_reference(REFERENCE_PATH)
|
|
366
|
+
|
|
367
|
+
if args.path:
|
|
368
|
+
path = Path(args.path)
|
|
369
|
+
if not path.exists():
|
|
370
|
+
print(f"Missing file: {path}", file=sys.stderr)
|
|
371
|
+
return 2
|
|
372
|
+
text = path.read_text(errors="replace")
|
|
373
|
+
else:
|
|
374
|
+
text = sys.stdin.buffer.read().decode("utf-8", errors="replace")
|
|
375
|
+
|
|
376
|
+
result = scan(text, reference, args.genre)
|
|
377
|
+
|
|
378
|
+
if not result.get("flags") and not is_probably_english(text):
|
|
379
|
+
print(json.dumps({"non_english": True, "flags": [], "penalty": None}, indent=2))
|
|
380
|
+
print("note: input appears non-English; scanner declined (English-only).", file=sys.stderr)
|
|
381
|
+
return 0
|
|
382
|
+
|
|
383
|
+
print(json.dumps(result, indent=2))
|
|
384
|
+
is_flagged = bool(result.get("penalty") is not None
|
|
385
|
+
and result["penalty"] >= PENALTY_THRESHOLD)
|
|
386
|
+
return 1 if is_flagged else 0
|
|
387
|
+
|
|
388
|
+
|
|
389
|
+
if __name__ == "__main__":
|
|
390
|
+
raise SystemExit(main(sys.argv[1:]))
|