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,737 @@
1
+ """The bounded research loop for one factual claim.
2
+
3
+ Per iteration (hard cap ``MAX_RESEARCH_ITERATIONS``): search (or follow a
4
+ link to the primary source), fetch the most promising pages, find the
5
+ relevant passages, and let the model assess each source. The loop stops when
6
+ a primary source settles the claim, when two independent sources agree, when
7
+ the model says further research will not help, when the budget runs out, or
8
+ at the cap. A final judge call proposes the verdict; code enforces that
9
+ "verified" and "wrong" are only given when the evidence settles the claim,
10
+ otherwise the claim is "unsupported" with the confidence noted.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ from collections import deque
16
+ from dataclasses import dataclass, field
17
+ from typing import TypeVar
18
+
19
+ from pydantic import BaseModel
20
+
21
+ from secondread.agents.factcheck import prompts
22
+ from secondread.agents.factcheck.schemas import (
23
+ Assessment,
24
+ Judgment,
25
+ NextStep,
26
+ QueryPlan,
27
+ Stance,
28
+ )
29
+ from secondread.agents.factcheck.sources import (
30
+ Source,
31
+ find_in_page,
32
+ is_http_url,
33
+ is_reputable,
34
+ looks_like_injection,
35
+ primary_hint,
36
+ relevant,
37
+ reputably_contradicted,
38
+ reputably_supported,
39
+ settles,
40
+ shared_terms,
41
+ verified_quote,
42
+ )
43
+ from secondread.contracts import (
44
+ MAX_RESEARCH_ITERATIONS,
45
+ AddResearchStep,
46
+ AgentName,
47
+ BudgetExceeded,
48
+ Claim,
49
+ ClaimBudget,
50
+ Evidence,
51
+ FetchedPage,
52
+ FetchError,
53
+ Finding,
54
+ LedgerUpdate,
55
+ Message,
56
+ ModelTier,
57
+ ProviderError,
58
+ ResearchAction,
59
+ ResearchStep,
60
+ ReviewContext,
61
+ SchemaError,
62
+ SearchResult,
63
+ SetVerdict,
64
+ Severity,
65
+ Usage,
66
+ Verdict,
67
+ utcnow,
68
+ )
69
+
70
+ FETCHES_PER_ITERATION = 2
71
+ """Pages fetched per search (primary-looking URLs first)."""
72
+ SNIPPET_SOURCES = 2
73
+ """Extra search results shown to the model as snippets only."""
74
+ MAX_QUERIES = 3
75
+ MAX_COUNTER_EVIDENCE = 3
76
+ SEARCH_K = 5
77
+ MAX_NOTE_CHARS = 500
78
+ SINGLE_SOURCE_MIN_CONFIDENCE = 0.75
79
+ """Judge confidence needed to verify a claim on one reputable source."""
80
+ SINGLE_SOURCE_MAX_CONFIDENCE = 0.7
81
+ """Confidence recorded for a claim verified on one reputable source."""
82
+ SECOND_LOOK_SOURCES = 3
83
+ """Pages gathered for other claims that a second look may assess."""
84
+ SECOND_LOOK_MIN_TERMS = 3
85
+ """Claim terms a page's passages must share to be worth a second look."""
86
+ SECOND_LOOK_TOKENS = 4_000
87
+ """Extra per-claim token allowance for the second look."""
88
+
89
+ _OUT_QUERIES = 300
90
+ _OUT_ASSESS = 1_000
91
+ _OUT_JUDGE = 900
92
+ MAX_OPEN_PARTS = 8
93
+
94
+ _T = TypeVar("_T", bound=BaseModel)
95
+
96
+
97
+ def estimate_tokens(messages: list[Message], max_output: int) -> int:
98
+ """Rough token estimate for a call (4 characters per token plus output)."""
99
+ return sum(len(m.content) for m in messages) // 4 + max_output
100
+
101
+
102
+ MAX_CONTEXT_CHARS = 900
103
+ """Cap on the surrounding passage shown with a claim."""
104
+
105
+
106
+ def surrounding_context(text: str, start: int, end: int) -> str:
107
+ """The document's first line plus the paragraph around ``[start, end)``.
108
+
109
+ Tells the model which event or subject a claim is about ("They", "the
110
+ first leg", "that season"). Clipped around the claim to ``MAX_CONTEXT_CHARS``.
111
+ """
112
+ para_start = text.rfind("\n\n", 0, start)
113
+ para_start = 0 if para_start < 0 else para_start + 2
114
+ para_end = text.find("\n\n", end)
115
+ para_end = len(text) if para_end < 0 else para_end
116
+ if para_end - para_start > MAX_CONTEXT_CHARS:
117
+ half = max(0, (MAX_CONTEXT_CHARS - (end - start)) // 2)
118
+ para_start = max(para_start, start - half)
119
+ para_end = min(para_end, end + half)
120
+ paragraph = " ".join(text[para_start:para_end].split())
121
+ title = " ".join(text.strip().split("\n", 1)[0].split())[:200]
122
+ if title and title not in paragraph:
123
+ return f"{title}\n{paragraph}"
124
+ return paragraph
125
+
126
+
127
+ def _clip(text: str, limit: int = MAX_NOTE_CHARS) -> str:
128
+ text = " ".join(text.split())
129
+ return text if len(text) <= limit else text[: limit - 1] + "…"
130
+
131
+
132
+ @dataclass
133
+ class ClaimOutcome:
134
+ """What researching one claim produced."""
135
+
136
+ claim_id: str
137
+ updates: list[LedgerUpdate] = field(default_factory=list)
138
+ findings: list[Finding] = field(default_factory=list)
139
+ usage: Usage = field(default_factory=Usage)
140
+ verdict: Verdict | None = None
141
+ error: str | None = None
142
+
143
+
144
+ class ClaimResearch:
145
+ """Researches one claim within its budget. Use once per claim.
146
+
147
+ ``deep`` claims get the full loop; others get a single search and one
148
+ iteration. Raises ``ProviderError`` for provider failures (the caller
149
+ degrades); budget exhaustion is handled here.
150
+ """
151
+
152
+ def __init__(
153
+ self, ctx: ReviewContext, claim: Claim, budget: ClaimBudget, *, deep: bool
154
+ ) -> None:
155
+ self.ctx = ctx
156
+ self.claim = claim
157
+ self.deep = deep
158
+ cap = min(budget.max_iterations, MAX_RESEARCH_ITERATIONS)
159
+ self.max_iterations = cap if deep else 1
160
+ self.max_searches = budget.max_search_calls if deep else min(1, budget.max_search_calls)
161
+ self.max_tokens = budget.max_tokens
162
+ self.usage = Usage()
163
+ self.tokens_used = 0
164
+ self.searches_used = 0
165
+ self.actions = 0
166
+ self.iteration = 1
167
+ self.sources: dict[str, Source] = {}
168
+ self.open_parts: list[str] | None = None
169
+ """Parts of the claim no source has confirmed or contradicted yet
170
+ (None until the first assessment)."""
171
+ self.outcome = ClaimOutcome(claim_id=claim.id)
172
+ self.context = surrounding_context(ctx.document.text, claim.span.start, claim.span.end)
173
+
174
+ # -- metered tools -----------------------------------------------------
175
+
176
+ def _charge(self, usage: Usage) -> None:
177
+ self.ctx.meter.charge(usage)
178
+ self.usage = self.usage + usage
179
+ self.tokens_used += usage.total_tokens
180
+
181
+ async def _llm(
182
+ self, messages: list[Message], schema: type[_T], tag: str, max_output: int
183
+ ) -> _T:
184
+ estimate = estimate_tokens(messages, max_output)
185
+ if self.tokens_used + estimate > self.max_tokens:
186
+ raise BudgetExceeded("claim token budget exhausted")
187
+ self.ctx.meter.require_tokens(estimate)
188
+ response = await self.ctx.llm.complete(
189
+ messages, schema=schema, model_tier=ModelTier.MID, tag=tag, max_tokens=max_output
190
+ )
191
+ self._charge(response.usage)
192
+ if not isinstance(response.parsed, schema):
193
+ raise SchemaError(f"{tag} reply did not match {schema.__name__}")
194
+ return response.parsed
195
+
196
+ async def _search(self, query: str) -> list[SearchResult]:
197
+ if self.searches_used >= self.max_searches:
198
+ raise BudgetExceeded("claim search budget exhausted")
199
+ self.ctx.meter.require_search()
200
+ # Charge before awaiting so concurrent claims cannot overspend the job.
201
+ self.searches_used += 1
202
+ self._charge(Usage(search_calls=1))
203
+ return await self.ctx.search.search(query, k=SEARCH_K)
204
+
205
+ async def _fetch(self, url: str) -> FetchedPage | None:
206
+ self.ctx.meter.require_time()
207
+ self._charge(Usage(fetch_calls=1))
208
+ try:
209
+ return await self.ctx.fetcher.fetch(url)
210
+ except FetchError:
211
+ return None
212
+
213
+ def _step(
214
+ self,
215
+ action: ResearchAction,
216
+ input: str,
217
+ summary: str = "",
218
+ urls: list[str] | None = None,
219
+ ) -> None:
220
+ step = ResearchStep(
221
+ agent=AgentName.FACTCHECK,
222
+ iteration=min(self.iteration, MAX_RESEARCH_ITERATIONS),
223
+ action=action,
224
+ input=_clip(input, 300),
225
+ summary=_clip(summary, 300),
226
+ evidence_urls=list(urls or []),
227
+ )
228
+ self.outcome.updates.append(AddResearchStep(claim_id=self.claim.id, step=step))
229
+
230
+ # -- loop pieces -------------------------------------------------------
231
+
232
+ async def _plan_queries(self) -> list[str]:
233
+ if not self.deep:
234
+ return [self.claim.checkable_text]
235
+ plan = await self._llm(
236
+ prompts.queries_messages(self.claim, self.context),
237
+ QueryPlan,
238
+ prompts.TAG_QUERIES,
239
+ _OUT_QUERIES,
240
+ )
241
+ queries = [" ".join(q.split()) for q in plan.queries if q.strip()]
242
+ return queries[:MAX_QUERIES] or [self.claim.checkable_text]
243
+
244
+ async def _read(
245
+ self, url: str, *, query: str, title: str, snippet: str, kind: str = "page"
246
+ ) -> Source | None:
247
+ """Fetch a page, find the relevant passages and register the source."""
248
+ page = await self._fetch(url)
249
+ self.actions += 1
250
+ if page is None:
251
+ if not snippet:
252
+ self._step(ResearchAction.FETCH_PAGE, url, "Fetch failed.")
253
+ self.sources[url] = Source(url=url, title=title, text="", passages=[])
254
+ return None
255
+ self._step(ResearchAction.FETCH_PAGE, url, "Fetch failed; using the search snippet.")
256
+ text, passages, kind, can_be_primary = snippet, [snippet], "snippet", False
257
+ else:
258
+ self._step(ResearchAction.FETCH_PAGE, url, f"Fetched: {page.title}", [page.final_url])
259
+ title = page.title or title
260
+ text, can_be_primary = page.text, True
261
+ passages = find_in_page(text, self.claim.checkable_text, query)
262
+ source = Source(
263
+ url=url,
264
+ title=title or url,
265
+ text=text,
266
+ passages=passages,
267
+ kind=kind,
268
+ can_be_primary=can_be_primary,
269
+ suspicious=looks_like_injection(text),
270
+ retrieved_at=page.fetched_at if page is not None else utcnow(),
271
+ )
272
+ self.sources[url] = source
273
+ if source.suspicious:
274
+ self._step(
275
+ ResearchAction.FIND_IN_PAGE,
276
+ url,
277
+ "Skipped: the page contains instructions aimed at the reviewer.",
278
+ )
279
+ return None
280
+ self._step(ResearchAction.FIND_IN_PAGE, url, f"{len(passages)} relevant passage(s).")
281
+ return source if passages else None
282
+
283
+ async def _search_and_read(self, query: str) -> list[Source]:
284
+ results = await self._search(query)
285
+ self.actions += 1
286
+ self._step(
287
+ ResearchAction.SEARCH, query, f"{len(results)} result(s).", [r.url for r in results]
288
+ )
289
+ fresh = [r for r in results if is_http_url(r.url) and r.url not in self.sources]
290
+ ranked = sorted(fresh, key=lambda r: (not primary_hint(r.url), r.rank))
291
+ to_fetch, rest = ranked[:FETCHES_PER_ITERATION], ranked[FETCHES_PER_ITERATION:]
292
+ batch: list[Source] = []
293
+ for result in to_fetch:
294
+ source = await self._read(
295
+ result.url, query=query, title=result.title, snippet=result.snippet
296
+ )
297
+ if source is not None:
298
+ batch.append(source)
299
+ for result in rest[:SNIPPET_SOURCES]:
300
+ if not result.snippet:
301
+ continue
302
+ source = Source(
303
+ url=result.url,
304
+ title=result.title or result.url,
305
+ text=result.snippet,
306
+ passages=[result.snippet],
307
+ kind="snippet",
308
+ can_be_primary=False,
309
+ suspicious=looks_like_injection(result.snippet),
310
+ )
311
+ self.sources[result.url] = source
312
+ if not source.suspicious:
313
+ batch.append(source)
314
+ return batch
315
+
316
+ async def _assess(self, batch: list[Source], query: str) -> Assessment:
317
+ assessment = await self._llm(
318
+ prompts.assess_messages(self.claim, batch, query, self.context, self.open_parts),
319
+ Assessment,
320
+ prompts.TAG_ASSESS,
321
+ _OUT_ASSESS,
322
+ )
323
+ self.open_parts = [
324
+ _clip(part, 120) for part in assessment.unverified_parts if part.strip()
325
+ ][:MAX_OPEN_PARTS]
326
+ by_url = {s.url: s for s in batch}
327
+ for judged in assessment.sources:
328
+ source = by_url.get(judged.url)
329
+ if source is None: # the model may only judge what it was shown
330
+ continue
331
+ source.stance = judged.stance
332
+ source.source_type = judged.source_type
333
+ if judged.stance is Stance.IRRELEVANT:
334
+ source.evidence = None
335
+ continue
336
+ fallback = source.passages[0] if source.passages else source.text
337
+ source.evidence = Evidence(
338
+ url=source.url,
339
+ title=source.title,
340
+ excerpt=verified_quote(judged.quote, source.text, fallback),
341
+ retrieved_at=source.retrieved_at,
342
+ is_primary=judged.is_primary and source.can_be_primary,
343
+ )
344
+ used = [s.url for s in batch if s.evidence is not None]
345
+ summary = assessment.summary or f"{len(used)} of {len(batch)} source(s) relevant."
346
+ self._step(ResearchAction.ASSESS, query or "follow-up", summary, used)
347
+ return assessment
348
+
349
+ def _settled(self) -> bool:
350
+ """True when research can stop: contradicted, or supported in every part.
351
+
352
+ Support for one part of a claim ("the club chartered a plane") does not
353
+ settle the others ("the next day", "to make the Wolves fixture"), so
354
+ the loop keeps going while parts remain unconfirmed.
355
+ """
356
+ sources = list(self.sources.values())
357
+ if settles(sources, Stance.CONTRADICTS):
358
+ return True
359
+ return settles(sources, Stance.SUPPORTS) and not self.open_parts
360
+
361
+ # -- entry points ------------------------------------------------------
362
+
363
+ async def run(self) -> ClaimOutcome:
364
+ """Research the claim and return its verdict, findings and trail."""
365
+ reason = "Research cap reached without a settling source."
366
+ if self.max_searches == 0 or self.ctx.meter.searches_left() == 0:
367
+ return await self._conclude("No search budget left.")
368
+ try:
369
+ pending = deque(await self._plan_queries())
370
+ follow: str | None = None
371
+ query = ""
372
+ tried: set[str] = set()
373
+ for iteration in range(1, self.max_iterations + 1):
374
+ self.iteration = iteration
375
+ if follow is not None:
376
+ source = await self._read(follow, query="", title=follow, snippet="")
377
+ batch = [source] if source is not None else []
378
+ follow = None
379
+ elif pending:
380
+ query = pending.popleft()
381
+ tried.add(query.casefold())
382
+ batch = await self._search_and_read(query)
383
+ else:
384
+ reason = "No further queries to try."
385
+ break
386
+ if not batch:
387
+ continue
388
+ assessment = await self._assess(batch, query)
389
+ if self._settled():
390
+ reason = "Settled by the evidence."
391
+ break
392
+ if assessment.next_step is NextStep.STOP:
393
+ reason = "Research stopped: no better sources expected."
394
+ break
395
+ if (
396
+ assessment.next_step is NextStep.FOLLOW
397
+ and assessment.follow_url
398
+ and is_http_url(assessment.follow_url)
399
+ and assessment.follow_url not in self.sources
400
+ ):
401
+ follow = assessment.follow_url
402
+ elif assessment.next_query and assessment.next_query.strip():
403
+ pending.appendleft(" ".join(assessment.next_query.split()))
404
+ while pending and pending[0].casefold() in tried:
405
+ pending.popleft()
406
+ except BudgetExceeded as exc:
407
+ reason = f"Budget ran short ({exc})."
408
+ return await self._conclude(reason)
409
+
410
+ async def recheck(self, prior: list[Evidence], counter: list[Evidence]) -> ClaimOutcome:
411
+ """Re-judge the claim once, given the devil's advocate's counter-evidence.
412
+
413
+ Counter-evidence pages are fetched so excerpts are re-validated; if a
414
+ page cannot be fetched, its excerpt is used but it cannot count as a
415
+ primary source. No new searches are made.
416
+ """
417
+ batch: list[Source] = []
418
+ try:
419
+ for ev in counter[:MAX_COUNTER_EVIDENCE]:
420
+ if ev.url in self.sources:
421
+ continue
422
+ source: Source | None = None
423
+ if is_http_url(ev.url):
424
+ source = await self._read(
425
+ ev.url,
426
+ query=ev.excerpt,
427
+ title=ev.title,
428
+ snippet=ev.excerpt,
429
+ kind="counter-evidence",
430
+ )
431
+ elif ev.excerpt:
432
+ source = Source(
433
+ url=ev.url,
434
+ title=ev.title,
435
+ text=ev.excerpt,
436
+ passages=[ev.excerpt],
437
+ kind="counter-evidence",
438
+ can_be_primary=False,
439
+ suspicious=looks_like_injection(ev.excerpt),
440
+ )
441
+ self.sources[ev.url] = source
442
+ source = None if source.suspicious else source
443
+ if source is not None:
444
+ batch.append(source)
445
+ for ev in prior:
446
+ if ev.url in self.sources or not ev.excerpt:
447
+ continue
448
+ source = Source(
449
+ url=ev.url,
450
+ title=ev.title,
451
+ text=ev.excerpt,
452
+ passages=[ev.excerpt],
453
+ kind="prior-evidence",
454
+ can_be_primary=ev.is_primary,
455
+ retrieved_at=ev.retrieved_at,
456
+ )
457
+ self.sources[ev.url] = source
458
+ batch.append(source)
459
+ if not batch:
460
+ self._step(ResearchAction.STOP, "re-check", "No usable counter-evidence.")
461
+ return self.outcome
462
+ self.actions += 1
463
+ await self._assess(batch, "re-check")
464
+ except BudgetExceeded as exc:
465
+ self._step(ResearchAction.STOP, "re-check", f"Re-check skipped: {exc}.")
466
+ return self.outcome
467
+ return await self._conclude("Re-checked against the devil's advocate's evidence.")
468
+
469
+ async def second_look(self, pool: list[Source]) -> ClaimOutcome:
470
+ """Re-judge an unsettled claim against pages fetched for other claims.
471
+
472
+ Claims in one document usually share a subject, so a page retrieved for
473
+ one claim often settles its neighbours. Costs no searches or fetches:
474
+ one assessment call, then the verdict rules run again. If nothing in
475
+ the pool is relevant, or the budget is spent, the outcome is unchanged.
476
+ """
477
+ claim_text = self.claim.checkable_text
478
+ scored: list[tuple[int, Source, list[str]]] = []
479
+ for candidate in pool:
480
+ if candidate.url in self.sources or candidate.suspicious or not candidate.text:
481
+ continue
482
+ passages = find_in_page(candidate.text, claim_text)
483
+ score = shared_terms(claim_text, " ".join(passages))
484
+ if passages and score >= SECOND_LOOK_MIN_TERMS:
485
+ scored.append((score, candidate, passages))
486
+ if not scored:
487
+ return self.outcome
488
+ scored.sort(key=lambda item: (-item[0], not is_reputable(item[1].url), item[1].url))
489
+ batch: list[Source] = []
490
+ for _score, candidate, passages in scored[:SECOND_LOOK_SOURCES]:
491
+ source = Source(
492
+ url=candidate.url,
493
+ title=candidate.title,
494
+ text=candidate.text,
495
+ passages=passages,
496
+ kind=candidate.kind,
497
+ can_be_primary=candidate.can_be_primary,
498
+ retrieved_at=candidate.retrieved_at,
499
+ )
500
+ self.sources[source.url] = source
501
+ batch.append(source)
502
+ self.max_tokens += SECOND_LOOK_TOKENS
503
+ self._step(
504
+ ResearchAction.FIND_IN_PAGE,
505
+ "pages gathered for other claims",
506
+ f"{len(batch)} page(s) already fetched in this review look relevant.",
507
+ [source.url for source in batch],
508
+ )
509
+ try:
510
+ await self._assess(batch, "")
511
+ except BudgetExceeded:
512
+ return self.outcome
513
+ if not any(source.evidence is not None for source in batch):
514
+ return self.outcome
515
+ self.outcome.updates = [u for u in self.outcome.updates if not isinstance(u, SetVerdict)]
516
+ self.outcome.findings.clear()
517
+ return await self._conclude("Checked against sources gathered for other claims.")
518
+
519
+ # -- verdict -----------------------------------------------------------
520
+
521
+ async def _conclude(self, reason: str) -> ClaimOutcome:
522
+ sources = list(self.sources.values())
523
+ supporting = relevant(sources, Stance.SUPPORTS)
524
+ contradicting = relevant(sources, Stance.CONTRADICTS)
525
+ if self.actions == 0:
526
+ self._step(ResearchAction.STOP, "unchecked", reason)
527
+ return self._finish(Verdict.UNCHECKED, [], None, f"Not checked. {reason}", None)
528
+
529
+ judgment: Judgment | None = None
530
+ if supporting or contradicting:
531
+ try:
532
+ judgment = await self._llm(
533
+ prompts.judge_messages(
534
+ self.claim,
535
+ [s.evidence for s in supporting if s.evidence],
536
+ [s.evidence for s in contradicting if s.evidence],
537
+ self.context,
538
+ self.open_parts,
539
+ ),
540
+ Judgment,
541
+ prompts.TAG_JUDGE,
542
+ _OUT_JUDGE,
543
+ )
544
+ except BudgetExceeded:
545
+ judgment = None
546
+ except ProviderError as exc: # decide from the evidence alone
547
+ judgment = None
548
+ self.outcome.error = f"judge failed: {type(exc).__name__}"
549
+
550
+ sup_settled = settles(sources, Stance.SUPPORTS)
551
+ con_settled = settles(sources, Stance.CONTRADICTS)
552
+ contested = True
553
+ if judgment is not None:
554
+ proposed, confidence = judgment.verdict, judgment.confidence
555
+ explanation = judgment.explanation
556
+ contested = judgment.contested
557
+ # A claim is verified only if every checkable part is. Enforced
558
+ # here, so one confirmed part cannot carry the whole sentence.
559
+ failing = [p for p in judgment.parts if p.verdict != "verified"]
560
+ if proposed == "verified" and failing:
561
+ proposed = "wrong" if any(p.verdict == "wrong" for p in failing) else "unsupported"
562
+ listed = "; ".join(_clip(p.part, 80) for p in failing[:4])
563
+ explanation = f"Not confirmed: {listed}. {explanation}".strip()
564
+ elif sup_settled != con_settled:
565
+ proposed = "verified" if sup_settled else "wrong"
566
+ decisive = supporting if sup_settled else contradicting
567
+ primary = any(s.evidence and s.evidence.is_primary for s in decisive)
568
+ confidence = 0.8 if primary else 0.65
569
+ explanation = "Decided from the evidence without a final judgment call."
570
+ else:
571
+ proposed, confidence, explanation = "unsupported", 0.3, ""
572
+
573
+ def evidence_of(items: list[Source]) -> list[Evidence]:
574
+ return [s.evidence for s in items if s.evidence is not None]
575
+
576
+ if proposed == "verified" and sup_settled:
577
+ self._step(ResearchAction.STOP, "verified", reason, [s.url for s in supporting])
578
+ return self._finish(
579
+ Verdict.VERIFIED, evidence_of(supporting), confidence, explanation, None
580
+ )
581
+ if (
582
+ proposed == "verified"
583
+ and judgment is not None
584
+ and confidence >= SINGLE_SOURCE_MIN_CONFIDENCE
585
+ and reputably_supported(sources)
586
+ ):
587
+ # Relaxed rule for uncontested facts: one reputable source is
588
+ # enough when nothing retrieved contradicts the claim.
589
+ note = "Supported by one reputable source; nothing retrieved contradicts it."
590
+ self._step(ResearchAction.STOP, "verified", note, [s.url for s in supporting])
591
+ return self._finish(
592
+ Verdict.VERIFIED,
593
+ evidence_of(supporting),
594
+ min(confidence, SINGLE_SOURCE_MAX_CONFIDENCE),
595
+ f"{note} {explanation}".strip(),
596
+ None,
597
+ )
598
+ if proposed == "wrong" and con_settled:
599
+ self._step(ResearchAction.STOP, "wrong", reason, [s.url for s in contradicting])
600
+ correction = judgment.correction if judgment is not None else None
601
+ return self._finish(
602
+ Verdict.WRONG, evidence_of(contradicting), confidence, explanation, correction
603
+ )
604
+ if (
605
+ proposed == "wrong"
606
+ and judgment is not None
607
+ and not contested
608
+ and confidence >= SINGLE_SOURCE_MIN_CONFIDENCE
609
+ and reputably_contradicted(sources)
610
+ ):
611
+ # Mirror of the single-source rule for "verified": one reputable
612
+ # source contradicts the claim and nothing retrieved supports the
613
+ # disputed detail.
614
+ note = (
615
+ "One reputable source contradicts this, and nothing retrieved supports "
616
+ "the disputed detail."
617
+ )
618
+ self._step(ResearchAction.STOP, "wrong", note, [s.url for s in contradicting])
619
+ return self._finish(
620
+ Verdict.WRONG,
621
+ evidence_of(contradicting),
622
+ min(confidence, SINGLE_SOURCE_MAX_CONFIDENCE),
623
+ f"{note} {explanation}".strip(),
624
+ judgment.correction,
625
+ )
626
+
627
+ # Not settled. Either the sources we found do not back the claim as
628
+ # written (a finding the author should act on), or we simply could not
629
+ # confirm it (a lower-severity note: absence of evidence is not error).
630
+ could_not_confirm = False
631
+ if proposed == "wrong":
632
+ note = (
633
+ "One source contradicts this claim; a second independent source would settle it. "
634
+ f"{explanation}"
635
+ )
636
+ elif proposed == "verified":
637
+ could_not_confirm = True
638
+ note = (
639
+ "Only weak sourcing was found: not a primary source, a reputable outlet or "
640
+ f"two independent sources. {explanation}"
641
+ )
642
+ elif contradicting and explanation:
643
+ note = explanation
644
+ elif supporting and explanation:
645
+ # Sources back part of the claim and dispute none of it: the rest
646
+ # could not be confirmed, which is not a must-fix.
647
+ could_not_confirm = True
648
+ note = explanation
649
+ else:
650
+ could_not_confirm = True
651
+ note = "No relevant source was found within the research budget."
652
+ confidence = min(confidence, 0.5)
653
+ self._step(ResearchAction.STOP, "unsupported", note)
654
+ evidence = evidence_of(supporting) + evidence_of(contradicting)
655
+ evidence.sort(key=lambda e: not e.is_primary)
656
+ return self._finish(
657
+ Verdict.UNSUPPORTED,
658
+ evidence,
659
+ confidence,
660
+ note.strip(),
661
+ None,
662
+ could_not_confirm=could_not_confirm,
663
+ )
664
+
665
+ def fail(self, exc: BaseException) -> ClaimOutcome:
666
+ """Degrade after a provider failure: keep the trail, mark the claim unchecked."""
667
+ self.outcome.findings.clear()
668
+ self.outcome.updates = [u for u in self.outcome.updates if not isinstance(u, SetVerdict)]
669
+ self._step(ResearchAction.STOP, "error", f"Provider error ({type(exc).__name__}).")
670
+ outcome = self._finish(
671
+ Verdict.UNCHECKED, [], None, "Not checked: a provider error stopped the research.", None
672
+ )
673
+ message = " ".join(str(exc).split())[:200]
674
+ outcome.error = f"{type(exc).__name__}: {message}" if message else type(exc).__name__
675
+ return outcome
676
+
677
+ def _finish(
678
+ self,
679
+ verdict: Verdict,
680
+ evidence: list[Evidence],
681
+ confidence: float | None,
682
+ note: str,
683
+ correction: str | None,
684
+ *,
685
+ could_not_confirm: bool = False,
686
+ ) -> ClaimOutcome:
687
+ note = _clip(note)
688
+ self.outcome.updates.append(
689
+ SetVerdict(
690
+ claim_id=self.claim.id,
691
+ verdict=verdict,
692
+ evidence=evidence,
693
+ confidence=confidence,
694
+ note=note,
695
+ )
696
+ )
697
+ if verdict is Verdict.WRONG:
698
+ self.outcome.findings.append(
699
+ Finding(
700
+ agent=AgentName.FACTCHECK,
701
+ severity=Severity.FACTUAL_ERROR,
702
+ span=self.claim.span,
703
+ message=_clip(f"Retrieved evidence contradicts this claim. {note}"),
704
+ evidence=evidence,
705
+ suggestion=_clip(correction) if correction else None,
706
+ claim_ids=[self.claim.id],
707
+ )
708
+ )
709
+ elif verdict is Verdict.UNSUPPORTED and could_not_confirm:
710
+ # Lower severity: we could not confirm it, which is not the same as
711
+ # the claim being unsupported. Reported under "Could not verify".
712
+ self.outcome.findings.append(
713
+ Finding(
714
+ agent=AgentName.FACTCHECK,
715
+ severity=Severity.CONSIDER,
716
+ span=self.claim.span,
717
+ message=_clip(f"Could not confirm this claim from retrieved sources. {note}"),
718
+ evidence=evidence,
719
+ suggestion="Double-check this detail, and add a source if you have one.",
720
+ claim_ids=[self.claim.id],
721
+ )
722
+ )
723
+ elif verdict is Verdict.UNSUPPORTED:
724
+ self.outcome.findings.append(
725
+ Finding(
726
+ agent=AgentName.FACTCHECK,
727
+ severity=Severity.UNSUPPORTED,
728
+ span=self.claim.span,
729
+ message=_clip(f"Retrieved sources do not back this claim as written. {note}"),
730
+ evidence=evidence,
731
+ suggestion="Check this against the sources, then correct or soften it.",
732
+ claim_ids=[self.claim.id],
733
+ )
734
+ )
735
+ self.outcome.verdict = verdict
736
+ self.outcome.usage = self.usage
737
+ return self.outcome