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,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
|