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,580 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
Deterministic dimension-controlled pair generation for the teach calibration game.
|
|
4
|
+
|
|
5
|
+
Given a base passage and ONE voice dimension, produce a minimal pair (A, B) where
|
|
6
|
+
B differs from A along exactly that dimension. The direction of the transform
|
|
7
|
+
(e.g. contract -> expand vs expand -> contract) is auto-detected from whichever
|
|
8
|
+
pole the base passage already sits at, so every dimension is reversible: feed it
|
|
9
|
+
a passage already on one pole and it moves to the other.
|
|
10
|
+
|
|
11
|
+
Every transform must preserve the must-preserve constraint tokens extracted by
|
|
12
|
+
scripts/extract_constraints.py (numbers, dates, names, quotes, units, etc). If a
|
|
13
|
+
dimension has no expressible transform in the given passage (neither pole's
|
|
14
|
+
pattern is present, or the only candidate transform would drop a constraint),
|
|
15
|
+
the command exits 3 with "dimension not expressible in this passage".
|
|
16
|
+
|
|
17
|
+
Usage:
|
|
18
|
+
python3 calibrate_pairs.py generate --base FILE --dimension DIM --seed N
|
|
19
|
+
python3 calibrate_pairs.py --list-dimensions
|
|
20
|
+
|
|
21
|
+
Dimensions: contractions, em_dash, sentence_length, connectives, staccato.
|
|
22
|
+
"""
|
|
23
|
+
from __future__ import annotations
|
|
24
|
+
|
|
25
|
+
import argparse
|
|
26
|
+
import hashlib
|
|
27
|
+
import json
|
|
28
|
+
import random
|
|
29
|
+
import re
|
|
30
|
+
import sys
|
|
31
|
+
from pathlib import Path
|
|
32
|
+
|
|
33
|
+
HERE = Path(__file__).resolve().parent
|
|
34
|
+
sys.path.insert(0, str(HERE))
|
|
35
|
+
|
|
36
|
+
from extract_constraints import extract_constraints # noqa: E402
|
|
37
|
+
from banned_phrase_scan import scan_for_violations # noqa: E402
|
|
38
|
+
|
|
39
|
+
DIMENSIONS = ["contractions", "em_dash", "sentence_length", "connectives", "staccato"]
|
|
40
|
+
|
|
41
|
+
# Each dimension has two named poles. transform_applied is "<dimension>:<pole of B>".
|
|
42
|
+
POLES: dict[str, tuple[str, str]] = {
|
|
43
|
+
"contractions": ("contracted", "expanded"),
|
|
44
|
+
"em_dash": ("dashed", "plain"),
|
|
45
|
+
"sentence_length": ("long", "short"),
|
|
46
|
+
"connectives": ("plain", "formal"),
|
|
47
|
+
"staccato": ("staccato", "flowing"),
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class NotExpressible(Exception):
|
|
52
|
+
"""Raised when a dimension has no applicable transform in the passage."""
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
# --------------------------------------------------------------------------
|
|
56
|
+
# contractions: expand <-> contract via a fixed, unambiguous mapping table.
|
|
57
|
+
# --------------------------------------------------------------------------
|
|
58
|
+
|
|
59
|
+
_CONTRACTION_PAIRS = [
|
|
60
|
+
("do not", "don't"), ("does not", "doesn't"), ("did not", "didn't"),
|
|
61
|
+
("cannot", "can't"), ("will not", "won't"), ("would not", "wouldn't"),
|
|
62
|
+
("should not", "shouldn't"), ("could not", "couldn't"), ("must not", "mustn't"),
|
|
63
|
+
("is not", "isn't"), ("are not", "aren't"), ("was not", "wasn't"), ("were not", "weren't"),
|
|
64
|
+
("have not", "haven't"), ("has not", "hasn't"), ("had not", "hadn't"),
|
|
65
|
+
("I am", "I'm"), ("you are", "you're"), ("we are", "we're"), ("they are", "they're"),
|
|
66
|
+
("I will", "I'll"), ("you will", "you'll"), ("we will", "we'll"), ("they will", "they'll"),
|
|
67
|
+
("he will", "he'll"), ("she will", "she'll"), ("it will", "it'll"),
|
|
68
|
+
("I have", "I've"), ("you have", "you've"), ("we have", "we've"), ("they have", "they've"),
|
|
69
|
+
("let us", "let's"),
|
|
70
|
+
("it is", "it's"), ("that is", "that's"), ("there is", "there's"),
|
|
71
|
+
("here is", "here's"), ("who is", "who's"), ("what is", "what's"),
|
|
72
|
+
]
|
|
73
|
+
# Pairs whose expanded side always keeps a hard-capitalized pronoun ("I ...").
|
|
74
|
+
_HARD_CAPITAL = {e for e, c in _CONTRACTION_PAIRS if e.startswith("I ")}
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _case_like(template: str, source_first_char: str) -> str:
|
|
78
|
+
if source_first_char.isupper():
|
|
79
|
+
return template[0].upper() + template[1:]
|
|
80
|
+
return template[0].lower() + template[1:]
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
# Words safe to lowercase when a sentence is folded mid-clause. A capitalized
|
|
84
|
+
# word NOT in this set is assumed to be a proper noun (constraint) and is left
|
|
85
|
+
# alone -- lowercasing "Priya" into a joined clause would corrupt a name.
|
|
86
|
+
_LOWERABLE_JOINERS = {
|
|
87
|
+
"the", "it", "she", "he", "they", "this", "that", "there", "i", "we", "you",
|
|
88
|
+
"who", "what", "when", "where", "why", "how", "a", "an", "and", "but", "so",
|
|
89
|
+
"yet", "or", "nobody", "everybody", "everyone", "someone", "something",
|
|
90
|
+
"nothing", "no", "her", "his", "its", "their", "our", "your",
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def _lower_first_word(s: str) -> str:
|
|
95
|
+
"""Lowercase the leading word only if it is a common function word/pronoun,
|
|
96
|
+
never a token that looks like a proper noun a constraint might depend on."""
|
|
97
|
+
m = re.match(r"[A-Za-z']+", s)
|
|
98
|
+
if not m:
|
|
99
|
+
return s
|
|
100
|
+
word = m.group(0)
|
|
101
|
+
if word.lower() in _LOWERABLE_JOINERS:
|
|
102
|
+
return word.lower() + s[len(word):]
|
|
103
|
+
return s
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
_WORD_BEFORE_RE = re.compile(r"([A-Za-z']+)\s*$")
|
|
107
|
+
_WORD_AFTER_RE = re.compile(r"^\s*([A-Za-z']+)")
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def _is_title_case(word: str) -> bool:
|
|
111
|
+
return bool(word) and word[0].isupper() and word[1:].islower()
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def _in_capitalized_span(m: "re.Match[str]") -> bool:
|
|
115
|
+
"""True if this contraction/expansion match sits inside a capitalized
|
|
116
|
+
multi-word span (e.g. "Venue Can't Stop") that looks like a proper noun,
|
|
117
|
+
rather than an ordinary sentence-level contraction.
|
|
118
|
+
|
|
119
|
+
A contraction rewrite is only skipped when the match ITSELF is
|
|
120
|
+
capitalized (so an ordinary sentence-initial "Can't you..." is untouched)
|
|
121
|
+
AND an immediately adjacent word is also Title Case, forming a >= 2-word
|
|
122
|
+
capitalized run. "Maria Chen can't attend" is not skipped (the match is
|
|
123
|
+
lowercase); "the Venue Can't Stop" is skipped (both "Venue" and "Can't"
|
|
124
|
+
are capitalized and adjacent).
|
|
125
|
+
"""
|
|
126
|
+
matched = m.group(0)
|
|
127
|
+
if not matched[0].isupper():
|
|
128
|
+
return False
|
|
129
|
+
text = m.string
|
|
130
|
+
before_m = _WORD_BEFORE_RE.search(text[: m.start()])
|
|
131
|
+
after_m = _WORD_AFTER_RE.match(text[m.end() :])
|
|
132
|
+
before_word = before_m.group(1) if before_m else ""
|
|
133
|
+
after_word = after_m.group(1) if after_m else ""
|
|
134
|
+
return _is_title_case(before_word) or _is_title_case(after_word)
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def _contraction_repl(replacement: str, hard: bool):
|
|
138
|
+
"""One replacement closure shared by both contraction directions (expand
|
|
139
|
+
and contract) and both casing modes (hard-capital literal vs case-matched).
|
|
140
|
+
"""
|
|
141
|
+
def repl(m):
|
|
142
|
+
if _in_capitalized_span(m):
|
|
143
|
+
return m.group(0)
|
|
144
|
+
return replacement if hard else _case_like(replacement, m.group(0)[0])
|
|
145
|
+
return repl
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def _apply_contractions(text: str) -> tuple[str, str]:
|
|
149
|
+
contract_hits = []
|
|
150
|
+
expand_hits = []
|
|
151
|
+
for expanded, contracted in _CONTRACTION_PAIRS:
|
|
152
|
+
if expanded in _HARD_CAPITAL:
|
|
153
|
+
# "I am"/"I'll"/... always literally capitalized; case-sensitive match.
|
|
154
|
+
if re.search(re.escape(contracted), text):
|
|
155
|
+
contract_hits.append((expanded, contracted))
|
|
156
|
+
if re.search(re.escape(expanded), text):
|
|
157
|
+
expand_hits.append((expanded, contracted))
|
|
158
|
+
else:
|
|
159
|
+
if re.search(r"\b" + re.escape(contracted) + r"\b", text, re.IGNORECASE):
|
|
160
|
+
contract_hits.append((expanded, contracted))
|
|
161
|
+
if re.search(r"\b" + re.escape(expanded) + r"\b", text, re.IGNORECASE):
|
|
162
|
+
expand_hits.append((expanded, contracted))
|
|
163
|
+
|
|
164
|
+
if contract_hits:
|
|
165
|
+
out = text
|
|
166
|
+
for expanded, contracted in contract_hits:
|
|
167
|
+
hard = expanded in _HARD_CAPITAL
|
|
168
|
+
pattern = re.escape(contracted) if hard else r"\b" + re.escape(contracted) + r"\b"
|
|
169
|
+
flags = 0 if hard else re.IGNORECASE
|
|
170
|
+
out = re.sub(pattern, _contraction_repl(expanded, hard), out, flags=flags)
|
|
171
|
+
return out, "expanded"
|
|
172
|
+
|
|
173
|
+
if expand_hits:
|
|
174
|
+
out = text
|
|
175
|
+
for expanded, contracted in expand_hits:
|
|
176
|
+
hard = expanded in _HARD_CAPITAL
|
|
177
|
+
pattern = re.escape(expanded) if hard else r"\b" + re.escape(expanded) + r"\b"
|
|
178
|
+
flags = 0 if hard else re.IGNORECASE
|
|
179
|
+
out = re.sub(pattern, _contraction_repl(contracted, hard), out, flags=flags)
|
|
180
|
+
return out, "contracted"
|
|
181
|
+
|
|
182
|
+
raise NotExpressible("no contraction or expandable phrase found")
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
# --------------------------------------------------------------------------
|
|
186
|
+
# em_dash: paired em-dash parentheticals <-> paired-comma parentheticals ONLY.
|
|
187
|
+
#
|
|
188
|
+
# Only the paired-dash<->paired-comma path is expressible for this dimension.
|
|
189
|
+
# A lone joiner dash (" — nobody objected") has no comma-pair equivalent that
|
|
190
|
+
# preserves sentence count: turning it into a period (the old behavior) split
|
|
191
|
+
# one sentence into two, silently changing sentence count between A and B.
|
|
192
|
+
# Passages with only a lone dash (no paired construction) are declined rather
|
|
193
|
+
# than forced through a transform that shifts sentence count.
|
|
194
|
+
# --------------------------------------------------------------------------
|
|
195
|
+
|
|
196
|
+
_PAIRED_DASH_RE = re.compile(r"\s—\s(.+?)\s—\s")
|
|
197
|
+
_PAIRED_COMMA_RE = re.compile(r",\s+(.+?),\s+")
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def _apply_em_dash(text: str) -> tuple[str, str]:
|
|
201
|
+
if _PAIRED_DASH_RE.search(text):
|
|
202
|
+
out = _PAIRED_DASH_RE.sub(lambda m: ", " + m.group(1) + ", ", text)
|
|
203
|
+
if "—" in out:
|
|
204
|
+
# A lone dash survived alongside the paired one -- the restricted
|
|
205
|
+
# paired-dash<->comma path can't fully express this passage
|
|
206
|
+
# without also touching the lone dash (and changing sentence
|
|
207
|
+
# count), so decline rather than ship a half-converted pair.
|
|
208
|
+
raise NotExpressible(
|
|
209
|
+
"passage mixes a paired em dash with a lone em dash; "
|
|
210
|
+
"the paired-dash<->comma path can't resolve the lone dash "
|
|
211
|
+
"without changing sentence count"
|
|
212
|
+
)
|
|
213
|
+
return out, "plain"
|
|
214
|
+
|
|
215
|
+
if "—" in text:
|
|
216
|
+
raise NotExpressible(
|
|
217
|
+
"only a lone em dash found; the em_dash dimension is restricted "
|
|
218
|
+
"to the paired-dash<->comma path so sentence count stays stable"
|
|
219
|
+
)
|
|
220
|
+
|
|
221
|
+
if _PAIRED_COMMA_RE.search(text):
|
|
222
|
+
out = _PAIRED_COMMA_RE.sub(lambda m: " — " + m.group(1) + " — ", text)
|
|
223
|
+
return out, "dashed"
|
|
224
|
+
|
|
225
|
+
raise NotExpressible("no paired em dash or comma-bounded parenthetical found")
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
# --------------------------------------------------------------------------
|
|
229
|
+
# sentence_length: split at coordinators <-> join short adjacent sentences.
|
|
230
|
+
# --------------------------------------------------------------------------
|
|
231
|
+
|
|
232
|
+
_COORD_RE = re.compile(r",\s+(and|but|or|so|yet)\s+", re.IGNORECASE)
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
def _apply_sentence_length(text: str) -> tuple[str, str]:
|
|
236
|
+
sentences = re.findall(r"[^.!?]+[.!?]+", text)
|
|
237
|
+
|
|
238
|
+
for sent in sentences:
|
|
239
|
+
for m in _COORD_RE.finditer(sent):
|
|
240
|
+
before = sent[:m.start()]
|
|
241
|
+
after = sent[m.end():]
|
|
242
|
+
if len(before.split()) >= 3 and len(after.split()) >= 3:
|
|
243
|
+
new_sent = before.rstrip() + ". " + m.group(1).capitalize() + " " + after
|
|
244
|
+
out = text.replace(sent, new_sent, 1)
|
|
245
|
+
return re.sub(r"\s+", " ", out).strip(), "short"
|
|
246
|
+
|
|
247
|
+
for i in range(len(sentences) - 1):
|
|
248
|
+
a_words = sentences[i].strip().split()
|
|
249
|
+
b_words = sentences[i + 1].strip().split()
|
|
250
|
+
if len(a_words) <= 8 and len(b_words) <= 8:
|
|
251
|
+
first_no_period = re.sub(r"[.!?]+\s*$", "", sentences[i].strip())
|
|
252
|
+
second = sentences[i + 1].strip()
|
|
253
|
+
second_body = re.sub(r"[.!?]+\s*$", "", second)
|
|
254
|
+
end_punct = second[len(second_body):].strip() or "."
|
|
255
|
+
joined = first_no_period + ", and " + _lower_first_word(second_body) + end_punct
|
|
256
|
+
prefix = "".join(sentences[:i])
|
|
257
|
+
suffix = "".join(sentences[i + 2:])
|
|
258
|
+
out = (prefix + " " + joined + " " + suffix).strip()
|
|
259
|
+
return re.sub(r"\s+", " ", out), "long"
|
|
260
|
+
|
|
261
|
+
raise NotExpressible("no coordinator split site or joinable short sentences found")
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
# --------------------------------------------------------------------------
|
|
265
|
+
# connectives: formal <-> plain via a fixed table.
|
|
266
|
+
# --------------------------------------------------------------------------
|
|
267
|
+
|
|
268
|
+
_FORMAL_TO_PLAIN = {
|
|
269
|
+
"however": "But", "additionally": "Also", "therefore": "So",
|
|
270
|
+
"furthermore": "Also", "moreover": "Also", "nevertheless": "Still",
|
|
271
|
+
"consequently": "So", "nonetheless": "Still", "subsequently": "Then",
|
|
272
|
+
"thus": "So",
|
|
273
|
+
}
|
|
274
|
+
_PLAIN_TO_FORMAL = {
|
|
275
|
+
"but": "However", "also": "Additionally", "so": "Therefore",
|
|
276
|
+
"still": "Nevertheless", "then": "Subsequently",
|
|
277
|
+
}
|
|
278
|
+
_FORMAL_RE = re.compile(
|
|
279
|
+
r"(?:^|(?<=[.!?]\s))(" + "|".join(_FORMAL_TO_PLAIN) + r"),?\s+", re.IGNORECASE
|
|
280
|
+
)
|
|
281
|
+
_PLAIN_RE = re.compile(
|
|
282
|
+
r"(?:^|(?<=[.!?]\s))(" + "|".join(_PLAIN_TO_FORMAL) + r"),?\s+", re.IGNORECASE
|
|
283
|
+
)
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
def _apply_connectives(text: str) -> tuple[str, str]:
|
|
287
|
+
if _FORMAL_RE.search(text):
|
|
288
|
+
def repl(m):
|
|
289
|
+
# No comma after the plain form: "But " reads as ordinary speech;
|
|
290
|
+
# "But, " is stilted, and "So, " specifically trips the scanner's
|
|
291
|
+
# filler_opener structural pattern. Dropping the comma across the
|
|
292
|
+
# board keeps every plain form clean of that tell.
|
|
293
|
+
return _FORMAL_TO_PLAIN[m.group(1).lower()] + " "
|
|
294
|
+
out = _FORMAL_RE.sub(repl, text)
|
|
295
|
+
return out, "plain"
|
|
296
|
+
|
|
297
|
+
if _PLAIN_RE.search(text):
|
|
298
|
+
def repl2(m):
|
|
299
|
+
return _PLAIN_TO_FORMAL[m.group(1).lower()] + ", "
|
|
300
|
+
out = _PLAIN_RE.sub(repl2, text)
|
|
301
|
+
return out, "formal"
|
|
302
|
+
|
|
303
|
+
raise NotExpressible("no formal or plain connective found")
|
|
304
|
+
|
|
305
|
+
|
|
306
|
+
# --------------------------------------------------------------------------
|
|
307
|
+
# staccato: fragment runs <-> flowing clauses.
|
|
308
|
+
# --------------------------------------------------------------------------
|
|
309
|
+
|
|
310
|
+
_LEADING_JOINER_RE = re.compile(
|
|
311
|
+
r"^(?:because|although|while|since|if|when|and|but|so|yet|or)\s+", re.IGNORECASE
|
|
312
|
+
)
|
|
313
|
+
|
|
314
|
+
|
|
315
|
+
def _apply_staccato(text: str, rng: random.Random) -> tuple[str, str]:
|
|
316
|
+
sentences = re.findall(r"[^.!?]+[.!?]+", text)
|
|
317
|
+
|
|
318
|
+
# Reverse direction: a run of >= 3 consecutive short (<=6 word) sentences -> flow.
|
|
319
|
+
runs = []
|
|
320
|
+
run_start = None
|
|
321
|
+
for i, sent in enumerate(sentences):
|
|
322
|
+
words = sent.strip().split()
|
|
323
|
+
if len(words) <= 6:
|
|
324
|
+
if run_start is None:
|
|
325
|
+
run_start = i
|
|
326
|
+
else:
|
|
327
|
+
if run_start is not None and i - run_start >= 3:
|
|
328
|
+
runs.append((run_start, i))
|
|
329
|
+
run_start = None
|
|
330
|
+
if run_start is not None and len(sentences) - run_start >= 3:
|
|
331
|
+
runs.append((run_start, len(sentences)))
|
|
332
|
+
|
|
333
|
+
if runs:
|
|
334
|
+
start, end = runs[rng.randrange(len(runs))] if len(runs) > 1 else runs[0]
|
|
335
|
+
parts = []
|
|
336
|
+
for idx in range(start, end):
|
|
337
|
+
body = re.sub(r"[.!?]+\s*$", "", sentences[idx].strip())
|
|
338
|
+
parts.append(body)
|
|
339
|
+
joined_parts = []
|
|
340
|
+
for i, p in enumerate(parts):
|
|
341
|
+
if i == 0:
|
|
342
|
+
joined_parts.append(p)
|
|
343
|
+
elif i == len(parts) - 1:
|
|
344
|
+
joined_parts.append("and " + _lower_first_word(p))
|
|
345
|
+
else:
|
|
346
|
+
joined_parts.append(_lower_first_word(p))
|
|
347
|
+
joined = ", ".join(joined_parts) + "."
|
|
348
|
+
prefix = "".join(sentences[:start])
|
|
349
|
+
suffix = "".join(sentences[end:])
|
|
350
|
+
out = (prefix + " " + joined + " " + suffix).strip()
|
|
351
|
+
return re.sub(r"\s+", " ", out), "flowing"
|
|
352
|
+
|
|
353
|
+
# Forward direction: the sentence with the most commas (>= 2) fragments.
|
|
354
|
+
best_idx, best_commas = None, 1
|
|
355
|
+
for i, sent in enumerate(sentences):
|
|
356
|
+
commas = sent.count(",")
|
|
357
|
+
if commas > best_commas:
|
|
358
|
+
best_idx, best_commas = i, commas
|
|
359
|
+
|
|
360
|
+
if best_idx is not None:
|
|
361
|
+
sent = sentences[best_idx]
|
|
362
|
+
body = re.sub(r"[.!?]+\s*$", "", sent.strip())
|
|
363
|
+
end_punct = sent.strip()[len(body):].strip() or "."
|
|
364
|
+
fragments = [f.strip() for f in body.split(",")]
|
|
365
|
+
cleaned = []
|
|
366
|
+
for frag in fragments:
|
|
367
|
+
frag = _LEADING_JOINER_RE.sub("", frag).strip()
|
|
368
|
+
if not frag:
|
|
369
|
+
continue
|
|
370
|
+
frag = frag[0].upper() + frag[1:]
|
|
371
|
+
cleaned.append(frag)
|
|
372
|
+
if len(cleaned) < 3:
|
|
373
|
+
raise NotExpressible("comma split did not yield a fragment run")
|
|
374
|
+
cleaned[-1] = cleaned[-1] + end_punct if not cleaned[-1].endswith((".", "!", "?")) else cleaned[-1]
|
|
375
|
+
new_sent = " ".join(
|
|
376
|
+
f if f.endswith((".", "!", "?")) else f + "." for f in cleaned
|
|
377
|
+
)
|
|
378
|
+
out = text.replace(sent, new_sent, 1)
|
|
379
|
+
return re.sub(r"\s+", " ", out).strip(), "staccato"
|
|
380
|
+
|
|
381
|
+
raise NotExpressible("no fragment run or multi-comma sentence found")
|
|
382
|
+
|
|
383
|
+
|
|
384
|
+
_APPLY = {
|
|
385
|
+
"contractions": lambda text, rng: _apply_contractions(text),
|
|
386
|
+
"em_dash": lambda text, rng: _apply_em_dash(text),
|
|
387
|
+
"sentence_length": lambda text, rng: _apply_sentence_length(text),
|
|
388
|
+
"connectives": lambda text, rng: _apply_connectives(text),
|
|
389
|
+
"staccato": _apply_staccato,
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
_EXAMPLES = {
|
|
393
|
+
"contractions": {
|
|
394
|
+
"description": "Expand <-> contract via a fixed, unambiguous mapping table "
|
|
395
|
+
"(do not <-> don't, I am <-> I'm, it is <-> it's, ...).",
|
|
396
|
+
"a": "The rollout is not finished, and I am not confident it will ship Friday.",
|
|
397
|
+
"b": "The rollout isn't finished, and I'm not confident it'll ship Friday.",
|
|
398
|
+
},
|
|
399
|
+
"em_dash": {
|
|
400
|
+
"description": "Paired em-dash parentheticals <-> paired-comma parentheticals only "
|
|
401
|
+
"(a lone joiner dash has no comma-pair equivalent that preserves "
|
|
402
|
+
"sentence count, so it is declined rather than converted).",
|
|
403
|
+
"a": "The plan — untested and rushed — still shipped on time.",
|
|
404
|
+
"b": "The plan, untested and rushed, still shipped on time.",
|
|
405
|
+
},
|
|
406
|
+
"sentence_length": {
|
|
407
|
+
"description": "Split at a coordinator <-> join two short adjacent sentences.",
|
|
408
|
+
"a": "The team shipped the fix, and the client renewed the contract.",
|
|
409
|
+
"b": "The team shipped the fix. And the client renewed the contract.",
|
|
410
|
+
},
|
|
411
|
+
"connectives": {
|
|
412
|
+
"description": "Formal <-> plain connective swap via a fixed table "
|
|
413
|
+
"(However -> But, Additionally -> Also, Therefore -> So, ...). "
|
|
414
|
+
"Plain forms drop the comma after the connective (\"But \", not "
|
|
415
|
+
"\"But, \") so they read as ordinary speech, not a filler-opener tell.",
|
|
416
|
+
"a": "However, the numbers slipped in March.",
|
|
417
|
+
"b": "But the numbers slipped in March.",
|
|
418
|
+
},
|
|
419
|
+
"staccato": {
|
|
420
|
+
"description": "Fragment runs <-> flowing clauses.",
|
|
421
|
+
"a": "Because the deploy failed, and the on-call missed the page, the team lost an hour.",
|
|
422
|
+
"b": "The deploy failed. The on-call missed the page. The team lost an hour.",
|
|
423
|
+
},
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
|
|
427
|
+
def _is_word_char(ch: str) -> bool:
|
|
428
|
+
return ch.isalnum()
|
|
429
|
+
|
|
430
|
+
|
|
431
|
+
def _has_whole_occurrence(value: str, text: str) -> bool:
|
|
432
|
+
"""True if `value` occurs in `text` as a standalone span rather than as a
|
|
433
|
+
substring straddling the edge of a longer, different word.
|
|
434
|
+
|
|
435
|
+
Plain substring containment is not enough: expanding "Can't" to "Cannot"
|
|
436
|
+
still contains the literal characters "Can", so a naive `"Can" in
|
|
437
|
+
"Cannot"` check reports a corrupted proper noun as "preserved". At each
|
|
438
|
+
edge of a candidate match, this only counts as a clash (disqualifying the
|
|
439
|
+
match) when BOTH the value's own boundary character and the adjacent text
|
|
440
|
+
character are alphanumeric -- i.e. when `value` could be glued onto a
|
|
441
|
+
longer token. Values that start/end with punctuation (currency signs,
|
|
442
|
+
quotes) never clash on that edge, since punctuation can't be silently
|
|
443
|
+
swallowed into a bigger word the way a letter/digit can.
|
|
444
|
+
"""
|
|
445
|
+
if not value:
|
|
446
|
+
return False
|
|
447
|
+
start = 0
|
|
448
|
+
while True:
|
|
449
|
+
idx = text.find(value, start)
|
|
450
|
+
if idx == -1:
|
|
451
|
+
return False
|
|
452
|
+
left_clash = idx > 0 and _is_word_char(text[idx - 1]) and _is_word_char(value[0])
|
|
453
|
+
end = idx + len(value)
|
|
454
|
+
right_clash = end < len(text) and _is_word_char(text[end]) and _is_word_char(value[-1])
|
|
455
|
+
if not left_clash and not right_clash:
|
|
456
|
+
return True
|
|
457
|
+
start = idx + 1
|
|
458
|
+
|
|
459
|
+
|
|
460
|
+
def _verify_constraints_preserved(base_text: str, transformed_text: str) -> list[str]:
|
|
461
|
+
"""Return constraint values from base_text missing from transformed_text.
|
|
462
|
+
|
|
463
|
+
Uses whole-occurrence (token-boundary) comparison rather than plain
|
|
464
|
+
substring containment -- see `_has_whole_occurrence` for why a substring
|
|
465
|
+
check is unsafe here.
|
|
466
|
+
"""
|
|
467
|
+
missing = []
|
|
468
|
+
for c in extract_constraints(base_text):
|
|
469
|
+
value = c["value"]
|
|
470
|
+
if _has_whole_occurrence(value, transformed_text):
|
|
471
|
+
continue
|
|
472
|
+
# Allow whitespace-normalized matches (transforms may collapse spacing).
|
|
473
|
+
normalized_value = re.sub(r"\s+", " ", value)
|
|
474
|
+
normalized_text = re.sub(r"\s+", " ", transformed_text)
|
|
475
|
+
if normalized_value != value and _has_whole_occurrence(normalized_value, normalized_text):
|
|
476
|
+
continue
|
|
477
|
+
missing.append(value)
|
|
478
|
+
return missing
|
|
479
|
+
|
|
480
|
+
|
|
481
|
+
def _scan_flags(text: str) -> list[str]:
|
|
482
|
+
"""Category names banned_phrase_scan raises on `text`, deduped and sorted.
|
|
483
|
+
|
|
484
|
+
Empty when the text scans clean. Generated B-variants can legitimately
|
|
485
|
+
trip the scanner (e.g. a staccato pole reads as anti_slop_register; see
|
|
486
|
+
references/calibrate.md "Voice overrides defaults") -- that is surfaced
|
|
487
|
+
here, not treated as a reason to decline the pair.
|
|
488
|
+
"""
|
|
489
|
+
categories = {v["category"] for v in scan_for_violations(text)}
|
|
490
|
+
return sorted(categories)
|
|
491
|
+
|
|
492
|
+
|
|
493
|
+
def generate_pair(base_text: str, dimension: str, seed: int) -> dict:
|
|
494
|
+
if dimension not in DIMENSIONS:
|
|
495
|
+
raise ValueError(f"unknown dimension: {dimension}")
|
|
496
|
+
|
|
497
|
+
rng = random.Random(seed)
|
|
498
|
+
transformed, pole = _APPLY[dimension](base_text, rng)
|
|
499
|
+
|
|
500
|
+
if transformed.strip() == base_text.strip():
|
|
501
|
+
raise NotExpressible("transform produced no change")
|
|
502
|
+
|
|
503
|
+
missing = _verify_constraints_preserved(base_text, transformed)
|
|
504
|
+
if missing:
|
|
505
|
+
raise NotExpressible(
|
|
506
|
+
"transform would drop must-preserve constraint(s): " + ", ".join(missing)
|
|
507
|
+
)
|
|
508
|
+
|
|
509
|
+
pair_id = hashlib.sha256(
|
|
510
|
+
f"{base_text}|{dimension}|{seed}".encode("utf-8")
|
|
511
|
+
).hexdigest()
|
|
512
|
+
|
|
513
|
+
return {
|
|
514
|
+
"pair_id": pair_id,
|
|
515
|
+
"dimension": dimension,
|
|
516
|
+
"a_text": base_text,
|
|
517
|
+
"b_text": transformed,
|
|
518
|
+
"transform_applied": f"{dimension}:{pole}",
|
|
519
|
+
"a_flags": _scan_flags(base_text),
|
|
520
|
+
"b_flags": _scan_flags(transformed),
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
|
|
524
|
+
def list_dimensions() -> dict:
|
|
525
|
+
return {
|
|
526
|
+
dim: {
|
|
527
|
+
"poles": list(POLES[dim]),
|
|
528
|
+
"description": _EXAMPLES[dim]["description"],
|
|
529
|
+
"example": {"a": _EXAMPLES[dim]["a"], "b": _EXAMPLES[dim]["b"]},
|
|
530
|
+
}
|
|
531
|
+
for dim in DIMENSIONS
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
|
|
535
|
+
def parse_args(argv: list[str]) -> argparse.Namespace:
|
|
536
|
+
parser = argparse.ArgumentParser(description=__doc__)
|
|
537
|
+
sub = parser.add_subparsers(dest="command")
|
|
538
|
+
|
|
539
|
+
gen = sub.add_parser("generate", help="generate one dimension-controlled pair")
|
|
540
|
+
gen.add_argument("--base", required=True, help="path to base passage file")
|
|
541
|
+
gen.add_argument("--dimension", required=True, choices=DIMENSIONS)
|
|
542
|
+
gen.add_argument("--seed", type=int, default=0)
|
|
543
|
+
|
|
544
|
+
parser.add_argument("--list-dimensions", action="store_true")
|
|
545
|
+
return parser.parse_args(argv)
|
|
546
|
+
|
|
547
|
+
|
|
548
|
+
def main(argv: list[str]) -> int:
|
|
549
|
+
args = parse_args(argv)
|
|
550
|
+
|
|
551
|
+
if args.list_dimensions:
|
|
552
|
+
print(json.dumps(list_dimensions(), indent=2, sort_keys=True))
|
|
553
|
+
return 0
|
|
554
|
+
|
|
555
|
+
if args.command != "generate":
|
|
556
|
+
print(json.dumps({"error": "no command given; use generate or --list-dimensions"}))
|
|
557
|
+
return 1
|
|
558
|
+
|
|
559
|
+
try:
|
|
560
|
+
base_text = Path(args.base).read_text()
|
|
561
|
+
except OSError as e:
|
|
562
|
+
print(json.dumps({"error": f"could not read base file: {e}"}))
|
|
563
|
+
return 2
|
|
564
|
+
|
|
565
|
+
try:
|
|
566
|
+
pair = generate_pair(base_text, args.dimension, args.seed)
|
|
567
|
+
except NotExpressible as e:
|
|
568
|
+
print(json.dumps({
|
|
569
|
+
"error": "dimension not expressible in this passage",
|
|
570
|
+
"dimension": args.dimension,
|
|
571
|
+
"detail": str(e),
|
|
572
|
+
}))
|
|
573
|
+
return 3
|
|
574
|
+
|
|
575
|
+
print(json.dumps(pair, indent=2, sort_keys=True))
|
|
576
|
+
return 0
|
|
577
|
+
|
|
578
|
+
|
|
579
|
+
if __name__ == "__main__":
|
|
580
|
+
sys.exit(main(sys.argv[1:]))
|