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,409 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
Validate that all must-preserve constraints survived transformation.
|
|
4
|
+
|
|
5
|
+
Compares original text constraints against transformed text.
|
|
6
|
+
Exit code 0 = all constraints preserved, 1 = missing constraints.
|
|
7
|
+
|
|
8
|
+
Usage:
|
|
9
|
+
python validate_preservation.py original.txt transformed.txt
|
|
10
|
+
python validate_preservation.py original.txt transformed.txt constraints.json
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import sys
|
|
16
|
+
import json
|
|
17
|
+
import re
|
|
18
|
+
from typing import TypedDict
|
|
19
|
+
|
|
20
|
+
# Import constraint extraction
|
|
21
|
+
from extract_constraints import extract_constraints, Constraint
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class ValidationResult(TypedDict):
|
|
25
|
+
passed: bool
|
|
26
|
+
total_constraints: int
|
|
27
|
+
preserved: int
|
|
28
|
+
missing: list[Constraint]
|
|
29
|
+
warnings: list[str]
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
_MAGNITUDES = {
|
|
33
|
+
"k": 1e3, "thousand": 1e3,
|
|
34
|
+
"m": 1e6, "million": 1e6,
|
|
35
|
+
"b": 1e9, "billion": 1e9,
|
|
36
|
+
"trillion": 1e12,
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
_MONTHS = [
|
|
40
|
+
"january", "february", "march", "april", "may", "june", "july",
|
|
41
|
+
"august", "september", "october", "november", "december",
|
|
42
|
+
]
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def normalize_value(value: str) -> str:
|
|
46
|
+
"""Normalize a constraint value for comparison."""
|
|
47
|
+
# Remove extra whitespace
|
|
48
|
+
normalized = re.sub(r'\s+', ' ', value.strip())
|
|
49
|
+
# Lowercase for comparison (preserve original for display)
|
|
50
|
+
return normalized.lower()
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def parse_money(token: str) -> float | None:
|
|
54
|
+
"""Parse a currency token into an absolute amount, honoring magnitude words.
|
|
55
|
+
|
|
56
|
+
'$47.3M' and '$47.3 million' -> 47_300_000; '$47.3 billion' -> 47_300_000_000.
|
|
57
|
+
Magnitude is part of the fact, so the two must not compare equal.
|
|
58
|
+
"""
|
|
59
|
+
m = re.search(
|
|
60
|
+
r'([\d,]+\.?\d*)\s*(k|m|b|thousand|million|billion)?',
|
|
61
|
+
token.lower().replace("$", ""),
|
|
62
|
+
)
|
|
63
|
+
if not m or not m.group(1).strip(","):
|
|
64
|
+
return None
|
|
65
|
+
amount = float(m.group(1).replace(",", ""))
|
|
66
|
+
if m.group(2):
|
|
67
|
+
amount *= _MAGNITUDES[m.group(2)]
|
|
68
|
+
return amount
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def parse_magnitude_number(token: str) -> float | None:
|
|
72
|
+
m = re.search(
|
|
73
|
+
r'\b([\d,]+\.?\d*)\s*(thousand|million|billion|trillion)?\b',
|
|
74
|
+
token.lower(),
|
|
75
|
+
)
|
|
76
|
+
if not m or not m.group(1).strip(","):
|
|
77
|
+
return None
|
|
78
|
+
amount = float(m.group(1).replace(",", ""))
|
|
79
|
+
if m.group(2):
|
|
80
|
+
amount *= _MAGNITUDES[m.group(2)]
|
|
81
|
+
return amount
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def _numbers_match_exactly(value: str, text: str, pattern: str) -> bool:
|
|
85
|
+
"""True iff the numeric value appears as a whole quantity in text.
|
|
86
|
+
|
|
87
|
+
Guards against the substring trap where '12' is 'found' inside '120'.
|
|
88
|
+
"""
|
|
89
|
+
want = re.findall(r'\d+\.?\d*', value)
|
|
90
|
+
have = set(re.findall(pattern, text.lower()))
|
|
91
|
+
return all(num in have for num in want) and bool(want)
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
_UNIT_SYNONYMS = {
|
|
95
|
+
"km": {"km", "kilometer", "kilometers", "kilometre", "kilometres"},
|
|
96
|
+
"mi": {"mi", "mile", "miles"},
|
|
97
|
+
"kg": {"kg", "kilogram", "kilograms"},
|
|
98
|
+
"g": {"g", "gram", "grams"},
|
|
99
|
+
"ms": {"ms", "millisecond", "milliseconds"},
|
|
100
|
+
"s": {"s", "sec", "second", "seconds"},
|
|
101
|
+
"m": {"m", "meter", "meters", "metre", "metres"},
|
|
102
|
+
"cm": {"cm", "centimeter", "centimeters", "centimetre", "centimetres"},
|
|
103
|
+
"mm": {"mm", "millimeter", "millimeters", "millimetre", "millimetres"},
|
|
104
|
+
"ft": {"ft", "foot", "feet"},
|
|
105
|
+
"in": {"in", "inch", "inches"},
|
|
106
|
+
"lb": {"lb", "lbs", "pound", "pounds"},
|
|
107
|
+
"oz": {"oz", "ounce", "ounces"},
|
|
108
|
+
"°c": {"°c", "c", "celsius"},
|
|
109
|
+
"°f": {"°f", "f", "fahrenheit"},
|
|
110
|
+
"min": {"min", "mins", "minute", "minutes"},
|
|
111
|
+
"hr": {"hr", "hrs", "hour", "hours"},
|
|
112
|
+
"day": {"day", "days"},
|
|
113
|
+
"week": {"week", "weeks", "wk", "wks"},
|
|
114
|
+
"month": {"month", "months", "mo"},
|
|
115
|
+
"year": {"year", "years", "yr", "yrs"},
|
|
116
|
+
"kb": {"kb", "kilobyte", "kilobytes"},
|
|
117
|
+
"mb": {"mb", "megabyte", "megabytes"},
|
|
118
|
+
"gb": {"gb", "gigabyte", "gigabytes"},
|
|
119
|
+
"tb": {"tb", "terabyte", "terabytes"},
|
|
120
|
+
"pb": {"pb", "petabyte", "petabytes"},
|
|
121
|
+
"px": {"px", "pixel", "pixels"},
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def _measurement_parts(value: str) -> tuple[str, set[str]] | None:
|
|
126
|
+
m = re.search(r'(\d+\.?\d*)\s*([^\d\s]+|degrees?(?:\s+\w+)?)', value, re.I)
|
|
127
|
+
if not m:
|
|
128
|
+
return None
|
|
129
|
+
unit = m.group(2).lower().strip()
|
|
130
|
+
unit = re.sub(r'^degrees?\s*', '', unit) or "degree"
|
|
131
|
+
aliases = _UNIT_SYNONYMS.get(unit)
|
|
132
|
+
if aliases is None:
|
|
133
|
+
for family in _UNIT_SYNONYMS.values():
|
|
134
|
+
if unit in family:
|
|
135
|
+
aliases = family
|
|
136
|
+
break
|
|
137
|
+
else:
|
|
138
|
+
aliases = {unit}
|
|
139
|
+
return m.group(1), aliases
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def _quote_core(value: str) -> str:
|
|
143
|
+
return value.strip().strip('"“”').lower()
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def _time_variants(value: str) -> set[str]:
|
|
147
|
+
m = re.search(r'\b(\d{1,2})(?::(\d{2}))?(?::\d{2})?\s*(am|pm)?\b', value, re.I)
|
|
148
|
+
if not m:
|
|
149
|
+
return set()
|
|
150
|
+
hour = str(int(m.group(1)))
|
|
151
|
+
minute = m.group(2) or "00"
|
|
152
|
+
suffix = (m.group(3) or "").lower()
|
|
153
|
+
spaced = f" {suffix}" if suffix else ""
|
|
154
|
+
compact = suffix
|
|
155
|
+
variants = {f"{hour}:{minute}{spaced}".strip(), f"{hour}:{minute}{compact}".strip()}
|
|
156
|
+
if minute == "00":
|
|
157
|
+
variants.update({f"{hour}{spaced}".strip(), f"{hour}{compact}".strip()})
|
|
158
|
+
return variants
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def find_constraint_in_text(constraint: Constraint, text: str) -> bool:
|
|
162
|
+
"""Check if a constraint value exists in text."""
|
|
163
|
+
value = constraint["value"]
|
|
164
|
+
ctype = constraint["type"]
|
|
165
|
+
normalized_value = normalize_value(value)
|
|
166
|
+
normalized_text = normalize_value(text)
|
|
167
|
+
|
|
168
|
+
# Direct match
|
|
169
|
+
if normalized_value in normalized_text:
|
|
170
|
+
return True
|
|
171
|
+
|
|
172
|
+
# and/or: the disjunction must survive; "or" (or "or both") is faithful,
|
|
173
|
+
# a bare conjunction is not.
|
|
174
|
+
if ctype == "and_or":
|
|
175
|
+
return bool(re.search(r"\band/or\b|\bor\b", text, re.IGNORECASE))
|
|
176
|
+
|
|
177
|
+
# Currency: compare absolute amounts so a magnitude swap (M -> billion) fails.
|
|
178
|
+
if ctype == "currency":
|
|
179
|
+
target = parse_money(value)
|
|
180
|
+
if target is None:
|
|
181
|
+
return False
|
|
182
|
+
for token in re.findall(
|
|
183
|
+
r'\$?[\d,]+\.?\d*\s*(?:k|m|b|thousand|million|billion)?', text, re.IGNORECASE
|
|
184
|
+
):
|
|
185
|
+
amount = parse_money(token)
|
|
186
|
+
if amount is not None and abs(amount - target) < 0.01:
|
|
187
|
+
return True
|
|
188
|
+
return False
|
|
189
|
+
|
|
190
|
+
if ctype == "magnitude_number":
|
|
191
|
+
target = parse_magnitude_number(value)
|
|
192
|
+
if target is None:
|
|
193
|
+
return False
|
|
194
|
+
for token in re.findall(
|
|
195
|
+
r'\b[\d,]+\.?\d*\s*(?:thousand|million|billion|trillion)?\b',
|
|
196
|
+
text,
|
|
197
|
+
re.IGNORECASE,
|
|
198
|
+
):
|
|
199
|
+
amount = parse_magnitude_number(token)
|
|
200
|
+
if amount is not None and abs(amount - target) < 0.01:
|
|
201
|
+
return True
|
|
202
|
+
return False
|
|
203
|
+
|
|
204
|
+
# Percentage: require an exact numeric match (12% != 120%).
|
|
205
|
+
if ctype == "percentage":
|
|
206
|
+
return _numbers_match_exactly(value, text, r'(\d+\.?\d*)\s*(?:%|percent)')
|
|
207
|
+
|
|
208
|
+
if ctype == "measurement":
|
|
209
|
+
parts = _measurement_parts(value)
|
|
210
|
+
if not parts:
|
|
211
|
+
return False
|
|
212
|
+
number, units = parts
|
|
213
|
+
clean_text = text.replace(",", "").lower()
|
|
214
|
+
if not re.search(r'(?<![\d.])' + re.escape(number.replace(",", "")) + r'(?![\d.])', clean_text):
|
|
215
|
+
return False
|
|
216
|
+
return any(re.search(r'(?<!\w)' + re.escape(unit) + r'(?!\w)', clean_text) for unit in units)
|
|
217
|
+
|
|
218
|
+
if ctype == "range":
|
|
219
|
+
return _numbers_match_exactly(value, text, r'(?<![\d.])(\d+\.?\d*)(?![\d.])')
|
|
220
|
+
|
|
221
|
+
if ctype == "time":
|
|
222
|
+
text_variants = _time_variants(text)
|
|
223
|
+
return bool(_time_variants(value) & text_variants)
|
|
224
|
+
|
|
225
|
+
# Other quantities: match digits, comma-insensitive, but as whole tokens.
|
|
226
|
+
if ctype in ("count", "number"):
|
|
227
|
+
for num in re.findall(r'[\d,]+\.?\d*', value):
|
|
228
|
+
clean_num = num.replace(",", "")
|
|
229
|
+
if re.search(r'(?<![\d.])' + re.escape(clean_num) + r'(?![\d.])',
|
|
230
|
+
text.replace(",", "")):
|
|
231
|
+
return True
|
|
232
|
+
return False
|
|
233
|
+
|
|
234
|
+
# Dates: the year alone is not enough — a changed month is a changed fact.
|
|
235
|
+
if ctype == "date_quarter":
|
|
236
|
+
year_match = re.search(r'\d{4}', value)
|
|
237
|
+
if not (year_match and year_match.group() in text):
|
|
238
|
+
return False
|
|
239
|
+
q = re.search(r'q([1-4])', value.lower())
|
|
240
|
+
if not q:
|
|
241
|
+
return False
|
|
242
|
+
ordinal = {"1": "first", "2": "second", "3": "third", "4": "fourth"}[q.group(1)]
|
|
243
|
+
low_text = text.lower()
|
|
244
|
+
return q.group(0) in low_text or re.search(ordinal + r'\s+quarter', low_text) is not None
|
|
245
|
+
|
|
246
|
+
if ctype.startswith("date"):
|
|
247
|
+
year_match = re.search(r'\d{4}', value)
|
|
248
|
+
if not (year_match and year_match.group() in text):
|
|
249
|
+
return False
|
|
250
|
+
low = value.lower()
|
|
251
|
+
month = next((mo for mo in _MONTHS if mo[:3] in low), None)
|
|
252
|
+
if month and month[:3] not in text.lower():
|
|
253
|
+
return False
|
|
254
|
+
quarter = re.search(r'q[1-4]', low)
|
|
255
|
+
if quarter and quarter.group() not in text.lower():
|
|
256
|
+
return False
|
|
257
|
+
return True
|
|
258
|
+
|
|
259
|
+
# For quotes, check if core content is present (without surrounding quotes)
|
|
260
|
+
if constraint["type"] == "quote":
|
|
261
|
+
inner = _quote_core(value)
|
|
262
|
+
comparable_text = normalized_text.replace("“", '"').replace("”", '"')
|
|
263
|
+
if inner in comparable_text:
|
|
264
|
+
return True
|
|
265
|
+
|
|
266
|
+
if ctype == "proper_noun":
|
|
267
|
+
words = re.findall(r'[A-Z][a-z]+', value)
|
|
268
|
+
if words and all(re.search(r'\b' + re.escape(word) + r'\b', text, re.I) for word in words):
|
|
269
|
+
return True
|
|
270
|
+
|
|
271
|
+
return False
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
_NEGATION_RE = re.compile(
|
|
275
|
+
r"\b(?:not|never|no|cannot|can't|won't|don't|doesn't|didn't|isn't|aren't|"
|
|
276
|
+
r"wasn't|weren't|without|neither|nor|none|fails?\s+to|rather\s+than)\b", re.I)
|
|
277
|
+
_SCOPE_RE = re.compile(
|
|
278
|
+
r"\b(?:most|all|none|every|each|some|few|several|majority|minority|only|"
|
|
279
|
+
r"always|usually|typically|rarely|approximately|roughly|about|nearly)\b", re.I)
|
|
280
|
+
_CONDITIONAL_RE = re.compile(
|
|
281
|
+
r"\b(?:if|unless|provided\s+that|only\s+if|assuming|given\s+that|"
|
|
282
|
+
r"in\s+the\s+event|contingent\s+on|except|excluding|other\s+than|"
|
|
283
|
+
r"aside\s+from|save\s+for)\b", re.I)
|
|
284
|
+
_WEAK_MODAL_RE = re.compile(
|
|
285
|
+
r"\b(?:may|might|could|should|can|likely|probably|appears?|suggests?|seems?)\b", re.I)
|
|
286
|
+
_STRONG_MODAL_RE = re.compile(
|
|
287
|
+
r"\b(?:will|must|always|definitely|certainly|guarantees?|proves?)\b", re.I)
|
|
288
|
+
|
|
289
|
+
|
|
290
|
+
def semantic_drift_warnings(original: str, transformed: str) -> list[str]:
|
|
291
|
+
"""Warn when meaning-bearing words present in the original vanish in the rewrite."""
|
|
292
|
+
out: list[str] = []
|
|
293
|
+
o, t = original.lower(), transformed.lower()
|
|
294
|
+
|
|
295
|
+
on, tn = len(_NEGATION_RE.findall(o)), len(_NEGATION_RE.findall(t))
|
|
296
|
+
if on > tn:
|
|
297
|
+
out.append(
|
|
298
|
+
f"Negation count dropped {on}->{tn}. Verify no claim was inverted or "
|
|
299
|
+
"weakened (e.g. 'does not support' -> 'supports').")
|
|
300
|
+
|
|
301
|
+
missing_scope = sorted({w for w in _SCOPE_RE.findall(o)} - {w for w in _SCOPE_RE.findall(t)})
|
|
302
|
+
for w in missing_scope:
|
|
303
|
+
out.append(f"Scope/precision word '{w}' not in output. Verify the claim's scope is unchanged.")
|
|
304
|
+
|
|
305
|
+
oc, tc = len(_CONDITIONAL_RE.findall(o)), len(_CONDITIONAL_RE.findall(t))
|
|
306
|
+
if oc > tc:
|
|
307
|
+
out.append(
|
|
308
|
+
f"Conditional count dropped {oc}->{tc}. Verify a conditional claim "
|
|
309
|
+
"wasn't turned into an unconditional one.")
|
|
310
|
+
|
|
311
|
+
ow, tw = len(_WEAK_MODAL_RE.findall(o)), len(_WEAK_MODAL_RE.findall(t))
|
|
312
|
+
os, ts = len(_STRONG_MODAL_RE.findall(o)), len(_STRONG_MODAL_RE.findall(t))
|
|
313
|
+
if ow > tw and ts > os:
|
|
314
|
+
out.append("Hedged claim may have been strengthened. Verify uncertainty was not turned into certainty.")
|
|
315
|
+
|
|
316
|
+
return out
|
|
317
|
+
|
|
318
|
+
|
|
319
|
+
def validate_preservation(
|
|
320
|
+
original_text: str,
|
|
321
|
+
transformed_text: str,
|
|
322
|
+
constraints: list[Constraint] | None = None
|
|
323
|
+
) -> ValidationResult:
|
|
324
|
+
"""Validate that all constraints are preserved in transformed text."""
|
|
325
|
+
|
|
326
|
+
if constraints is None:
|
|
327
|
+
constraints = extract_constraints(original_text)
|
|
328
|
+
|
|
329
|
+
missing: list[Constraint] = []
|
|
330
|
+
warnings: list[str] = []
|
|
331
|
+
|
|
332
|
+
for constraint in constraints:
|
|
333
|
+
if not find_constraint_in_text(constraint, transformed_text):
|
|
334
|
+
missing.append(constraint)
|
|
335
|
+
|
|
336
|
+
# Generate warnings for near-misses
|
|
337
|
+
for m in missing:
|
|
338
|
+
if m["type"] == "percentage":
|
|
339
|
+
# Check if number exists without %
|
|
340
|
+
num = re.search(r'[\d.]+', m["value"])
|
|
341
|
+
if num and num.group() in transformed_text:
|
|
342
|
+
warnings.append(f"Number {num.group()} found but missing '%' symbol")
|
|
343
|
+
|
|
344
|
+
# Semantic drift warnings. These are NOT numbers/names — they are the
|
|
345
|
+
# meaning-bearing words (negations, scope, conditionals) that the skill's
|
|
346
|
+
# de-hedging rules are most likely to delete and that pure fact-matching
|
|
347
|
+
# cannot see. Surfaced as warnings so the rewrite gets a human re-check;
|
|
348
|
+
# de-slopping legitimately removes some, so they don't hard-fail on their own.
|
|
349
|
+
warnings.extend(semantic_drift_warnings(original_text, transformed_text))
|
|
350
|
+
|
|
351
|
+
preserved = len(constraints) - len(missing)
|
|
352
|
+
|
|
353
|
+
return {
|
|
354
|
+
"passed": len(missing) == 0,
|
|
355
|
+
"total_constraints": len(constraints),
|
|
356
|
+
"preserved": preserved,
|
|
357
|
+
"missing": missing,
|
|
358
|
+
"warnings": warnings
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
|
|
362
|
+
def main() -> None:
|
|
363
|
+
args = sys.argv[1:]
|
|
364
|
+
strict = False
|
|
365
|
+
if args and args[0] == "--strict":
|
|
366
|
+
strict = True
|
|
367
|
+
args = args[1:]
|
|
368
|
+
|
|
369
|
+
if len(args) < 2:
|
|
370
|
+
print("Usage: validate_preservation.py <original.txt> <transformed.txt> [constraints.json]")
|
|
371
|
+
sys.exit(1)
|
|
372
|
+
|
|
373
|
+
# Read inputs
|
|
374
|
+
try:
|
|
375
|
+
with open(args[0], 'r') as f:
|
|
376
|
+
original_text = f.read()
|
|
377
|
+
with open(args[1], 'r') as f:
|
|
378
|
+
transformed_text = f.read()
|
|
379
|
+
except OSError as e:
|
|
380
|
+
print(json.dumps({"error": f"Could not read input: {e}"}))
|
|
381
|
+
sys.exit(2)
|
|
382
|
+
|
|
383
|
+
# Optionally read pre-computed constraints
|
|
384
|
+
constraints = None
|
|
385
|
+
if len(args) > 2:
|
|
386
|
+
with open(args[2], 'r') as f:
|
|
387
|
+
data = json.load(f)
|
|
388
|
+
constraints = data.get("constraints", [])
|
|
389
|
+
|
|
390
|
+
result = validate_preservation(original_text, transformed_text, constraints)
|
|
391
|
+
|
|
392
|
+
# Output result
|
|
393
|
+
output = {
|
|
394
|
+
"passed": result["passed"],
|
|
395
|
+
"total_constraints": result["total_constraints"],
|
|
396
|
+
"preserved": result["preserved"],
|
|
397
|
+
"missing_count": len(result["missing"]),
|
|
398
|
+
"missing": result["missing"],
|
|
399
|
+
"warnings": result["warnings"]
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
print(json.dumps(output, indent=2))
|
|
403
|
+
|
|
404
|
+
# Exit with appropriate code
|
|
405
|
+
sys.exit(0 if result["passed"] and not (strict and result["warnings"]) else 1)
|
|
406
|
+
|
|
407
|
+
|
|
408
|
+
if __name__ == "__main__":
|
|
409
|
+
main()
|