second-read 0.1.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.
- second_read-0.1.1.dist-info/METADATA +243 -0
- second_read-0.1.1.dist-info/RECORD +95 -0
- second_read-0.1.1.dist-info/WHEEL +4 -0
- second_read-0.1.1.dist-info/entry_points.txt +4 -0
- second_read-0.1.1.dist-info/licenses/LICENSE +21 -0
- secondread/__init__.py +0 -0
- secondread/agents/__init__.py +0 -0
- secondread/agents/copyedit/__init__.py +10 -0
- secondread/agents/copyedit/agent.py +261 -0
- secondread/agents/copyedit/prompts.py +123 -0
- secondread/agents/copyedit/text.py +274 -0
- secondread/agents/devils_advocate/__init__.py +18 -0
- secondread/agents/devils_advocate/agent.py +681 -0
- secondread/agents/devils_advocate/prompts.py +263 -0
- secondread/agents/devils_advocate/schemas.py +86 -0
- secondread/agents/devils_advocate/text.py +123 -0
- secondread/agents/extractor/__init__.py +20 -0
- secondread/agents/extractor/agent.py +330 -0
- secondread/agents/extractor/chunks.py +53 -0
- secondread/agents/extractor/prompts.py +121 -0
- secondread/agents/extractor/spans.py +177 -0
- secondread/agents/factcheck/__init__.py +12 -0
- secondread/agents/factcheck/agent.py +267 -0
- secondread/agents/factcheck/prompts.py +264 -0
- secondread/agents/factcheck/research.py +737 -0
- secondread/agents/factcheck/schemas.py +124 -0
- secondread/agents/factcheck/sources.py +295 -0
- secondread/agents/originality/__init__.py +29 -0
- secondread/agents/originality/agent.py +277 -0
- secondread/agents/originality/sentences.py +311 -0
- secondread/agents/originality/similarity.py +114 -0
- secondread/agents/structure/__init__.py +25 -0
- secondread/agents/structure/agent.py +301 -0
- secondread/agents/structure/prompts.py +179 -0
- secondread/cli/__init__.py +5 -0
- secondread/cli/__main__.py +6 -0
- secondread/cli/envfile.py +80 -0
- secondread/cli/errors.py +114 -0
- secondread/cli/main.py +327 -0
- secondread/contracts/__init__.py +134 -0
- secondread/contracts/errors.py +60 -0
- secondread/contracts/interfaces.py +314 -0
- secondread/contracts/models.py +689 -0
- secondread/mcp_local/README.md +96 -0
- secondread/mcp_local/__init__.py +35 -0
- secondread/mcp_local/__main__.py +6 -0
- secondread/mcp_local/demo.py +111 -0
- secondread/mcp_local/errors.py +107 -0
- secondread/mcp_local/main.py +80 -0
- secondread/mcp_local/progress.py +86 -0
- secondread/mcp_local/server.py +187 -0
- secondread/mcp_local/wiring.py +251 -0
- secondread/orchestrator/__init__.py +54 -0
- secondread/orchestrator/assemble.py +148 -0
- secondread/orchestrator/authwatch.py +83 -0
- secondread/orchestrator/classify.py +75 -0
- secondread/orchestrator/conflicts.py +406 -0
- secondread/orchestrator/labels.py +39 -0
- secondread/orchestrator/orchestrator.py +601 -0
- secondread/orchestrator/planning.py +221 -0
- secondread/orchestrator/progress.py +114 -0
- secondread/orchestrator/prompts.py +132 -0
- secondread/orchestrator/reactions.py +153 -0
- secondread/orchestrator/registry.py +56 -0
- secondread/orchestrator/schemas.py +45 -0
- secondread/pipeline.py +73 -0
- secondread/providers/__init__.py +0 -0
- secondread/providers/fetch/__init__.py +29 -0
- secondread/providers/fetch/extract.py +153 -0
- secondread/providers/fetch/fetcher.py +241 -0
- secondread/providers/fetch/guarded.py +110 -0
- secondread/providers/fetch/ssrf.py +207 -0
- secondread/providers/llm/__init__.py +49 -0
- secondread/providers/llm/_common.py +259 -0
- secondread/providers/llm/anthropic_client.py +332 -0
- secondread/providers/llm/config.py +93 -0
- secondread/providers/llm/openai_client.py +229 -0
- secondread/providers/search/__init__.py +45 -0
- secondread/providers/search/base.py +218 -0
- secondread/providers/search/brave.py +55 -0
- secondread/providers/search/config.py +63 -0
- secondread/providers/search/exa.py +62 -0
- secondread/providers/search/retry.py +60 -0
- secondread/providers/search/tavily.py +60 -0
- secondread/registry.py +270 -0
- secondread/report/__init__.py +30 -0
- secondread/report/email.py +43 -0
- secondread/report/html.py +49 -0
- secondread/report/markdown.py +195 -0
- secondread/report/templates/email.html.j2 +139 -0
- secondread/report/text.py +158 -0
- secondread/report/view.py +408 -0
- secondread/testing/__init__.py +1 -0
- secondread/testing/fakes.py +458 -0
- secondread/testing/samples.py +348 -0
|
@@ -0,0 +1,681 @@
|
|
|
1
|
+
"""The devil's advocate agent.
|
|
2
|
+
|
|
3
|
+
Builds the strongest opposing case against the thesis and supporting claims
|
|
4
|
+
from retrieved counter-evidence.
|
|
5
|
+
|
|
6
|
+
Flow of ``run``:
|
|
7
|
+
|
|
8
|
+
1. Pick targets: thesis and supporting claims from the ledger, most important
|
|
9
|
+
first (plan deep-research claims first when a plan exists), at most
|
|
10
|
+
``max_claims``.
|
|
11
|
+
2. For each target run a bounded research loop (hard cap 3 iterations):
|
|
12
|
+
plan queries → search → fetch the best fresh hit → find-in-page → assess
|
|
13
|
+
(quote counter-evidence, then stop / refine / follow a link). Every step is
|
|
14
|
+
returned as an ``AddResearchStep``. Quoted excerpts are checked against
|
|
15
|
+
the retrieved text, so the model cannot invent citations.
|
|
16
|
+
3. One ``rebut`` call turns the gathered evidence into ``Rebuttal``s. Target
|
|
17
|
+
claim IDs are repaired against the ledger (case, brackets, claim text, or
|
|
18
|
+
the claim the cited evidence was gathered for); rebuttals with no valid
|
|
19
|
+
target are dropped, so every emitted rebuttal references at least one
|
|
20
|
+
real claim. Strength is clamped to 1–5.
|
|
21
|
+
4. Each rebuttal becomes an ``AddRebuttal`` update plus a ``Finding``:
|
|
22
|
+
``Severity.STRONG_REBUTTAL`` when it cites evidence, ``Severity.CONSIDER``
|
|
23
|
+
when it does not (the design doc's "no retrieved source → consider" rule,
|
|
24
|
+
applied here explicitly and again by the orchestrator). Evidence-free
|
|
25
|
+
rebuttals are kept, with empty ``evidence``, so the orchestrator can
|
|
26
|
+
downgrade them.
|
|
27
|
+
|
|
28
|
+
Failures never raise: ``ProviderError`` and ``BudgetExceeded`` end the run
|
|
29
|
+
with ``AgentResult.error`` set and whatever partial output exists. A search
|
|
30
|
+
outage or an exhausted search budget stops research but still lets the agent
|
|
31
|
+
write (evidence-free) rebuttals if tokens remain.
|
|
32
|
+
"""
|
|
33
|
+
|
|
34
|
+
from __future__ import annotations
|
|
35
|
+
|
|
36
|
+
import contextlib
|
|
37
|
+
import time
|
|
38
|
+
from collections import deque
|
|
39
|
+
from dataclasses import dataclass, field
|
|
40
|
+
from datetime import datetime
|
|
41
|
+
from typing import TypeVar
|
|
42
|
+
|
|
43
|
+
from pydantic import BaseModel
|
|
44
|
+
|
|
45
|
+
from secondread.agents.devils_advocate import prompts
|
|
46
|
+
from secondread.agents.devils_advocate.prompts import EvidenceView, SourceView
|
|
47
|
+
from secondread.agents.devils_advocate.schemas import (
|
|
48
|
+
Angle,
|
|
49
|
+
Assessment,
|
|
50
|
+
DebateReply,
|
|
51
|
+
NextAction,
|
|
52
|
+
RebuttalDraft,
|
|
53
|
+
RebuttalSet,
|
|
54
|
+
ResearchPlan,
|
|
55
|
+
)
|
|
56
|
+
from secondread.agents.devils_advocate.text import (
|
|
57
|
+
domain,
|
|
58
|
+
find_in_page,
|
|
59
|
+
keywords,
|
|
60
|
+
locate_excerpt,
|
|
61
|
+
normalize,
|
|
62
|
+
urls_in,
|
|
63
|
+
)
|
|
64
|
+
from secondread.contracts import (
|
|
65
|
+
MAX_RESEARCH_ITERATIONS,
|
|
66
|
+
AddRebuttal,
|
|
67
|
+
AddResearchStep,
|
|
68
|
+
AgentName,
|
|
69
|
+
AgentResult,
|
|
70
|
+
BudgetExceeded,
|
|
71
|
+
Claim,
|
|
72
|
+
ClaimBudget,
|
|
73
|
+
ClaimType,
|
|
74
|
+
DebatePosition,
|
|
75
|
+
Evidence,
|
|
76
|
+
FetchedPage,
|
|
77
|
+
FetchError,
|
|
78
|
+
Finding,
|
|
79
|
+
LedgerUpdate,
|
|
80
|
+
Message,
|
|
81
|
+
ModelTier,
|
|
82
|
+
ProgressEvent,
|
|
83
|
+
ProviderError,
|
|
84
|
+
Rebuttal,
|
|
85
|
+
ResearchAction,
|
|
86
|
+
ResearchStep,
|
|
87
|
+
ReviewContext,
|
|
88
|
+
SchemaError,
|
|
89
|
+
SearchResult,
|
|
90
|
+
SecondReadError,
|
|
91
|
+
Severity,
|
|
92
|
+
Usage,
|
|
93
|
+
new_id,
|
|
94
|
+
utcnow,
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
T = TypeVar("T", bound=BaseModel)
|
|
98
|
+
|
|
99
|
+
NAME = AgentName.DEVILS_ADVOCATE.value
|
|
100
|
+
|
|
101
|
+
_ANGLE_PREFIX = {
|
|
102
|
+
Angle.COUNTER_EVIDENCE: "Counter-argument: ",
|
|
103
|
+
Angle.ALTERNATIVE: "Alternative not considered: ",
|
|
104
|
+
Angle.ASSUMPTION: "Unstated assumption: ",
|
|
105
|
+
Angle.FAILURE_MODE: "Unhandled failure mode: ",
|
|
106
|
+
}
|
|
107
|
+
_NO_EVIDENCE_NOTE = " (No retrieved source supports this; treat it as a point to consider.)"
|
|
108
|
+
_SUGGESTION = "Acknowledge and answer this objection, or narrow the claim."
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def rebuttal_severity(rebuttal: Rebuttal) -> Severity:
|
|
112
|
+
"""Severity of the finding for ``rebuttal``.
|
|
113
|
+
|
|
114
|
+
``STRONG_REBUTTAL`` when it cites retrieved evidence, otherwise
|
|
115
|
+
``CONSIDER`` (evidence-free rebuttals are marked for downgrade).
|
|
116
|
+
"""
|
|
117
|
+
return Severity.STRONG_REBUTTAL if rebuttal.has_evidence else Severity.CONSIDER
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
class _SearchUnavailable(SecondReadError):
|
|
121
|
+
"""The search provider failed; research stops but rebuttals may continue."""
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
@dataclass
|
|
125
|
+
class _Gathered:
|
|
126
|
+
label: str
|
|
127
|
+
evidence: Evidence
|
|
128
|
+
claim_id: str
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
@dataclass
|
|
132
|
+
class _Source:
|
|
133
|
+
view: SourceView
|
|
134
|
+
full_text: str
|
|
135
|
+
retrieved_at: datetime | None = None
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
@dataclass
|
|
139
|
+
class _Session:
|
|
140
|
+
"""Per-call state: charges the shared meter and tracks this agent's usage."""
|
|
141
|
+
|
|
142
|
+
ctx: ReviewContext
|
|
143
|
+
usage: Usage = field(default_factory=Usage)
|
|
144
|
+
started: float = field(default_factory=time.monotonic)
|
|
145
|
+
updates: list[LedgerUpdate] = field(default_factory=list)
|
|
146
|
+
gathered: list[_Gathered] = field(default_factory=list)
|
|
147
|
+
|
|
148
|
+
def charge(self, usage: Usage) -> None:
|
|
149
|
+
self.usage = self.usage + usage
|
|
150
|
+
self.ctx.meter.charge(usage)
|
|
151
|
+
|
|
152
|
+
async def llm(
|
|
153
|
+
self, tag: str, messages: list[Message], schema: type[T], *, max_tokens: int = 1_500
|
|
154
|
+
) -> T:
|
|
155
|
+
estimate = sum(len(m.content) for m in messages) // 4 + 1
|
|
156
|
+
self.ctx.meter.require_tokens(estimate)
|
|
157
|
+
response = await self.ctx.llm.complete(
|
|
158
|
+
messages, schema=schema, model_tier=ModelTier.STRONG, tag=tag, max_tokens=max_tokens
|
|
159
|
+
)
|
|
160
|
+
self.charge(response.usage)
|
|
161
|
+
if not isinstance(response.parsed, schema):
|
|
162
|
+
raise SchemaError(f"{tag} returned no {schema.__name__}")
|
|
163
|
+
return response.parsed
|
|
164
|
+
|
|
165
|
+
async def search(self, query: str, k: int) -> list[SearchResult]:
|
|
166
|
+
self.ctx.meter.require_search()
|
|
167
|
+
try:
|
|
168
|
+
hits = await self.ctx.search.search(query, k=k)
|
|
169
|
+
except ProviderError as exc:
|
|
170
|
+
raise _SearchUnavailable(f"search unavailable: {exc}") from exc
|
|
171
|
+
finally:
|
|
172
|
+
self.charge(Usage(search_calls=1))
|
|
173
|
+
return hits
|
|
174
|
+
|
|
175
|
+
async def fetch(self, url: str) -> FetchedPage | None:
|
|
176
|
+
self.ctx.meter.require_time()
|
|
177
|
+
try:
|
|
178
|
+
return await self.ctx.fetcher.fetch(url)
|
|
179
|
+
except (FetchError, ProviderError):
|
|
180
|
+
return None
|
|
181
|
+
finally:
|
|
182
|
+
self.charge(Usage(fetch_calls=1))
|
|
183
|
+
|
|
184
|
+
def step(
|
|
185
|
+
self,
|
|
186
|
+
claim_id: str,
|
|
187
|
+
iteration: int,
|
|
188
|
+
action: ResearchAction,
|
|
189
|
+
input: str,
|
|
190
|
+
summary: str = "",
|
|
191
|
+
urls: list[str] | None = None,
|
|
192
|
+
) -> None:
|
|
193
|
+
self.updates.append(
|
|
194
|
+
AddResearchStep(
|
|
195
|
+
claim_id=claim_id,
|
|
196
|
+
step=ResearchStep(
|
|
197
|
+
agent=NAME,
|
|
198
|
+
iteration=max(1, min(iteration, MAX_RESEARCH_ITERATIONS)),
|
|
199
|
+
action=action,
|
|
200
|
+
input=input[:500],
|
|
201
|
+
summary=summary[:500],
|
|
202
|
+
evidence_urls=list(urls or []),
|
|
203
|
+
),
|
|
204
|
+
)
|
|
205
|
+
)
|
|
206
|
+
|
|
207
|
+
def progress(self, message: str, percent: float) -> None:
|
|
208
|
+
with contextlib.suppress(Exception): # progress must never break the review
|
|
209
|
+
self.ctx.emit_progress(
|
|
210
|
+
ProgressEvent(step=NAME, message=message, percent=max(0.0, min(percent, 100.0)))
|
|
211
|
+
)
|
|
212
|
+
|
|
213
|
+
def final_usage(self) -> Usage:
|
|
214
|
+
return self.usage + Usage(seconds=time.monotonic() - self.started)
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def _clean_label(raw: str) -> str:
|
|
218
|
+
return raw.strip().strip("[]()").strip().upper()
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
def _error_text(exc: Exception) -> str:
|
|
222
|
+
return f"devil's advocate degraded: {type(exc).__name__}: {exc}"
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
class DevilsAdvocateAgent:
|
|
226
|
+
"""Devil's advocate: ``Agent`` and ``Debater``.
|
|
227
|
+
|
|
228
|
+
``max_claims`` caps how many thesis/supporting claims get researched,
|
|
229
|
+
``max_rebuttals`` how many rebuttals are kept, ``search_k`` the hits per
|
|
230
|
+
search, and ``snippet_sources`` how many extra hits (snippet only) are
|
|
231
|
+
shown to the model alongside the fetched page.
|
|
232
|
+
"""
|
|
233
|
+
|
|
234
|
+
def __init__(
|
|
235
|
+
self,
|
|
236
|
+
*,
|
|
237
|
+
max_claims: int = 5,
|
|
238
|
+
max_rebuttals: int = 6,
|
|
239
|
+
search_k: int = 5,
|
|
240
|
+
snippet_sources: int = 2,
|
|
241
|
+
) -> None:
|
|
242
|
+
self.max_claims = max_claims
|
|
243
|
+
self.max_rebuttals = max_rebuttals
|
|
244
|
+
self.search_k = search_k
|
|
245
|
+
self.snippet_sources = snippet_sources
|
|
246
|
+
|
|
247
|
+
@property
|
|
248
|
+
def name(self) -> str:
|
|
249
|
+
"""``AgentName.DEVILS_ADVOCATE``."""
|
|
250
|
+
return NAME
|
|
251
|
+
|
|
252
|
+
# ------------------------------------------------------------------ run
|
|
253
|
+
|
|
254
|
+
async def run(self, ctx: ReviewContext) -> AgentResult:
|
|
255
|
+
"""Research counter-evidence and return rebuttals, findings and trails."""
|
|
256
|
+
s = _Session(ctx)
|
|
257
|
+
targets = self._targets(ctx)
|
|
258
|
+
if not targets:
|
|
259
|
+
return AgentResult(agent=NAME, usage=s.final_usage())
|
|
260
|
+
|
|
261
|
+
error: str | None = None
|
|
262
|
+
for index, claim in enumerate(targets):
|
|
263
|
+
s.progress(
|
|
264
|
+
f"Devil's advocate: researching claim {index + 1} of {len(targets)}",
|
|
265
|
+
100.0 * index / (len(targets) + 1),
|
|
266
|
+
)
|
|
267
|
+
iterations, claim_budget = self._iterations_for(ctx, claim, len(targets) - index)
|
|
268
|
+
try:
|
|
269
|
+
await self._research(s, claim, iterations, claim_budget)
|
|
270
|
+
except (BudgetExceeded, _SearchUnavailable) as exc:
|
|
271
|
+
error = _error_text(exc)
|
|
272
|
+
break
|
|
273
|
+
except ProviderError as exc:
|
|
274
|
+
return self._result(s, [], _error_text(exc))
|
|
275
|
+
|
|
276
|
+
s.progress(
|
|
277
|
+
"Devil's advocate: writing the counter-case", 100.0 * len(targets) / (1 + len(targets))
|
|
278
|
+
)
|
|
279
|
+
try:
|
|
280
|
+
rebuttals = await self._rebut(s, targets)
|
|
281
|
+
except (ProviderError, BudgetExceeded) as exc:
|
|
282
|
+
return self._result(s, [], error or _error_text(exc))
|
|
283
|
+
s.progress("Devil's advocate: done", 100.0)
|
|
284
|
+
return self._result(s, rebuttals, error)
|
|
285
|
+
|
|
286
|
+
def _result(
|
|
287
|
+
self, s: _Session, rebuttals: list[tuple[Rebuttal, Angle]], error: str | None
|
|
288
|
+
) -> AgentResult:
|
|
289
|
+
findings: list[Finding] = []
|
|
290
|
+
updates: list[LedgerUpdate] = list(s.updates)
|
|
291
|
+
for rebuttal, angle in rebuttals:
|
|
292
|
+
updates.append(AddRebuttal(rebuttal=rebuttal))
|
|
293
|
+
findings.append(self._finding(s.ctx, rebuttal, angle))
|
|
294
|
+
return AgentResult(
|
|
295
|
+
agent=NAME,
|
|
296
|
+
findings=findings,
|
|
297
|
+
ledger_updates=updates,
|
|
298
|
+
usage=s.final_usage(),
|
|
299
|
+
error=error,
|
|
300
|
+
)
|
|
301
|
+
|
|
302
|
+
def _targets(self, ctx: ReviewContext) -> list[Claim]:
|
|
303
|
+
claims = ctx.ledger.claims(ClaimType.THESIS, ClaimType.SUPPORTING)
|
|
304
|
+
if ctx.plan is not None and ctx.plan.deep_research_claim_ids:
|
|
305
|
+
deep = set(ctx.plan.deep_research_claim_ids)
|
|
306
|
+
claims = [c for c in claims if c.id in deep] + [c for c in claims if c.id not in deep]
|
|
307
|
+
return claims[: self.max_claims]
|
|
308
|
+
|
|
309
|
+
def _iterations_for(
|
|
310
|
+
self, ctx: ReviewContext, claim: Claim, remaining: int
|
|
311
|
+
) -> tuple[int, ClaimBudget]:
|
|
312
|
+
plan = ctx.plan
|
|
313
|
+
budget = plan.budget_for(claim.id) if plan is not None else ClaimBudget()
|
|
314
|
+
iterations = min(budget.max_iterations, MAX_RESEARCH_ITERATIONS)
|
|
315
|
+
deep = plan.deep_research_claim_ids if plan is not None else []
|
|
316
|
+
if deep and claim.id not in deep:
|
|
317
|
+
iterations = 1 # not a deep-research claim: a single search
|
|
318
|
+
if ctx.meter.searches_left() < remaining * iterations:
|
|
319
|
+
iterations = 1 # budget running short: a single search per claim
|
|
320
|
+
return iterations, budget
|
|
321
|
+
|
|
322
|
+
# ------------------------------------------------------------- research
|
|
323
|
+
|
|
324
|
+
async def _research(
|
|
325
|
+
self, s: _Session, claim: Claim, iterations: int, budget: ClaimBudget
|
|
326
|
+
) -> None:
|
|
327
|
+
ctx = s.ctx
|
|
328
|
+
tokens_at_start = s.usage.total_tokens
|
|
329
|
+
plan = await s.llm(
|
|
330
|
+
prompts.TAG_PLAN,
|
|
331
|
+
prompts.plan_messages(ctx.profile, ctx.document.text, claim, ctx.focus),
|
|
332
|
+
ResearchPlan,
|
|
333
|
+
)
|
|
334
|
+
queries = deque(q.strip()[:300] for q in plan.queries[:3] if q.strip())
|
|
335
|
+
if not queries:
|
|
336
|
+
queries.append(claim.checkable_text[:300])
|
|
337
|
+
terms = keywords(claim.checkable_text, *queries)
|
|
338
|
+
seen: set[str] = set()
|
|
339
|
+
known: set[str] = set()
|
|
340
|
+
notes: list[str] = []
|
|
341
|
+
evidence: list[Evidence] = []
|
|
342
|
+
follow: str | None = None
|
|
343
|
+
searches = 0
|
|
344
|
+
stop_reason = "iteration cap reached"
|
|
345
|
+
iteration = 1
|
|
346
|
+
|
|
347
|
+
for iteration in range(1, iterations + 1):
|
|
348
|
+
sources: list[_Source] = []
|
|
349
|
+
if follow is not None:
|
|
350
|
+
url, follow = follow, None
|
|
351
|
+
seen.add(url)
|
|
352
|
+
page = await s.fetch(url)
|
|
353
|
+
s.step(
|
|
354
|
+
claim.id,
|
|
355
|
+
iteration,
|
|
356
|
+
ResearchAction.FETCH_PAGE,
|
|
357
|
+
url,
|
|
358
|
+
"followed link" if page else "follow-up fetch failed",
|
|
359
|
+
[url] if page else [],
|
|
360
|
+
)
|
|
361
|
+
if page is not None:
|
|
362
|
+
sources.append(self._page_source(s, claim, iteration, page, terms, known, 1))
|
|
363
|
+
else:
|
|
364
|
+
if searches >= budget.max_search_calls:
|
|
365
|
+
stop_reason = "claim search budget used"
|
|
366
|
+
break
|
|
367
|
+
if not queries:
|
|
368
|
+
stop_reason = "no further queries"
|
|
369
|
+
break
|
|
370
|
+
query = queries.popleft()
|
|
371
|
+
hits = await s.search(query, self.search_k)
|
|
372
|
+
searches += 1
|
|
373
|
+
known.update(h.url for h in hits)
|
|
374
|
+
s.step(
|
|
375
|
+
claim.id,
|
|
376
|
+
iteration,
|
|
377
|
+
ResearchAction.SEARCH,
|
|
378
|
+
query,
|
|
379
|
+
f"{len(hits)} results",
|
|
380
|
+
[h.url for h in hits],
|
|
381
|
+
)
|
|
382
|
+
fresh = [h for h in hits if h.url not in seen]
|
|
383
|
+
if fresh:
|
|
384
|
+
top = fresh[0]
|
|
385
|
+
seen.add(top.url)
|
|
386
|
+
page = await s.fetch(top.url)
|
|
387
|
+
s.step(
|
|
388
|
+
claim.id,
|
|
389
|
+
iteration,
|
|
390
|
+
ResearchAction.FETCH_PAGE,
|
|
391
|
+
top.url,
|
|
392
|
+
"fetched" if page else "fetch failed; using search snippet",
|
|
393
|
+
[top.url],
|
|
394
|
+
)
|
|
395
|
+
if page is not None:
|
|
396
|
+
sources.append(
|
|
397
|
+
self._page_source(s, claim, iteration, page, terms, known, 1)
|
|
398
|
+
)
|
|
399
|
+
elif top.snippet:
|
|
400
|
+
sources.append(self._snippet_source(top, 1))
|
|
401
|
+
for extra in fresh[1 : 1 + self.snippet_sources]:
|
|
402
|
+
if extra.snippet:
|
|
403
|
+
sources.append(self._snippet_source(extra, len(sources) + 1))
|
|
404
|
+
|
|
405
|
+
if not sources:
|
|
406
|
+
notes.append(f"Iteration {iteration}: nothing retrieved.")
|
|
407
|
+
s.step(claim.id, iteration, ResearchAction.ASSESS, claim.id, "nothing to assess")
|
|
408
|
+
if not queries and follow is None:
|
|
409
|
+
stop_reason = "no sources found"
|
|
410
|
+
break
|
|
411
|
+
continue
|
|
412
|
+
|
|
413
|
+
assessment = await s.llm(
|
|
414
|
+
prompts.TAG_ASSESS,
|
|
415
|
+
prompts.assess_messages(ctx.profile, claim, [x.view for x in sources], notes),
|
|
416
|
+
Assessment,
|
|
417
|
+
max_tokens=1_200,
|
|
418
|
+
)
|
|
419
|
+
found = self._verified_evidence(assessment, sources, evidence)
|
|
420
|
+
for item in found:
|
|
421
|
+
evidence.append(item)
|
|
422
|
+
s.gathered.append(_Gathered(f"E{len(s.gathered) + 1}", item, claim.id))
|
|
423
|
+
summary = assessment.summary.strip() or f"{len(found)} counter-evidence excerpts"
|
|
424
|
+
notes.append(f"Iteration {iteration}: {summary[:300]}")
|
|
425
|
+
s.step(
|
|
426
|
+
claim.id,
|
|
427
|
+
iteration,
|
|
428
|
+
ResearchAction.ASSESS,
|
|
429
|
+
claim.id,
|
|
430
|
+
summary,
|
|
431
|
+
[e.url for e in found],
|
|
432
|
+
)
|
|
433
|
+
|
|
434
|
+
if any(e.is_primary for e in evidence):
|
|
435
|
+
stop_reason = "primary source found"
|
|
436
|
+
break
|
|
437
|
+
if len({domain(e.url) for e in evidence}) >= 2:
|
|
438
|
+
stop_reason = "two independent sources agree"
|
|
439
|
+
break
|
|
440
|
+
if s.usage.total_tokens - tokens_at_start >= budget.max_tokens:
|
|
441
|
+
stop_reason = "claim token budget used"
|
|
442
|
+
break
|
|
443
|
+
if assessment.next_action is NextAction.STOP:
|
|
444
|
+
stop_reason = "assessment chose to stop"
|
|
445
|
+
break
|
|
446
|
+
if assessment.next_action is NextAction.FOLLOW_LINK:
|
|
447
|
+
url = (assessment.follow_url or "").strip()
|
|
448
|
+
if url and url in known and url not in seen:
|
|
449
|
+
follow = url
|
|
450
|
+
else:
|
|
451
|
+
notes.append("Ignored a follow_url that was not in the retrieved material.")
|
|
452
|
+
if follow is None and assessment.next_query and assessment.next_query.strip():
|
|
453
|
+
query = assessment.next_query.strip()[:300]
|
|
454
|
+
queries.appendleft(query)
|
|
455
|
+
terms |= keywords(query)
|
|
456
|
+
|
|
457
|
+
s.step(
|
|
458
|
+
claim.id,
|
|
459
|
+
iteration,
|
|
460
|
+
ResearchAction.STOP,
|
|
461
|
+
claim.id,
|
|
462
|
+
f"{stop_reason}; {len(evidence)} counter-evidence excerpts",
|
|
463
|
+
[e.url for e in evidence],
|
|
464
|
+
)
|
|
465
|
+
|
|
466
|
+
def _page_source(
|
|
467
|
+
self,
|
|
468
|
+
s: _Session,
|
|
469
|
+
claim: Claim,
|
|
470
|
+
iteration: int,
|
|
471
|
+
page: FetchedPage,
|
|
472
|
+
terms: set[str],
|
|
473
|
+
known: set[str],
|
|
474
|
+
number: int,
|
|
475
|
+
) -> _Source:
|
|
476
|
+
known.update(urls_in(page.text))
|
|
477
|
+
passages = find_in_page(page.text, terms)
|
|
478
|
+
s.step(
|
|
479
|
+
claim.id,
|
|
480
|
+
iteration,
|
|
481
|
+
ResearchAction.FIND_IN_PAGE,
|
|
482
|
+
page.final_url,
|
|
483
|
+
f"{len(passages)} matching passages",
|
|
484
|
+
[page.final_url],
|
|
485
|
+
)
|
|
486
|
+
view = SourceView(
|
|
487
|
+
label=f"S{number}",
|
|
488
|
+
url=page.final_url,
|
|
489
|
+
title=page.title or page.final_url,
|
|
490
|
+
text="\n[...]\n".join(passages),
|
|
491
|
+
)
|
|
492
|
+
return _Source(view=view, full_text=page.text, retrieved_at=page.fetched_at)
|
|
493
|
+
|
|
494
|
+
@staticmethod
|
|
495
|
+
def _snippet_source(hit: SearchResult, number: int) -> _Source:
|
|
496
|
+
view = SourceView(label=f"S{number}", url=hit.url, title=hit.title, text=hit.snippet)
|
|
497
|
+
return _Source(view=view, full_text=hit.snippet)
|
|
498
|
+
|
|
499
|
+
@staticmethod
|
|
500
|
+
def _verified_evidence(
|
|
501
|
+
assessment: Assessment, sources: list[_Source], existing: list[Evidence]
|
|
502
|
+
) -> list[Evidence]:
|
|
503
|
+
by_label = {x.view.label: x for x in sources}
|
|
504
|
+
have = {(e.url, normalize(e.excerpt)) for e in existing}
|
|
505
|
+
found: list[Evidence] = []
|
|
506
|
+
for item in assessment.evidence:
|
|
507
|
+
source = by_label.get(_clean_label(item.source_id))
|
|
508
|
+
if source is None:
|
|
509
|
+
continue
|
|
510
|
+
excerpt = locate_excerpt(item.excerpt, source.full_text)
|
|
511
|
+
if excerpt is None:
|
|
512
|
+
continue # not in the source: invented or misquoted
|
|
513
|
+
key = (source.view.url, normalize(excerpt))
|
|
514
|
+
if key in have:
|
|
515
|
+
continue
|
|
516
|
+
have.add(key)
|
|
517
|
+
retrieved = source.retrieved_at
|
|
518
|
+
found.append(
|
|
519
|
+
Evidence(
|
|
520
|
+
url=source.view.url,
|
|
521
|
+
title=source.view.title,
|
|
522
|
+
excerpt=excerpt,
|
|
523
|
+
retrieved_at=retrieved if retrieved is not None else utcnow(),
|
|
524
|
+
is_primary=item.is_primary,
|
|
525
|
+
)
|
|
526
|
+
)
|
|
527
|
+
return found
|
|
528
|
+
|
|
529
|
+
# ---------------------------------------------------------------- rebut
|
|
530
|
+
|
|
531
|
+
async def _rebut(self, s: _Session, targets: list[Claim]) -> list[tuple[Rebuttal, Angle]]:
|
|
532
|
+
ctx = s.ctx
|
|
533
|
+
target_ids = {c.id for c in targets}
|
|
534
|
+
context_lines = []
|
|
535
|
+
for entry in ctx.ledger.entries.values():
|
|
536
|
+
if entry.claim.id in target_ids:
|
|
537
|
+
continue
|
|
538
|
+
note = f" ({entry.verdict_note})" if entry.verdict_note else ""
|
|
539
|
+
context_lines.append(
|
|
540
|
+
f"[{entry.claim.id}] ({entry.claim.type.value}) "
|
|
541
|
+
f"verdict={entry.verdict.value}{note}: {entry.claim.text}"
|
|
542
|
+
)
|
|
543
|
+
views = [EvidenceView(g.label, g.evidence, g.claim_id) for g in s.gathered]
|
|
544
|
+
reply = await s.llm(
|
|
545
|
+
prompts.TAG_REBUT,
|
|
546
|
+
prompts.rebut_messages(
|
|
547
|
+
ctx.profile, ctx.document.text, targets, context_lines, views, ctx.focus
|
|
548
|
+
),
|
|
549
|
+
RebuttalSet,
|
|
550
|
+
max_tokens=3_000,
|
|
551
|
+
)
|
|
552
|
+
built = [r for r in (self._build(ctx, d, s.gathered) for d in reply.rebuttals) if r]
|
|
553
|
+
built.sort(key=lambda pair: -pair[0].strength)
|
|
554
|
+
return built[: self.max_rebuttals]
|
|
555
|
+
|
|
556
|
+
def _build(
|
|
557
|
+
self, ctx: ReviewContext, draft: RebuttalDraft, gathered: list[_Gathered]
|
|
558
|
+
) -> tuple[Rebuttal, Angle] | None:
|
|
559
|
+
argument = draft.argument.strip()
|
|
560
|
+
if not argument:
|
|
561
|
+
return None
|
|
562
|
+
by_label = {g.label: g for g in gathered}
|
|
563
|
+
cited: list[_Gathered] = []
|
|
564
|
+
for raw in draft.evidence_ids:
|
|
565
|
+
g = by_label.get(_clean_label(raw))
|
|
566
|
+
if g is not None and g not in cited:
|
|
567
|
+
cited.append(g)
|
|
568
|
+
target_ids = self.repair_targets(ctx, draft.target_claim_ids)
|
|
569
|
+
if not target_ids:
|
|
570
|
+
target_ids = list(dict.fromkeys(g.claim_id for g in cited))
|
|
571
|
+
if not target_ids:
|
|
572
|
+
return None # cannot tie it to any real claim: drop it
|
|
573
|
+
rebuttal = Rebuttal(
|
|
574
|
+
id=new_id("reb"),
|
|
575
|
+
target_claim_ids=target_ids,
|
|
576
|
+
argument=argument,
|
|
577
|
+
evidence=[g.evidence for g in cited],
|
|
578
|
+
strength=max(1, min(5, draft.strength)),
|
|
579
|
+
target=draft.target,
|
|
580
|
+
)
|
|
581
|
+
return rebuttal, draft.angle
|
|
582
|
+
|
|
583
|
+
@staticmethod
|
|
584
|
+
def repair_targets(ctx: ReviewContext, raw_ids: list[str]) -> list[str]:
|
|
585
|
+
"""Map model-proposed claim IDs to real ledger IDs; drop the rest.
|
|
586
|
+
|
|
587
|
+
Accepts exact IDs, IDs differing in case or wrapped in brackets, and
|
|
588
|
+
the claim's exact text instead of its ID.
|
|
589
|
+
"""
|
|
590
|
+
entries = ctx.ledger.entries
|
|
591
|
+
by_lower = {cid.lower(): cid for cid in entries}
|
|
592
|
+
by_text = {normalize(e.claim.text): cid for cid, e in entries.items()}
|
|
593
|
+
out: list[str] = []
|
|
594
|
+
for raw in raw_ids:
|
|
595
|
+
cleaned = raw.strip().strip("[]()").strip()
|
|
596
|
+
cid = (
|
|
597
|
+
cleaned
|
|
598
|
+
if cleaned in entries
|
|
599
|
+
else by_lower.get(cleaned.lower()) or by_text.get(normalize(cleaned))
|
|
600
|
+
)
|
|
601
|
+
if cid is not None and cid not in out:
|
|
602
|
+
out.append(cid)
|
|
603
|
+
return out
|
|
604
|
+
|
|
605
|
+
def _finding(self, ctx: ReviewContext, rebuttal: Rebuttal, angle: Angle) -> Finding:
|
|
606
|
+
claims = [ctx.ledger.get(cid).claim for cid in rebuttal.target_claim_ids]
|
|
607
|
+
anchor = max(claims, key=lambda c: c.importance)
|
|
608
|
+
message = _ANGLE_PREFIX[angle] + rebuttal.argument
|
|
609
|
+
if not rebuttal.has_evidence:
|
|
610
|
+
message += _NO_EVIDENCE_NOTE
|
|
611
|
+
return Finding(
|
|
612
|
+
id=f"find_{rebuttal.id}",
|
|
613
|
+
agent=NAME,
|
|
614
|
+
severity=rebuttal_severity(rebuttal),
|
|
615
|
+
span=anchor.span,
|
|
616
|
+
message=message,
|
|
617
|
+
evidence=list(rebuttal.evidence),
|
|
618
|
+
suggestion=_SUGGESTION,
|
|
619
|
+
claim_ids=list(rebuttal.target_claim_ids),
|
|
620
|
+
)
|
|
621
|
+
|
|
622
|
+
# --------------------------------------------------------------- debate
|
|
623
|
+
|
|
624
|
+
async def respond(
|
|
625
|
+
self, ctx: ReviewContext, claim_id: str, opposing_evidence: list[Evidence]
|
|
626
|
+
) -> DebatePosition:
|
|
627
|
+
"""One debate response to the fact-checker's evidence on ``claim_id``.
|
|
628
|
+
|
|
629
|
+
Uses the rebuttals already on the claim's ledger entry. Never raises:
|
|
630
|
+
on an unknown claim, provider error or exhausted budget it returns a
|
|
631
|
+
position explaining that it could not respond (``concedes=False``).
|
|
632
|
+
"""
|
|
633
|
+
entry = ctx.ledger.entries.get(claim_id)
|
|
634
|
+
if entry is None:
|
|
635
|
+
return DebatePosition(
|
|
636
|
+
agent=NAME, claim_id=claim_id, argument="No such claim; no position to defend."
|
|
637
|
+
)
|
|
638
|
+
own: list[EvidenceView] = []
|
|
639
|
+
for rebuttal in entry.rebuttals:
|
|
640
|
+
for ev in rebuttal.evidence:
|
|
641
|
+
if all(ev != v.evidence for v in own):
|
|
642
|
+
own.append(EvidenceView(f"E{len(own) + 1}", ev))
|
|
643
|
+
opposing = [EvidenceView(f"O{i + 1}", ev) for i, ev in enumerate(opposing_evidence)]
|
|
644
|
+
verdict = entry.verdict.value + (f" ({entry.verdict_note})" if entry.verdict_note else "")
|
|
645
|
+
s = _Session(ctx)
|
|
646
|
+
try:
|
|
647
|
+
reply = await s.llm(
|
|
648
|
+
prompts.TAG_DEBATE,
|
|
649
|
+
prompts.debate_messages(
|
|
650
|
+
ctx.profile,
|
|
651
|
+
entry.claim,
|
|
652
|
+
verdict,
|
|
653
|
+
[r.argument for r in entry.rebuttals],
|
|
654
|
+
own,
|
|
655
|
+
opposing,
|
|
656
|
+
),
|
|
657
|
+
DebateReply,
|
|
658
|
+
max_tokens=800,
|
|
659
|
+
)
|
|
660
|
+
except (ProviderError, BudgetExceeded) as exc:
|
|
661
|
+
return DebatePosition(
|
|
662
|
+
agent=NAME,
|
|
663
|
+
claim_id=claim_id,
|
|
664
|
+
argument=f"The devil's advocate could not respond ({type(exc).__name__}).",
|
|
665
|
+
)
|
|
666
|
+
by_label = {v.label: v.evidence for v in own}
|
|
667
|
+
cited: list[Evidence] = []
|
|
668
|
+
for raw in reply.evidence_ids:
|
|
669
|
+
chosen = by_label.get(_clean_label(raw))
|
|
670
|
+
if chosen is not None and chosen not in cited:
|
|
671
|
+
cited.append(chosen)
|
|
672
|
+
argument = reply.argument.strip() or (
|
|
673
|
+
"Concedes the point." if reply.concedes else "The rebuttal stands."
|
|
674
|
+
)
|
|
675
|
+
return DebatePosition(
|
|
676
|
+
agent=NAME,
|
|
677
|
+
claim_id=claim_id,
|
|
678
|
+
argument=argument,
|
|
679
|
+
evidence=cited,
|
|
680
|
+
concedes=reply.concedes,
|
|
681
|
+
)
|