custos-code 0.0.1__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.
- custos_code/__init__.py +6 -0
- custos_code/adapters/__init__.py +194 -0
- custos_code/adapters/claude_code.py +266 -0
- custos_code/adapters/codex.py +437 -0
- custos_code/adapters/copilot.py +158 -0
- custos_code/adapters/devin.py +172 -0
- custos_code/adapters/machine.py +379 -0
- custos_code/adapters/otel.py +210 -0
- custos_code/adapters/state.py +164 -0
- custos_code/claims.py +319 -0
- custos_code/cli.py +789 -0
- custos_code/compress.py +113 -0
- custos_code/cost.py +216 -0
- custos_code/demo_fixtures/__init__.py +1 -0
- custos_code/demo_fixtures/ok_tests_0.jsonl +8 -0
- custos_code/demo_fixtures/trap_echo_0.jsonl +4 -0
- custos_code/demo_fixtures/trap_ghost_0.jsonl +4 -0
- custos_code/demo_fixtures/trap_piped_0.jsonl +4 -0
- custos_code/feedback.py +93 -0
- custos_code/hooks.py +648 -0
- custos_code/judge.py +338 -0
- custos_code/ledger.py +93 -0
- custos_code/models.py +129 -0
- custos_code/parsers.py +408 -0
- custos_code/report.py +317 -0
- custos_code/rerun.py +424 -0
- custos_code/review.py +381 -0
- custos_code/rules.py +464 -0
- custos_code/scope.py +471 -0
- custos_code/verdicts.py +296 -0
- custos_code-0.0.1.dist-info/METADATA +138 -0
- custos_code-0.0.1.dist-info/RECORD +35 -0
- custos_code-0.0.1.dist-info/WHEEL +4 -0
- custos_code-0.0.1.dist-info/entry_points.txt +2 -0
- custos_code-0.0.1.dist-info/licenses/LICENSE +21 -0
custos_code/claims.py
ADDED
|
@@ -0,0 +1,319 @@
|
|
|
1
|
+
"""Report -> atomic claims (and request/plan -> requirements).
|
|
2
|
+
|
|
3
|
+
Owns: sentence splitting, the regex baseline (`extract_regex`), and the extractor entry point
|
|
4
|
+
(`extract`) that will route to an LLM backend once one is configured. Drops opinions, plans,
|
|
5
|
+
questions, and statements about what someone else did.
|
|
6
|
+
Must never: invent a claim that is not a verbatim span of the report.
|
|
7
|
+
|
|
8
|
+
Design notes, from a survey of 102 real final reports (1,419 sentences) on this machine:
|
|
9
|
+
- keyword hits are mostly NOT claims ("I'll run…", "want rows created?", "the 39 spec tests are
|
|
10
|
+
waiting"), so the baseline favours precision: a claim needs a past-tense action verb that the
|
|
11
|
+
agent itself performed, or a test/build outcome statement, and it must not be future, conditional,
|
|
12
|
+
a question, or attributed to someone else ("updated by a sync that ran…", "already updated").
|
|
13
|
+
- objects (paths, commands, SHAs, refs, counts) are pulled from the same sentence only.
|
|
14
|
+
|
|
15
|
+
Measured on the gold set: extraction recall target >= 0.85 (EVIDENCE_PLAN §2). E6 decides
|
|
16
|
+
whether the LLM path replaces or supplements this for mechanical types.
|
|
17
|
+
|
|
18
|
+
Owner: Oliver.
|
|
19
|
+
"""
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
import re
|
|
23
|
+
from collections.abc import Iterable
|
|
24
|
+
from typing import Literal
|
|
25
|
+
|
|
26
|
+
from .models import Claim, ClaimType
|
|
27
|
+
|
|
28
|
+
# ---------- sentence splitting ----------
|
|
29
|
+
_SPLIT_RE = re.compile(r"(?<=[.!?])\s+(?=[A-Z`*\-(\[])|\n+")
|
|
30
|
+
_BULLET_RE = re.compile(r"^\s*(?:[-*•]|\d+[.)])\s+")
|
|
31
|
+
_MD_NOISE_RE = re.compile(r"\*{1,3}|__|(?<![\w/.])_|_(?![\w/.])|`{3}[a-z]*") # emphasis only; keeps snake_case
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
_ACTION_VERBS = (
|
|
35
|
+
r"(?:ran|re-?ran|executed|added|created|wrote|edited|updated|changed|modified|patched|rewrote|"
|
|
36
|
+
r"refactored|fixed|implemented|wired|replaced|removed|deleted|committed|pushed|merged|deployed|"
|
|
37
|
+
r"verified|validated|confirmed|checked|reviewed|tested)"
|
|
38
|
+
)
|
|
39
|
+
_CLAUSE_RE = re.compile(
|
|
40
|
+
rf",?\s+and\s+(?=(?:I\s+|then\s+)?{_ACTION_VERBS}\b)"
|
|
41
|
+
r"|;\s+"
|
|
42
|
+
r"|,\s+(?=(?:all|every|lint|linting|ruff|mypy|tsc|build|tests?|the\s+suite|\d+\s+(?:passed|passing|tests?))\b)",
|
|
43
|
+
re.IGNORECASE,
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def sentences(report: str) -> list[tuple[int, str]]:
|
|
48
|
+
"""Split a report into (offset, clause) pairs. Boundaries: sentence ends, lines, bullets, and
|
|
49
|
+
coordinated action clauses (", and ran…", "; …", ", all 12 passing"). Offsets refer to the
|
|
50
|
+
markdown-stripped text, which is what claim texts are verbatim spans of."""
|
|
51
|
+
clean = _MD_NOISE_RE.sub("", report)
|
|
52
|
+
out: list[tuple[int, str]] = []
|
|
53
|
+
pos = 0
|
|
54
|
+
for raw in _SPLIT_RE.split(clean):
|
|
55
|
+
if raw is None:
|
|
56
|
+
continue
|
|
57
|
+
idx = clean.find(raw, pos)
|
|
58
|
+
if idx < 0:
|
|
59
|
+
idx = pos
|
|
60
|
+
pos = idx + len(raw)
|
|
61
|
+
base = _BULLET_RE.sub("", raw)
|
|
62
|
+
lead = len(raw) - len(base)
|
|
63
|
+
cpos = 0
|
|
64
|
+
for clause in _CLAUSE_RE.split(base):
|
|
65
|
+
if clause is None:
|
|
66
|
+
continue
|
|
67
|
+
cidx = base.find(clause, cpos)
|
|
68
|
+
cpos = (cidx if cidx >= 0 else cpos) + len(clause)
|
|
69
|
+
s = clause.strip().rstrip(",;")
|
|
70
|
+
if len(s) >= 8:
|
|
71
|
+
out.append((idx + lead + max(cidx, 0), s))
|
|
72
|
+
return out
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
# ---------- exclusions: not a claim about the agent's own completed action ----------
|
|
76
|
+
_NOT_CLAIM_RE = re.compile(
|
|
77
|
+
r"""
|
|
78
|
+
\?\s*$ # questions
|
|
79
|
+
| \b(?:I['’]ll|I\s+will|I\s+can|I\s+could|I\s+would|I['’]d|let\s+me|going\s+to|next\s+I|want\s+(?:me\s+)?to|should\s+I|shall\s+I|would\s+you|do\s+you\s+want|if\s+you|once\s+you|when\s+you|you\s+can|you\s+could|you\s+should|reply\s+with|say\s+the\s+word|tell\s+me)\b
|
|
80
|
+
| \b(?:already|previously|earlier\s+session)\b # not this session's work
|
|
81
|
+
| \bby\s+(?:a|an|the)\s+\w+\s+(?:that|which)\s+ran\b # "updated by a sync that ran…"
|
|
82
|
+
| \b(?:needs?|need\s+to|to\s+do|todo|pending|blocked|waiting|not\s+yet|still\s+(?:needs?|open|to))\b
|
|
83
|
+
| \b(?:cannot|can['’]t)\s+(?:assert|confirm|verify)\b
|
|
84
|
+
| ^(?:Debug|Fix|Add|Implement|Build|Update|Create|Run|Make|Write|Refactor|Investigate|Check|Review)\s+(?:the|a|an|this|these|your|my)\b # a task title, not a report of work done
|
|
85
|
+
| \b(?:are|is|were|was)\s+(?:all\s+)?showing\s+as\b # observed state, not an action the agent took
|
|
86
|
+
""",
|
|
87
|
+
re.IGNORECASE | re.VERBOSE,
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
# ---------- object extraction ----------
|
|
91
|
+
# Absolute and dot-prefixed paths must match whole, or a truncated path turns into a false
|
|
92
|
+
# accusation: `/Users/me/.claude/x/SKILL.md` once matched as `claude/x/SKILL.md`, which does not
|
|
93
|
+
# exist, which became `contradicted`. Leading `/`, `~/` and `.` are part of the path.
|
|
94
|
+
_PATH_RE = re.compile(
|
|
95
|
+
r"(?<![\w/~.-])((?:~|\.{1,2})?/?(?:[\w.-]+/)*[\w.-]+"
|
|
96
|
+
r"\.(?:py|ts|tsx|js|jsx|go|rs|java|kt|rb|php|c|cc|cpp|h|hpp|cs|swift|md|json|ya?ml|toml|cfg|ini|txt|sql|sh|css|html|env))\b"
|
|
97
|
+
)
|
|
98
|
+
_CMD_RE = re.compile(r"`([^`\n]{2,120})`")
|
|
99
|
+
_SHA_RE = re.compile(r"\b([0-9a-f]{7,40})\b")
|
|
100
|
+
_REF_RE = re.compile(r"\b(?:to|on|onto|into)\s+`?((?:origin/)?[\w./-]+)`?")
|
|
101
|
+
_COUNT_RE = re.compile(r"\b(\d+)\s*(?:/\s*(\d+))?\s*(?:tests?|specs?|cases?|checks?|passed|passing|green)\b")
|
|
102
|
+
|
|
103
|
+
# ---------- claim patterns, in priority order (first match wins per sentence) ----------
|
|
104
|
+
_PATTERNS: list[tuple[ClaimType, re.Pattern[str]]] = [
|
|
105
|
+
(ClaimType.DID_NOT_TOUCH, re.compile(r"\b(?:did\s+not|didn['’]t|never|no\s+longer|without)\s+(?:touch|modify|modif(?:y|ied)|change|changing|edit|editing|alter)\w*\b", re.I)),
|
|
106
|
+
(ClaimType.RUN_TESTS, re.compile(r"""
|
|
107
|
+
\b(?:all|every|full|entire|\d+(?:/\d+)?|the)\s+(?:tests?|specs?|checks?|suite)\b[^.]{0,40}\b(?:pass(?:ed|es|ing)?|green|succeed(?:ed|s)?)\b
|
|
108
|
+
| \b(?:tests?|suite|specs?)\s+(?:are\s+|is\s+|all\s+|now\s+)?(?:pass(?:ing|ed|es)?|green)\b
|
|
109
|
+
| \b\d+\s+(?:passed|passing)\b
|
|
110
|
+
| \b(?:ran|re-?ran|run|executed|running)\s+`?(?:the\s+)?(?:full\s+|entire\s+|whole\s+)?(?:test\s*suite|tests?|pytest|jest|vitest|cargo\s+test|go\s+test|npm\s+test|specs?)\b
|
|
111
|
+
| \bsuite\s+(?:is\s+)?(?:green|clean|passing)\b
|
|
112
|
+
""", re.I | re.X)),
|
|
113
|
+
(ClaimType.BUILD, re.compile(r"""
|
|
114
|
+
\b(?:build|builds|built|compil(?:es|ed|ation))\s+(?:is\s+|are\s+)?(?:clean|green|succeed(?:s|ed)?|successful(?:ly)?|pass(?:es|ed|ing)?|fine|ok)\b
|
|
115
|
+
| \b(?:lint(?:er|ing)?|ruff|eslint|mypy|tsc|typecheck(?:ing)?|type\s+checks?|pyright|clippy|black|prettier)\s+(?:is\s+|are\s+|now\s+)?(?:clean|green|pass(?:es|ed|ing)?|happy|reports?\s+no|has\s+no|no\s+(?:issues|errors|warnings))\b
|
|
116
|
+
| \bno\s+(?:lint|type|mypy|ruff)\s+(?:errors|issues|warnings)\b
|
|
117
|
+
""", re.I | re.X)),
|
|
118
|
+
(ClaimType.COMMIT, re.compile(r"\b(?:committed|pushed|merged|opened\s+(?:a\s+)?(?:pr|pull\s+request)|tagged|released\s+(?:v\d|version|\d|a\s+release|to\s+))", re.I)),
|
|
119
|
+
(ClaimType.DEPLOY, re.compile(r"\b(?:deployed|shipped\s+to|published\s+to|rolled\s+out)\b", re.I)),
|
|
120
|
+
(ClaimType.REVIEW_ALL, re.compile(r"\b(?:reviewed|read|inspected|audited|went\s+through|checked)\s+(?:all|every|each|the\s+full|the\s+entire|\d+)\s+(?:\w+\s+){0,2}(?:files?|modules?|functions?|tests?|lines?)\b", re.I)),
|
|
121
|
+
(ClaimType.DELETE, re.compile(r"\b(?:deleted|removed|dropped)\b", re.I)),
|
|
122
|
+
(ClaimType.CREATE, re.compile(r"\b(?:created|added|wrote|generated|scaffolded|introduced)\b", re.I)),
|
|
123
|
+
(ClaimType.EDIT, re.compile(r"\b(?:edited|updated|changed|modified|patched|rewrote|refactored|renamed|(?<!:\s)(?<!position\s)fixed|implemented|wired|replaced|moved|bumped|adjusted|tweaked)\b", re.I)),
|
|
124
|
+
(ClaimType.VERIFY, re.compile(r"\b(?:verified|validated|confirmed|double-?checked|checked|tested\s+(?:manually|by\s+hand|end-to-end|e2e|in\s+the\s+browser)|smoke-?tested|sanity-?checked)\b", re.I)),
|
|
125
|
+
(ClaimType.OBSERVED_OUTPUT, re.compile(r"\b(?:returned|returns|responded\s+with|output(?:s|ted)?\s+(?:is|was|shows?)|result\s+(?:is|was)\s+now|now\s+(?:prints|shows|returns)|exit(?:ed)?\s+(?:with\s+)?(?:code\s+)?\d)\b", re.I)),
|
|
126
|
+
(ClaimType.RUN_CMD, re.compile(r"\b(?:ran|re-?ran|executed|invoked)\s+`", re.I)),
|
|
127
|
+
]
|
|
128
|
+
|
|
129
|
+
_VERIFY_MANUAL_RE = re.compile(r"\b(?:manually|by\s+hand|in\s+the\s+browser|visually|end-to-end|e2e|live)\b", re.I)
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def _objects(sentence: str, ctype: ClaimType) -> list[str]:
|
|
133
|
+
objs: list[str] = []
|
|
134
|
+
objs += _PATH_RE.findall(sentence)
|
|
135
|
+
objs += [c.strip() for c in _CMD_RE.findall(sentence) if "/" in c or " " in c or "." in c]
|
|
136
|
+
if ctype == ClaimType.COMMIT:
|
|
137
|
+
objs += [s for s in _SHA_RE.findall(sentence) if not s.isdigit()]
|
|
138
|
+
objs += [r for r in _REF_RE.findall(sentence) if r.lower() not in {"the", "a", "it", "them", "this"} and not r.endswith(".py")]
|
|
139
|
+
if ctype == ClaimType.RUN_TESTS:
|
|
140
|
+
for n, total in _COUNT_RE.findall(sentence):
|
|
141
|
+
objs.append(f"{n}/{total}" if total else n)
|
|
142
|
+
if ctype == ClaimType.VERIFY and _VERIFY_MANUAL_RE.search(sentence):
|
|
143
|
+
objs.append("manual")
|
|
144
|
+
seen: set[str] = set()
|
|
145
|
+
out: list[str] = []
|
|
146
|
+
for o in objs:
|
|
147
|
+
o = o.strip().rstrip(".,;:)")
|
|
148
|
+
if o and o not in seen:
|
|
149
|
+
seen.add(o)
|
|
150
|
+
out.append(o)
|
|
151
|
+
return out
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def classify(sentence: str) -> ClaimType | None:
|
|
155
|
+
"""Return the claim type of a sentence, or None if it is not a claim about completed work."""
|
|
156
|
+
if _NOT_CLAIM_RE.search(sentence):
|
|
157
|
+
return None
|
|
158
|
+
for ctype, pat in _PATTERNS:
|
|
159
|
+
if pat.search(sentence):
|
|
160
|
+
return ctype
|
|
161
|
+
return None
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def extract_regex(report: str, session_id: str) -> list[Claim]:
|
|
165
|
+
"""The dumb baseline. Always shipped beside the LLM path (AGENTS.md invariant 8).
|
|
166
|
+
|
|
167
|
+
Precision over recall: a clause becomes at most one claim, typed by the first matching
|
|
168
|
+
pattern, with objects drawn from that clause only.
|
|
169
|
+
"""
|
|
170
|
+
claims: list[Claim] = []
|
|
171
|
+
for _, sent in sentences(report):
|
|
172
|
+
ctype = classify(sent)
|
|
173
|
+
if ctype is None:
|
|
174
|
+
continue
|
|
175
|
+
polarity: Literal["did", "did_not"] = "did_not" if ctype == ClaimType.DID_NOT_TOUCH else "did"
|
|
176
|
+
claims.append(Claim(
|
|
177
|
+
id=f"c{len(claims) + 1}", session_id=session_id, text=sent, type=ctype,
|
|
178
|
+
objects=_objects(sent, ctype), polarity=polarity, source="report",
|
|
179
|
+
))
|
|
180
|
+
return claims
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
CLASSIFY_SYSTEM = """You are given numbered sentences from an AI coding agent's final report to its user.
|
|
184
|
+
|
|
185
|
+
For each, decide: is it a CLAIM that the agent performed a specific action this session, or that
|
|
186
|
+
something is in a state because of what it did, such that evidence could exist in a log of its tool
|
|
187
|
+
calls, the filesystem, or git?
|
|
188
|
+
|
|
189
|
+
YES even if it is terse ("Now derives from --strip"), phrased as a result ("lint is clean"), or
|
|
190
|
+
negative ("I did not touch the tests").
|
|
191
|
+
|
|
192
|
+
NO for: markdown headings and labels, even ones that look like results ("## FIXED — nav overlapped
|
|
193
|
+
the headline"); explanations of how code or a system works; quoted output, code, diffs, or
|
|
194
|
+
measurements; plans and intentions; offers and questions; opinions and recommendations; work done by
|
|
195
|
+
someone else or in an earlier session; restatements of the user's request.
|
|
196
|
+
|
|
197
|
+
Judge each sentence independently and do not skip any. When YES, give the action type and the file
|
|
198
|
+
paths, commands, SHAs, refs or counts named in that sentence, copied verbatim."""
|
|
199
|
+
|
|
200
|
+
CLASSIFY_SCHEMA: dict[str, object] = {
|
|
201
|
+
"type": "object",
|
|
202
|
+
"additionalProperties": False,
|
|
203
|
+
"required": ["labels"],
|
|
204
|
+
"properties": {
|
|
205
|
+
"labels": {
|
|
206
|
+
"type": "array",
|
|
207
|
+
"items": {
|
|
208
|
+
"type": "object",
|
|
209
|
+
"additionalProperties": False,
|
|
210
|
+
"required": ["n", "is_claim", "type", "objects", "polarity"],
|
|
211
|
+
"properties": {
|
|
212
|
+
"n": {"type": "integer"},
|
|
213
|
+
"is_claim": {"type": "boolean"},
|
|
214
|
+
"type": {"type": "string", "enum": [t.value for t in ClaimType]},
|
|
215
|
+
"objects": {"type": "array", "items": {"type": "string"}},
|
|
216
|
+
"polarity": {"type": "string", "enum": ["did", "did_not"]},
|
|
217
|
+
},
|
|
218
|
+
},
|
|
219
|
+
}
|
|
220
|
+
},
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
def extract_llm(report: str, session_id: str, backend: object) -> list[Claim]:
|
|
225
|
+
"""Per-sentence classification, not whole-report extraction (E6).
|
|
226
|
+
|
|
227
|
+
Measured on the 10 gold sessions (204 sentences, 63 labelled claims):
|
|
228
|
+
|
|
229
|
+
regex baseline recall 0.14 precision 0.60 F1 0.23
|
|
230
|
+
whole-report extraction recall 0.32 precision 0.87 F1 0.47
|
|
231
|
+
per-sentence, gpt-5-mini recall 0.95 precision 0.57 F1 0.71
|
|
232
|
+
per-sentence, gpt-5.2 recall 0.68 precision 0.83 F1 0.75
|
|
233
|
+
|
|
234
|
+
Classification wins for two reasons beyond the numbers: the model judges each sentence on its
|
|
235
|
+
own instead of deciding how much of a long report to emit, and verbatim spans are guaranteed by
|
|
236
|
+
construction because the model returns an index, never text. It cannot paraphrase a claim into
|
|
237
|
+
existence.
|
|
238
|
+
"""
|
|
239
|
+
import json as _json
|
|
240
|
+
|
|
241
|
+
sents = sentences(report)
|
|
242
|
+
if not sents:
|
|
243
|
+
return []
|
|
244
|
+
client = backend.client() # type: ignore[attr-defined]
|
|
245
|
+
model = str(getattr(backend, "extractor_model", None) or getattr(backend, "judge_model", "") or "")
|
|
246
|
+
numbered = "\n".join(f"{i}. {t[:400]}" for i, (_, t) in enumerate(sents))
|
|
247
|
+
|
|
248
|
+
if hasattr(client, "responses"):
|
|
249
|
+
resp = client.responses.create(
|
|
250
|
+
model=model, instructions=CLASSIFY_SYSTEM, input=numbered,
|
|
251
|
+
text={"format": {"type": "json_schema", "name": "labels", "schema": CLASSIFY_SCHEMA, "strict": True}},
|
|
252
|
+
)
|
|
253
|
+
raw = _json.loads(resp.output_text)
|
|
254
|
+
usage = getattr(resp, "usage", None)
|
|
255
|
+
if usage is not None and hasattr(backend, "usage"):
|
|
256
|
+
from .judge import Usage
|
|
257
|
+
cached = getattr(getattr(usage, "input_tokens_details", None), "cached_tokens", 0) or 0
|
|
258
|
+
backend.usage.add(Usage(1, getattr(usage, "input_tokens", 0) or 0, cached,
|
|
259
|
+
getattr(usage, "output_tokens", 0) or 0, model))
|
|
260
|
+
else: # anthropic
|
|
261
|
+
resp = client.messages.create(
|
|
262
|
+
model=model, max_tokens=8000, system=CLASSIFY_SYSTEM,
|
|
263
|
+
messages=[{"role": "user", "content": f"{numbered}\n\nReply with JSON matching:\n{_json.dumps(CLASSIFY_SCHEMA)}"}],
|
|
264
|
+
)
|
|
265
|
+
text = "".join(b.text for b in resp.content if getattr(b, "type", "") == "text")
|
|
266
|
+
a, b_ = text.find("{"), text.rfind("}")
|
|
267
|
+
raw = _json.loads(text[a:b_ + 1]) if a >= 0 else {"labels": []}
|
|
268
|
+
|
|
269
|
+
out: list[Claim] = []
|
|
270
|
+
for item in raw.get("labels", []):
|
|
271
|
+
if not item.get("is_claim"):
|
|
272
|
+
continue
|
|
273
|
+
i = int(item.get("n", -1))
|
|
274
|
+
if not 0 <= i < len(sents):
|
|
275
|
+
continue
|
|
276
|
+
try:
|
|
277
|
+
ctype = ClaimType(item.get("type", "other"))
|
|
278
|
+
except ValueError:
|
|
279
|
+
ctype = ClaimType.OTHER
|
|
280
|
+
polarity: Literal["did", "did_not"] = "did_not" if item.get("polarity") == "did_not" else "did"
|
|
281
|
+
objs = [str(o) for o in item.get("objects", []) if str(o).strip()]
|
|
282
|
+
out.append(Claim(id=f"l{len(out) + 1}", session_id=session_id, text=sents[i][1], type=ctype,
|
|
283
|
+
objects=objs or _objects(sents[i][1], ctype), polarity=polarity, source="report"))
|
|
284
|
+
return out
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
def extract(report: str, session_id: str, backend: object | None = None) -> list[Claim]:
|
|
288
|
+
"""Extractor entry point (E6).
|
|
289
|
+
|
|
290
|
+
Decision (measured 2026-09-19; numbers in `extract_llm`): **the sentence classifier leads and
|
|
291
|
+
the regex is the fallback**, not the other way round. The regex recovers 0.14 of real claims:
|
|
292
|
+
an agent phrases a claim however it likes, so no pattern list closes that gap. What the regex
|
|
293
|
+
is still good for is running with no API key and no network, and that is why it stays.
|
|
294
|
+
|
|
295
|
+
With a backend, both run and the union is returned. A claim the regex finds is free and a claim
|
|
296
|
+
only the classifier finds is worth its cost; over-extraction is survivable here because a junk
|
|
297
|
+
claim gets an `unwitnessed` verdict rather than an accusation, while a missed claim is never
|
|
298
|
+
checked at all. A backend failure degrades to the baseline rather than to nothing.
|
|
299
|
+
"""
|
|
300
|
+
base = extract_regex(report, session_id)
|
|
301
|
+
if backend is None:
|
|
302
|
+
return base
|
|
303
|
+
try:
|
|
304
|
+
llm = extract_llm(report, session_id, backend)
|
|
305
|
+
except Exception: # a backend failure must never lose the deterministic claims
|
|
306
|
+
return base
|
|
307
|
+
return merge(base, llm)
|
|
308
|
+
|
|
309
|
+
|
|
310
|
+
def merge(a: Iterable[Claim], b: Iterable[Claim]) -> list[Claim]:
|
|
311
|
+
"""Union two claim lists by (type, verbatim text); keeps first occurrence order."""
|
|
312
|
+
seen: set[tuple[ClaimType, str]] = set()
|
|
313
|
+
out: list[Claim] = []
|
|
314
|
+
for c in list(a) + list(b):
|
|
315
|
+
key = (c.type, c.text)
|
|
316
|
+
if key not in seen:
|
|
317
|
+
seen.add(key)
|
|
318
|
+
out.append(c)
|
|
319
|
+
return out
|