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,322 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Scan prose for macro-structure AI-writing patterns."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import argparse
|
|
7
|
+
import json
|
|
8
|
+
import math
|
|
9
|
+
import re
|
|
10
|
+
import sys
|
|
11
|
+
from collections import Counter
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
|
|
14
|
+
HERE = Path(__file__).resolve().parent
|
|
15
|
+
sys.path.insert(0, str(HERE))
|
|
16
|
+
|
|
17
|
+
from _lang import ( # noqa: E402
|
|
18
|
+
ENGLISH_FUNCTION_WORDS,
|
|
19
|
+
english_function_share,
|
|
20
|
+
is_probably_english,
|
|
21
|
+
paragraphs as _prose_paragraphs,
|
|
22
|
+
words,
|
|
23
|
+
)
|
|
24
|
+
from readability_metrics import split_sentences # noqa: E402
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
STOPWORDS = {
|
|
28
|
+
"the", "a", "an", "and", "or", "but", "in", "on", "at", "to", "for", "of",
|
|
29
|
+
"with", "by", "from", "is", "are", "was", "were", "be", "been", "being",
|
|
30
|
+
"have", "has", "had", "do", "does", "did", "will", "would", "could",
|
|
31
|
+
"should", "may", "might", "must", "can", "it", "its", "this", "that",
|
|
32
|
+
"these", "those", "we", "you", "they", "i", "he", "she", "as", "than",
|
|
33
|
+
"if", "then", "so", "not", "no", "yes", "into", "over", "under",
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
CONNECTIVE_OPENERS = re.compile(
|
|
38
|
+
r"^(however|moreover|furthermore|additionally|in addition|overall|"
|
|
39
|
+
r"consequently|nevertheless)\b",
|
|
40
|
+
re.I,
|
|
41
|
+
)
|
|
42
|
+
# Paragraph-initial "Every <noun> <verb-phrase>" template opener ("Every
|
|
43
|
+
# transformation is scored ...", "Every rewrite passes ..."). As a RHYTHM tell
|
|
44
|
+
# this is about REPETITION, so the metric below fires only at 2+ such paragraph
|
|
45
|
+
# openers; a lone "Every child deserves a good school." stays clean. The verb is
|
|
46
|
+
# a copula/auxiliary or a present/past inflection (-s / -ed) so the second word
|
|
47
|
+
# reads as a predicate, not another noun.
|
|
48
|
+
EVERY_OPENER_RE = re.compile(
|
|
49
|
+
r"^every\s+[a-z][\w'-]*\s+(?:is|are|was|were|be|been|being|has|have|had|"
|
|
50
|
+
r"do|does|did|can|will|shall|must|should|would|may|might|"
|
|
51
|
+
r"[a-z]+(?:s|ed))\b",
|
|
52
|
+
re.I,
|
|
53
|
+
)
|
|
54
|
+
SIGNPOST_RE = re.compile(
|
|
55
|
+
r"\b(first,|next,|having (?:covered|established)|in (?:this|the following|"
|
|
56
|
+
r"the next) section|as (?:mentioned|noted) (?:above|earlier)|let us turn|"
|
|
57
|
+
r"let's turn)\b",
|
|
58
|
+
re.I,
|
|
59
|
+
)
|
|
60
|
+
CLOSER_RE = re.compile(
|
|
61
|
+
r",\s+(ensuring|highlighting|reflecting|allowing|enabling|underscoring|"
|
|
62
|
+
r"showcasing|emphasizing|fostering|driving|paving|reinforcing|solidifying|"
|
|
63
|
+
r"demonstrating|contributing to)\b[^.!?]*[.!?\"]?$",
|
|
64
|
+
re.I,
|
|
65
|
+
)
|
|
66
|
+
CODA_START_RE = re.compile(
|
|
67
|
+
r"^(ultimately,|in the end,|in conclusion|as we've seen|only time will tell|"
|
|
68
|
+
r"remember,|the future\b)",
|
|
69
|
+
re.I,
|
|
70
|
+
)
|
|
71
|
+
BOLD_COLON_RE = re.compile(r"^\s*[-*+]?\s*\*\*[^*]{1,40}\*\*\s*:", re.M)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def prose_paragraphs(text: str) -> list[str]:
|
|
75
|
+
return _prose_paragraphs(text, blank_blockquotes=True)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def cv(values: list[int]) -> float:
|
|
79
|
+
if not values:
|
|
80
|
+
return 0.0
|
|
81
|
+
mean = sum(values) / len(values)
|
|
82
|
+
if mean == 0:
|
|
83
|
+
return 0.0
|
|
84
|
+
return math.sqrt(sum((v - mean) ** 2 for v in values) / len(values)) / mean
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def content_bigrams(text: str) -> set[tuple[str, str]]:
|
|
88
|
+
toks = [w for w in words(text) if len(w) > 3 and w not in STOPWORDS]
|
|
89
|
+
return set(zip(toks, toks[1:]))
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def triad_count(text: str) -> int:
|
|
93
|
+
return len(re.findall(r"\b[A-Za-z][A-Za-z-]+,\s+[A-Za-z][A-Za-z-]+,\s+and\s+[A-Za-z][A-Za-z-]+\b", text))
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def flag(metric: str, value: float | int, threshold: str, detail: str, suggestion: str) -> dict:
|
|
97
|
+
return {
|
|
98
|
+
"metric": metric,
|
|
99
|
+
"value": value,
|
|
100
|
+
"threshold": threshold,
|
|
101
|
+
"severity": "soft",
|
|
102
|
+
"detail": detail,
|
|
103
|
+
"suggestion": suggestion,
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
# Metrics a given --genre suppresses outright (the genre's normal shape trips
|
|
108
|
+
# the metric without being AI-slop). Table form so each suppression is a single
|
|
109
|
+
# lookup instead of an inline `genre != "..."` conditional per metric.
|
|
110
|
+
GENRE_SUPPRESSIONS = {
|
|
111
|
+
"docs": {"bold_colon_listicle"},
|
|
112
|
+
"social": {"one_line_staccato"},
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def scan(text: str, genre: str = "prose") -> dict:
|
|
117
|
+
paragraphs = prose_paragraphs(text)
|
|
118
|
+
prose_text = "\n\n".join(paragraphs)
|
|
119
|
+
sentences = split_sentences(prose_text)
|
|
120
|
+
sentence_lengths = [len(words(s)) for s in sentences]
|
|
121
|
+
prose_words = words(prose_text)
|
|
122
|
+
para_lengths = [len(words(p)) for p in paragraphs]
|
|
123
|
+
metrics = {
|
|
124
|
+
"sentence_burstiness": round(cv(sentence_lengths), 3),
|
|
125
|
+
"summary_sandwich": 0.0,
|
|
126
|
+
"paragraph_cv": round(cv(para_lengths), 3),
|
|
127
|
+
"sentence_mean_len": round(sum(sentence_lengths) / len(sentence_lengths), 1) if sentence_lengths else 0,
|
|
128
|
+
"triad_density": round((triad_count(prose_text) / len(prose_words) * 1000), 3) if prose_words else 0,
|
|
129
|
+
"em_dash_per_1k": round((text.count("—") / len(prose_words) * 1000), 3) if prose_words else 0,
|
|
130
|
+
"bold_colon_listicle_count": len(BOLD_COLON_RE.findall(text)),
|
|
131
|
+
"one_line_staccato_share": 0.0,
|
|
132
|
+
"connective_paragraph_openers": 0,
|
|
133
|
+
"every_template_openers": 0,
|
|
134
|
+
"signpost_density": 0.0,
|
|
135
|
+
"opener_unique_ratio": 0.0,
|
|
136
|
+
"top_opener_share": 0.0,
|
|
137
|
+
"max_consecutive_opener": 0,
|
|
138
|
+
"participial_closer_share": 0.0,
|
|
139
|
+
"conclusion_coda": False,
|
|
140
|
+
}
|
|
141
|
+
flags = []
|
|
142
|
+
|
|
143
|
+
if len(paragraphs) >= 2:
|
|
144
|
+
first = content_bigrams(paragraphs[0])
|
|
145
|
+
last = content_bigrams(paragraphs[-1])
|
|
146
|
+
union = first | last
|
|
147
|
+
metrics["summary_sandwich"] = round(len(first & last) / len(union), 3) if union else 0.0
|
|
148
|
+
|
|
149
|
+
if len(sentences) >= 8 and metrics["sentence_burstiness"] < 0.55:
|
|
150
|
+
flags.append(flag(
|
|
151
|
+
"sentence_burstiness",
|
|
152
|
+
metrics["sentence_burstiness"],
|
|
153
|
+
"< 0.55 over at least 8 prose sentences",
|
|
154
|
+
"Sentence lengths are unusually uniform for running prose.",
|
|
155
|
+
"Vary sentence length and cadence; if this is formal reference prose, review before treating it as blocking.",
|
|
156
|
+
))
|
|
157
|
+
|
|
158
|
+
if len(paragraphs) >= 3:
|
|
159
|
+
coda = bool(CODA_START_RE.search(paragraphs[-1]))
|
|
160
|
+
if not coda:
|
|
161
|
+
coda = len(content_bigrams(paragraphs[0]) & content_bigrams(paragraphs[-1])) >= 2
|
|
162
|
+
metrics["conclusion_coda"] = coda
|
|
163
|
+
if coda:
|
|
164
|
+
flags.append(flag(
|
|
165
|
+
"conclusion_coda",
|
|
166
|
+
1,
|
|
167
|
+
"last paragraph starts with a stock coda or repeats 2+ first-paragraph content bigrams",
|
|
168
|
+
"The ending reads like a recap/moral coda instead of a concrete final point.",
|
|
169
|
+
"Cut the wrap-up or end on a specific fact; if this is an abstract or executive summary, judge the genre before changing it.",
|
|
170
|
+
))
|
|
171
|
+
|
|
172
|
+
if (metrics["bold_colon_listicle_count"] >= 3
|
|
173
|
+
and "bold_colon_listicle" not in GENRE_SUPPRESSIONS.get(genre, set())):
|
|
174
|
+
flags.append(flag(
|
|
175
|
+
"bold_colon_listicle",
|
|
176
|
+
metrics["bold_colon_listicle_count"],
|
|
177
|
+
">= 3 bold-label colon lines",
|
|
178
|
+
"The raw Markdown has repeated bold-label listicle formatting.",
|
|
179
|
+
"Convert to prose or plain bullets; if this is a reference doc, rerun with --genre docs.",
|
|
180
|
+
))
|
|
181
|
+
|
|
182
|
+
if paragraphs:
|
|
183
|
+
one_line = 0
|
|
184
|
+
for p in paragraphs:
|
|
185
|
+
ps = split_sentences(p)
|
|
186
|
+
if len(ps) == 1 and len(words(ps[0])) < 12:
|
|
187
|
+
one_line += 1
|
|
188
|
+
metrics["one_line_staccato_share"] = round(one_line / len(paragraphs), 3)
|
|
189
|
+
if (len(paragraphs) >= 6 and metrics["one_line_staccato_share"] > 0.6
|
|
190
|
+
and "one_line_staccato" not in GENRE_SUPPRESSIONS.get(genre, set())):
|
|
191
|
+
flags.append(flag(
|
|
192
|
+
"one_line_staccato",
|
|
193
|
+
metrics["one_line_staccato_share"],
|
|
194
|
+
"> 0.60 over at least 6 paragraphs",
|
|
195
|
+
"Most paragraphs are short single-sentence beats.",
|
|
196
|
+
"Merge related beats and vary paragraph length; if this is social copy, rerun with --genre social.",
|
|
197
|
+
))
|
|
198
|
+
|
|
199
|
+
connective = sum(1 for p in paragraphs if CONNECTIVE_OPENERS.search(p))
|
|
200
|
+
metrics["connective_paragraph_openers"] = connective
|
|
201
|
+
if connective >= 3 or (len(paragraphs) >= 8 and connective / len(paragraphs) > 0.4):
|
|
202
|
+
flags.append(flag(
|
|
203
|
+
"connective_paragraph_openers",
|
|
204
|
+
connective,
|
|
205
|
+
">= 3 paragraphs or > 40% of 8+ paragraphs",
|
|
206
|
+
"Paragraphs repeatedly open with formal transition words.",
|
|
207
|
+
"Replace scaffold openers with specific topic sentences; academic prose may justify some connectors.",
|
|
208
|
+
))
|
|
209
|
+
|
|
210
|
+
every_openers = sum(1 for p in paragraphs if EVERY_OPENER_RE.search(p))
|
|
211
|
+
metrics["every_template_openers"] = every_openers
|
|
212
|
+
if every_openers >= 2:
|
|
213
|
+
flags.append(flag(
|
|
214
|
+
"every_template_openers",
|
|
215
|
+
every_openers,
|
|
216
|
+
">= 2 paragraphs opening 'Every <noun> <verb>'",
|
|
217
|
+
"Paragraphs repeatedly open on the 'Every ___ is/does ...' template.",
|
|
218
|
+
"Vary the paragraph openings; a repeated Every-template is a machine rhythm tell even when each sentence is fine on its own.",
|
|
219
|
+
))
|
|
220
|
+
|
|
221
|
+
if prose_words:
|
|
222
|
+
signposts = len(SIGNPOST_RE.findall(prose_text))
|
|
223
|
+
metrics["signpost_density"] = round(signposts / len(prose_words) * 100, 3)
|
|
224
|
+
if len(prose_words) >= 150 and metrics["signpost_density"] > 0.6:
|
|
225
|
+
flags.append(flag(
|
|
226
|
+
"signpost_density",
|
|
227
|
+
metrics["signpost_density"],
|
|
228
|
+
"> 0.6 per 100 prose words, minimum 150 words",
|
|
229
|
+
"The text over-explains its own structure.",
|
|
230
|
+
"Remove roadmap language unless the genre is a textbook, legal brief, or long guide.",
|
|
231
|
+
))
|
|
232
|
+
|
|
233
|
+
if len(sentences) >= 5:
|
|
234
|
+
openers = []
|
|
235
|
+
for s in sentences:
|
|
236
|
+
ws = words(s)
|
|
237
|
+
if not ws:
|
|
238
|
+
continue
|
|
239
|
+
openers.append(ws[0])
|
|
240
|
+
enumeration = {"the", "a", "an", "section", "chapter", "figure", "table",
|
|
241
|
+
"step", "part", "appendix"}
|
|
242
|
+
counted = [o for o in openers if o not in enumeration]
|
|
243
|
+
top_count = 0
|
|
244
|
+
if counted:
|
|
245
|
+
counts = Counter(counted)
|
|
246
|
+
top_count = max(counts.values())
|
|
247
|
+
metrics["opener_unique_ratio"] = round(len(counts) / len(counted), 3)
|
|
248
|
+
metrics["top_opener_share"] = round(top_count / len(counted), 3)
|
|
249
|
+
run = max_run = 0
|
|
250
|
+
prev = None
|
|
251
|
+
for opener in counted:
|
|
252
|
+
run = run + 1 if opener == prev else 1
|
|
253
|
+
prev = opener
|
|
254
|
+
max_run = max(max_run, run)
|
|
255
|
+
metrics["max_consecutive_opener"] = max_run
|
|
256
|
+
top_repeat = metrics["top_opener_share"] > 0.25 and top_count >= 4
|
|
257
|
+
if metrics["opener_unique_ratio"] < 0.55 or top_repeat or max_run >= 4:
|
|
258
|
+
flags.append(flag(
|
|
259
|
+
"opener_repetition",
|
|
260
|
+
metrics["opener_unique_ratio"],
|
|
261
|
+
"unique ratio < 0.55, one opener > 25% with 4+ uses, or 4 consecutive identical openers",
|
|
262
|
+
"Sentence openings repeat in a template-like rhythm.",
|
|
263
|
+
"Rewrite repeated starts; step-by-step docs may need repeated imperative openers.",
|
|
264
|
+
))
|
|
265
|
+
|
|
266
|
+
if sentences:
|
|
267
|
+
closer_count = sum(1 for s in sentences if CLOSER_RE.search(s))
|
|
268
|
+
metrics["participial_closer_share"] = round(closer_count / len(sentences), 3)
|
|
269
|
+
if len(sentences) >= 8 and metrics["participial_closer_share"] >= 0.15:
|
|
270
|
+
flags.append(flag(
|
|
271
|
+
"participial_closer_share",
|
|
272
|
+
metrics["participial_closer_share"],
|
|
273
|
+
">= 0.15 over at least 8 prose sentences",
|
|
274
|
+
"Many sentences end with editorial -ing consequence tails.",
|
|
275
|
+
"Make the consequence concrete or cut the tail; analytical prose may allow an occasional closer.",
|
|
276
|
+
))
|
|
277
|
+
|
|
278
|
+
return {
|
|
279
|
+
"flags": flags,
|
|
280
|
+
"flagged": {f["metric"]: True for f in flags},
|
|
281
|
+
"metrics": metrics,
|
|
282
|
+
"genre": genre,
|
|
283
|
+
"prose_sentences": len(sentences),
|
|
284
|
+
"prose_paragraphs": len(paragraphs),
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
def parse_args(argv: list[str]) -> argparse.Namespace:
|
|
289
|
+
parser = argparse.ArgumentParser(description=__doc__)
|
|
290
|
+
parser.add_argument("path", nargs="?")
|
|
291
|
+
parser.add_argument("--genre", choices=["prose", "docs", "social"], default="prose")
|
|
292
|
+
return parser.parse_args(argv)
|
|
293
|
+
|
|
294
|
+
|
|
295
|
+
def main(argv: list[str]) -> int:
|
|
296
|
+
args = parse_args(argv)
|
|
297
|
+
if args.path:
|
|
298
|
+
path = Path(args.path)
|
|
299
|
+
if not path.exists():
|
|
300
|
+
print(f"Missing file: {path}", file=sys.stderr)
|
|
301
|
+
return 2
|
|
302
|
+
text = path.read_text(errors="replace")
|
|
303
|
+
else:
|
|
304
|
+
text = sys.stdin.buffer.read().decode("utf-8", errors="replace")
|
|
305
|
+
|
|
306
|
+
# English-only graceful decline, matching banned_phrase_scan.py.
|
|
307
|
+
result = scan(text, args.genre)
|
|
308
|
+
|
|
309
|
+
# Function-word absence alone is not evidence of a foreign language
|
|
310
|
+
# (imperative stacks and buzzword lists are English slop with few function
|
|
311
|
+
# words). Decline only when the heuristic fails AND nothing flagged.
|
|
312
|
+
if not result.get("flags") and not is_probably_english(text):
|
|
313
|
+
print(json.dumps({"non_english": True, "violations": [], "flags": []}, indent=2))
|
|
314
|
+
print("note: input appears non-English; scanner declined (English-only).", file=sys.stderr)
|
|
315
|
+
return 0
|
|
316
|
+
|
|
317
|
+
print(json.dumps(result, indent=2))
|
|
318
|
+
return 1 if result["flags"] else 0
|
|
319
|
+
|
|
320
|
+
|
|
321
|
+
if __name__ == "__main__":
|
|
322
|
+
raise SystemExit(main(sys.argv[1:]))
|
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Co-writer suggestion mode: emit LSP-style structured edit suggestions.
|
|
3
|
+
|
|
4
|
+
Detection is cheap and deterministic (banned_phrase_scan + structure_scan).
|
|
5
|
+
Replacement generation is DELEGATED to a stronger model: by default every
|
|
6
|
+
suggestion is emitted with ``"suggested_replacement": null``. A separate
|
|
7
|
+
``--apply-replacements FILE`` mode merges externally-produced replacements back
|
|
8
|
+
in and light-validates them. The blocking contract gates live in
|
|
9
|
+
check_suggestions.py; this script never rewrites the document itself.
|
|
10
|
+
|
|
11
|
+
Output shape:
|
|
12
|
+
{
|
|
13
|
+
"document": "<original text>",
|
|
14
|
+
"suggestions": [
|
|
15
|
+
{
|
|
16
|
+
"span": {"start": N, "end": N, "text": "..."},
|
|
17
|
+
"severity": "hard" | "soft",
|
|
18
|
+
"category": "...",
|
|
19
|
+
"rationale": "...",
|
|
20
|
+
"suggested_replacement": null,
|
|
21
|
+
"phrased_as_question": bool
|
|
22
|
+
},
|
|
23
|
+
...
|
|
24
|
+
],
|
|
25
|
+
"counts": {...}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
Soft findings are phrased as questions (register-dependent judgment calls);
|
|
29
|
+
hard findings are stated as direct replacements. Suggestions are emitted in a
|
|
30
|
+
deterministic order (span start, then end, then category) and never overlap.
|
|
31
|
+
|
|
32
|
+
Usage:
|
|
33
|
+
python3 scripts/suggest.py document.md
|
|
34
|
+
python3 scripts/suggest.py < document.md
|
|
35
|
+
python3 scripts/suggest.py document.md --apply-replacements replacements.json
|
|
36
|
+
"""
|
|
37
|
+
|
|
38
|
+
from __future__ import annotations
|
|
39
|
+
|
|
40
|
+
import argparse
|
|
41
|
+
import json
|
|
42
|
+
import re
|
|
43
|
+
import sys
|
|
44
|
+
from pathlib import Path
|
|
45
|
+
|
|
46
|
+
from banned_phrase_scan import (
|
|
47
|
+
is_probably_english,
|
|
48
|
+
scan_for_violations,
|
|
49
|
+
)
|
|
50
|
+
from structure_scan import scan as structure_scan
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _line_starts(text: str) -> list[int]:
|
|
54
|
+
"""Character offset at which each line begins (index 0 == line 1)."""
|
|
55
|
+
starts = [0]
|
|
56
|
+
for i, ch in enumerate(text):
|
|
57
|
+
if ch == "\n":
|
|
58
|
+
starts.append(i + 1)
|
|
59
|
+
return starts
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _offset(line_starts: list[int], line_number: int, column: int) -> int:
|
|
63
|
+
return line_starts[line_number - 1] + (column - 1)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _rationale(category: str, span_text: str, suggestion: str | None, is_soft: bool) -> str:
|
|
67
|
+
"""Build a reviewer-facing rationale.
|
|
68
|
+
|
|
69
|
+
Soft findings are worded as a question because they are judgment calls whose
|
|
70
|
+
right answer depends on register; hard findings are stated as a direct fix.
|
|
71
|
+
"""
|
|
72
|
+
if is_soft:
|
|
73
|
+
hint = f" Consider: {suggestion}." if suggestion else ""
|
|
74
|
+
return f"Soft tell ({category}): could “{span_text}” be cut or reworded here?{hint}"
|
|
75
|
+
if suggestion:
|
|
76
|
+
return f"AI-writing tell ({category}): replace “{span_text}” — {suggestion}"
|
|
77
|
+
return f"AI-writing tell ({category}): replace “{span_text}”."
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def build_suggestions(text: str) -> list[dict]:
|
|
81
|
+
"""Detect AI-isms and turn each into a span-anchored suggestion.
|
|
82
|
+
|
|
83
|
+
Spans come from banned_phrase_scan (phrases + structural regexes), which are
|
|
84
|
+
the findings with real character offsets that a replacement can be applied
|
|
85
|
+
to. macro-structure flags from structure_scan are document-level and are
|
|
86
|
+
surfaced in ``counts`` instead, since they have no single applyable span.
|
|
87
|
+
"""
|
|
88
|
+
violations = scan_for_violations(text)
|
|
89
|
+
line_starts = _line_starts(text)
|
|
90
|
+
candidates: list[dict] = []
|
|
91
|
+
for v in violations:
|
|
92
|
+
start = _offset(line_starts, v["line_number"], v["column"])
|
|
93
|
+
end = start + len(v["phrase"])
|
|
94
|
+
span_text = text[start:end]
|
|
95
|
+
is_soft = v["severity"] == "soft"
|
|
96
|
+
candidates.append({
|
|
97
|
+
"span": {"start": start, "end": end, "text": span_text},
|
|
98
|
+
"severity": v["severity"],
|
|
99
|
+
"category": v["category"],
|
|
100
|
+
"rationale": _rationale(v["category"], span_text, v.get("suggestion"), is_soft),
|
|
101
|
+
"suggested_replacement": None,
|
|
102
|
+
"phrased_as_question": is_soft,
|
|
103
|
+
})
|
|
104
|
+
|
|
105
|
+
candidates.sort(key=lambda s: (s["span"]["start"], s["span"]["end"], s["category"]))
|
|
106
|
+
|
|
107
|
+
# Enforce non-overlap deterministically: keep the earliest-starting span and
|
|
108
|
+
# drop any later suggestion that overlaps an already-kept one, so the emitted
|
|
109
|
+
# set always satisfies the span-overlap contract gate.
|
|
110
|
+
kept: list[dict] = []
|
|
111
|
+
last_end = -1
|
|
112
|
+
for s in candidates:
|
|
113
|
+
if s["span"]["start"] >= last_end:
|
|
114
|
+
kept.append(s)
|
|
115
|
+
last_end = s["span"]["end"]
|
|
116
|
+
return kept
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def counts_block(suggestions: list[dict], struct: dict) -> dict:
|
|
120
|
+
by_category: dict[str, int] = {}
|
|
121
|
+
hard = soft = 0
|
|
122
|
+
for s in suggestions:
|
|
123
|
+
by_category[s["category"]] = by_category.get(s["category"], 0) + 1
|
|
124
|
+
if s["severity"] == "soft":
|
|
125
|
+
soft += 1
|
|
126
|
+
else:
|
|
127
|
+
hard += 1
|
|
128
|
+
return {
|
|
129
|
+
"total": len(suggestions),
|
|
130
|
+
"hard": hard,
|
|
131
|
+
"soft": soft,
|
|
132
|
+
"by_category": by_category,
|
|
133
|
+
"structure_flags": [f["metric"] for f in struct.get("flags", [])],
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def apply_replacements(suggestions: list[dict], repl_path: str) -> list[str]:
|
|
138
|
+
"""Merge externally-produced replacements into suggestions and light-validate.
|
|
139
|
+
|
|
140
|
+
The replacement file is ``{"replacements": [{"start", "end", "replacement"}]}``.
|
|
141
|
+
Each replacement is keyed to a suggestion by exact (start, end) span. This is
|
|
142
|
+
only a light merge/validation pass; the blocking contract lives in
|
|
143
|
+
check_suggestions.py.
|
|
144
|
+
"""
|
|
145
|
+
data = json.loads(Path(repl_path).read_text())
|
|
146
|
+
index = {(r["start"], r["end"]): r["replacement"] for r in data.get("replacements", [])}
|
|
147
|
+
warnings: list[str] = []
|
|
148
|
+
matched: set[tuple[int, int]] = set()
|
|
149
|
+
for s in suggestions:
|
|
150
|
+
key = (s["span"]["start"], s["span"]["end"])
|
|
151
|
+
if key not in index:
|
|
152
|
+
continue
|
|
153
|
+
rep = index[key]
|
|
154
|
+
s["suggested_replacement"] = rep
|
|
155
|
+
matched.add(key)
|
|
156
|
+
if rep == s["span"]["text"]:
|
|
157
|
+
warnings.append(f"replacement for span {list(key)} is identical to span text")
|
|
158
|
+
elif scan_for_violations(rep) or structure_scan(rep).get("flags"):
|
|
159
|
+
warnings.append(f"replacement for span {list(key)} does not pass the scanners in isolation")
|
|
160
|
+
for key in index:
|
|
161
|
+
if key not in matched:
|
|
162
|
+
warnings.append(f"replacement targets unknown span {list(key)}")
|
|
163
|
+
return warnings
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def parse_args(argv: list[str]) -> argparse.Namespace:
|
|
167
|
+
parser = argparse.ArgumentParser(description=__doc__)
|
|
168
|
+
parser.add_argument("path", nargs="?", help="Document file. Reads stdin when omitted.")
|
|
169
|
+
parser.add_argument(
|
|
170
|
+
"--apply-replacements",
|
|
171
|
+
metavar="FILE",
|
|
172
|
+
help="Merge externally-produced replacements (JSON) into the suggestions.",
|
|
173
|
+
)
|
|
174
|
+
return parser.parse_args(argv)
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def main(argv: list[str]) -> int:
|
|
178
|
+
args = parse_args(argv)
|
|
179
|
+
if args.path:
|
|
180
|
+
path = Path(args.path)
|
|
181
|
+
if not path.exists():
|
|
182
|
+
print(f"Missing file: {path}", file=sys.stderr)
|
|
183
|
+
return 2
|
|
184
|
+
text = path.read_text(errors="replace")
|
|
185
|
+
else:
|
|
186
|
+
text = sys.stdin.buffer.read().decode("utf-8", errors="replace")
|
|
187
|
+
|
|
188
|
+
# English-only graceful decline, matching the two scanners.
|
|
189
|
+
if not is_probably_english(text):
|
|
190
|
+
print(json.dumps({"non_english": True, "document": text, "suggestions": [],
|
|
191
|
+
"counts": {"total": 0, "hard": 0, "soft": 0,
|
|
192
|
+
"by_category": {}, "structure_flags": []}},
|
|
193
|
+
indent=2))
|
|
194
|
+
print("note: input appears non-English; co-writer declined (English-only).", file=sys.stderr)
|
|
195
|
+
return 0
|
|
196
|
+
|
|
197
|
+
suggestions = build_suggestions(text)
|
|
198
|
+
struct = structure_scan(text)
|
|
199
|
+
out = {
|
|
200
|
+
"document": text,
|
|
201
|
+
"suggestions": suggestions,
|
|
202
|
+
"counts": counts_block(suggestions, struct),
|
|
203
|
+
}
|
|
204
|
+
if args.apply_replacements:
|
|
205
|
+
out["apply_warnings"] = apply_replacements(suggestions, args.apply_replacements)
|
|
206
|
+
print(json.dumps(out, indent=2))
|
|
207
|
+
return 0
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
if __name__ == "__main__":
|
|
211
|
+
raise SystemExit(main(sys.argv[1:]))
|