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.
Files changed (95) hide show
  1. second_read-0.1.1.dist-info/METADATA +243 -0
  2. second_read-0.1.1.dist-info/RECORD +95 -0
  3. second_read-0.1.1.dist-info/WHEEL +4 -0
  4. second_read-0.1.1.dist-info/entry_points.txt +4 -0
  5. second_read-0.1.1.dist-info/licenses/LICENSE +21 -0
  6. secondread/__init__.py +0 -0
  7. secondread/agents/__init__.py +0 -0
  8. secondread/agents/copyedit/__init__.py +10 -0
  9. secondread/agents/copyedit/agent.py +261 -0
  10. secondread/agents/copyedit/prompts.py +123 -0
  11. secondread/agents/copyedit/text.py +274 -0
  12. secondread/agents/devils_advocate/__init__.py +18 -0
  13. secondread/agents/devils_advocate/agent.py +681 -0
  14. secondread/agents/devils_advocate/prompts.py +263 -0
  15. secondread/agents/devils_advocate/schemas.py +86 -0
  16. secondread/agents/devils_advocate/text.py +123 -0
  17. secondread/agents/extractor/__init__.py +20 -0
  18. secondread/agents/extractor/agent.py +330 -0
  19. secondread/agents/extractor/chunks.py +53 -0
  20. secondread/agents/extractor/prompts.py +121 -0
  21. secondread/agents/extractor/spans.py +177 -0
  22. secondread/agents/factcheck/__init__.py +12 -0
  23. secondread/agents/factcheck/agent.py +267 -0
  24. secondread/agents/factcheck/prompts.py +264 -0
  25. secondread/agents/factcheck/research.py +737 -0
  26. secondread/agents/factcheck/schemas.py +124 -0
  27. secondread/agents/factcheck/sources.py +295 -0
  28. secondread/agents/originality/__init__.py +29 -0
  29. secondread/agents/originality/agent.py +277 -0
  30. secondread/agents/originality/sentences.py +311 -0
  31. secondread/agents/originality/similarity.py +114 -0
  32. secondread/agents/structure/__init__.py +25 -0
  33. secondread/agents/structure/agent.py +301 -0
  34. secondread/agents/structure/prompts.py +179 -0
  35. secondread/cli/__init__.py +5 -0
  36. secondread/cli/__main__.py +6 -0
  37. secondread/cli/envfile.py +80 -0
  38. secondread/cli/errors.py +114 -0
  39. secondread/cli/main.py +327 -0
  40. secondread/contracts/__init__.py +134 -0
  41. secondread/contracts/errors.py +60 -0
  42. secondread/contracts/interfaces.py +314 -0
  43. secondread/contracts/models.py +689 -0
  44. secondread/mcp_local/README.md +96 -0
  45. secondread/mcp_local/__init__.py +35 -0
  46. secondread/mcp_local/__main__.py +6 -0
  47. secondread/mcp_local/demo.py +111 -0
  48. secondread/mcp_local/errors.py +107 -0
  49. secondread/mcp_local/main.py +80 -0
  50. secondread/mcp_local/progress.py +86 -0
  51. secondread/mcp_local/server.py +187 -0
  52. secondread/mcp_local/wiring.py +251 -0
  53. secondread/orchestrator/__init__.py +54 -0
  54. secondread/orchestrator/assemble.py +148 -0
  55. secondread/orchestrator/authwatch.py +83 -0
  56. secondread/orchestrator/classify.py +75 -0
  57. secondread/orchestrator/conflicts.py +406 -0
  58. secondread/orchestrator/labels.py +39 -0
  59. secondread/orchestrator/orchestrator.py +601 -0
  60. secondread/orchestrator/planning.py +221 -0
  61. secondread/orchestrator/progress.py +114 -0
  62. secondread/orchestrator/prompts.py +132 -0
  63. secondread/orchestrator/reactions.py +153 -0
  64. secondread/orchestrator/registry.py +56 -0
  65. secondread/orchestrator/schemas.py +45 -0
  66. secondread/pipeline.py +73 -0
  67. secondread/providers/__init__.py +0 -0
  68. secondread/providers/fetch/__init__.py +29 -0
  69. secondread/providers/fetch/extract.py +153 -0
  70. secondread/providers/fetch/fetcher.py +241 -0
  71. secondread/providers/fetch/guarded.py +110 -0
  72. secondread/providers/fetch/ssrf.py +207 -0
  73. secondread/providers/llm/__init__.py +49 -0
  74. secondread/providers/llm/_common.py +259 -0
  75. secondread/providers/llm/anthropic_client.py +332 -0
  76. secondread/providers/llm/config.py +93 -0
  77. secondread/providers/llm/openai_client.py +229 -0
  78. secondread/providers/search/__init__.py +45 -0
  79. secondread/providers/search/base.py +218 -0
  80. secondread/providers/search/brave.py +55 -0
  81. secondread/providers/search/config.py +63 -0
  82. secondread/providers/search/exa.py +62 -0
  83. secondread/providers/search/retry.py +60 -0
  84. secondread/providers/search/tavily.py +60 -0
  85. secondread/registry.py +270 -0
  86. secondread/report/__init__.py +30 -0
  87. secondread/report/email.py +43 -0
  88. secondread/report/html.py +49 -0
  89. secondread/report/markdown.py +195 -0
  90. secondread/report/templates/email.html.j2 +139 -0
  91. secondread/report/text.py +158 -0
  92. secondread/report/view.py +408 -0
  93. secondread/testing/__init__.py +1 -0
  94. secondread/testing/fakes.py +458 -0
  95. 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
+ )