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,124 @@
|
|
|
1
|
+
"""Structured-output schemas the fact-checker asks the LLM for.
|
|
2
|
+
|
|
3
|
+
These are internal to the fact-checker; they never leave the package. Code
|
|
4
|
+
re-validates everything the model returns (URLs must be ones we retrieved,
|
|
5
|
+
quotes must appear in the retrieved text, verdicts must be settled by the
|
|
6
|
+
evidence), so a confused or manipulated model cannot mark a claim verified on
|
|
7
|
+
its own say-so.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from enum import StrEnum
|
|
13
|
+
from typing import Literal
|
|
14
|
+
|
|
15
|
+
from pydantic import BaseModel, Field
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class QueryPlan(BaseModel):
|
|
19
|
+
"""Reply to ``factcheck.queries``: search queries for one claim, best first."""
|
|
20
|
+
|
|
21
|
+
queries: list[str] = Field(default_factory=list)
|
|
22
|
+
rationale: str = ""
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class Stance(StrEnum):
|
|
26
|
+
"""How one source bears on the claim."""
|
|
27
|
+
|
|
28
|
+
SUPPORTS = "supports"
|
|
29
|
+
CONTRADICTS = "contradicts"
|
|
30
|
+
IRRELEVANT = "irrelevant"
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class NextStep(StrEnum):
|
|
34
|
+
"""What the research loop should do after assessing sources."""
|
|
35
|
+
|
|
36
|
+
STOP = "stop"
|
|
37
|
+
"""Nothing more to gain (or the claim is settled)."""
|
|
38
|
+
REFINE = "refine"
|
|
39
|
+
"""Search again with ``next_query``."""
|
|
40
|
+
FOLLOW = "follow"
|
|
41
|
+
"""Fetch ``follow_url`` (usually the primary source a page cites)."""
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class SourceType(StrEnum):
|
|
45
|
+
"""What kind of publication a source is (the model's reading, checked in code)."""
|
|
46
|
+
|
|
47
|
+
PRIMARY = "primary"
|
|
48
|
+
"""The original record itself (official table, filing, transcript, dataset)."""
|
|
49
|
+
REFERENCE = "reference"
|
|
50
|
+
"""An edited encyclopedia or reference work."""
|
|
51
|
+
MAJOR_NEWS = "major_news"
|
|
52
|
+
"""An established national or international news organisation."""
|
|
53
|
+
OFFICIAL = "official"
|
|
54
|
+
"""A government, governing body, league, or the organisation the claim is about."""
|
|
55
|
+
SPECIALIST = "specialist"
|
|
56
|
+
"""A specialist publication or database with editorial standards."""
|
|
57
|
+
BLOG = "blog"
|
|
58
|
+
"""A personal, fan or self-published site."""
|
|
59
|
+
SOCIAL = "social"
|
|
60
|
+
"""Social media or other user-generated content."""
|
|
61
|
+
UNKNOWN = "unknown"
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
REPUTABLE_SOURCE_TYPES = frozenset(
|
|
65
|
+
{SourceType.PRIMARY, SourceType.REFERENCE, SourceType.MAJOR_NEWS, SourceType.OFFICIAL}
|
|
66
|
+
)
|
|
67
|
+
"""Types that can verify (or refute) an uncontested claim on their own."""
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
class SourceAssessment(BaseModel):
|
|
71
|
+
"""The model's reading of one retrieved source."""
|
|
72
|
+
|
|
73
|
+
url: str
|
|
74
|
+
stance: Stance
|
|
75
|
+
source_type: SourceType = SourceType.UNKNOWN
|
|
76
|
+
is_primary: bool = False
|
|
77
|
+
quote: str = ""
|
|
78
|
+
"""Verbatim sentence from the source that supports the stance."""
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
class Assessment(BaseModel):
|
|
82
|
+
"""Reply to ``factcheck.assess``: per-source stances and the next step."""
|
|
83
|
+
|
|
84
|
+
sources: list[SourceAssessment] = Field(default_factory=list)
|
|
85
|
+
next_step: NextStep = NextStep.STOP
|
|
86
|
+
next_query: str | None = None
|
|
87
|
+
follow_url: str | None = None
|
|
88
|
+
summary: str = ""
|
|
89
|
+
unverified_parts: list[str] = Field(default_factory=list)
|
|
90
|
+
"""Checkable parts of the claim that no source seen so far confirms or
|
|
91
|
+
contradicts (a few words each). Empty when every part is covered."""
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
class PartVerdict(BaseModel):
|
|
95
|
+
"""The judge's verdict on one checkable part of a claim."""
|
|
96
|
+
|
|
97
|
+
part: str
|
|
98
|
+
verdict: Literal["verified", "wrong", "unsupported"]
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
class Judgment(BaseModel):
|
|
102
|
+
"""Reply to ``factcheck.judge``: the final verdict proposal for one claim."""
|
|
103
|
+
|
|
104
|
+
verdict: Literal["verified", "wrong", "unsupported"]
|
|
105
|
+
confidence: float = Field(default=0.5, ge=0.0, le=1.0)
|
|
106
|
+
explanation: str = ""
|
|
107
|
+
correction: str | None = None
|
|
108
|
+
"""For wrong claims: what the evidence says instead."""
|
|
109
|
+
evidence_urls: list[str] = Field(default_factory=list)
|
|
110
|
+
parts: list[PartVerdict] = Field(default_factory=list)
|
|
111
|
+
"""A verdict for each checkable part of the claim (number, date, name,
|
|
112
|
+
place, cause, sequence). The claim is verified only if every part is."""
|
|
113
|
+
contested: bool = True
|
|
114
|
+
"""For a "wrong" verdict: True if any evidence supports the specific
|
|
115
|
+
detail the contradicting evidence disputes. Defaults to True (the cautious
|
|
116
|
+
reading) when the model does not say."""
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
class DebateReply(BaseModel):
|
|
120
|
+
"""Reply to ``factcheck.debate``: one response to the opposing evidence."""
|
|
121
|
+
|
|
122
|
+
argument: str
|
|
123
|
+
concedes: bool = False
|
|
124
|
+
evidence_urls: list[str] = Field(default_factory=list)
|
|
@@ -0,0 +1,295 @@
|
|
|
1
|
+
"""Deterministic helpers for handling retrieved sources.
|
|
2
|
+
|
|
3
|
+
Everything here treats page text as untrusted data: it is searched, sliced
|
|
4
|
+
and quoted, never interpreted.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import re
|
|
10
|
+
from dataclasses import dataclass, field
|
|
11
|
+
from datetime import datetime
|
|
12
|
+
from urllib.parse import urlsplit
|
|
13
|
+
|
|
14
|
+
from secondread.agents.factcheck.schemas import REPUTABLE_SOURCE_TYPES, SourceType, Stance
|
|
15
|
+
from secondread.contracts import Evidence, utcnow
|
|
16
|
+
|
|
17
|
+
MAX_PASSAGES = 5
|
|
18
|
+
"""Passages kept per page by ``find_in_page``."""
|
|
19
|
+
|
|
20
|
+
MAX_PASSAGE_CHARS = 400
|
|
21
|
+
MAX_EXCERPT_CHARS = 500
|
|
22
|
+
|
|
23
|
+
_WORD = re.compile(r"[a-z0-9][a-z0-9.,%-]*", re.IGNORECASE)
|
|
24
|
+
_SENTENCE = re.compile(r"(?<=[.!?])\s+|\n+")
|
|
25
|
+
_STOPWORD_TEXT = (
|
|
26
|
+
"a an and are as at be by for from has have in is it its of on or that the their "
|
|
27
|
+
"there this to was were with than any other more most"
|
|
28
|
+
)
|
|
29
|
+
_STOPWORDS = frozenset(_STOPWORD_TEXT.split())
|
|
30
|
+
|
|
31
|
+
_PRIMARY_HOST_SUFFIXES = (".gov", ".edu", ".int", ".mil")
|
|
32
|
+
_PRIMARY_HOSTS = frozenset(
|
|
33
|
+
{"doi.org", "arxiv.org", "pubmed.ncbi.nlm.nih.gov", "data.worldbank.org", "sec.gov"}
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
_LOW_QUALITY_HOSTS = frozenset(
|
|
37
|
+
{
|
|
38
|
+
"facebook.com",
|
|
39
|
+
"instagram.com",
|
|
40
|
+
"youtube.com",
|
|
41
|
+
"youtu.be",
|
|
42
|
+
"tiktok.com",
|
|
43
|
+
"x.com",
|
|
44
|
+
"twitter.com",
|
|
45
|
+
"reddit.com",
|
|
46
|
+
"pinterest.com",
|
|
47
|
+
"quora.com",
|
|
48
|
+
"linkedin.com",
|
|
49
|
+
"threads.net",
|
|
50
|
+
}
|
|
51
|
+
)
|
|
52
|
+
"""Social and user-generated hosts. Shown as evidence, but they never count
|
|
53
|
+
towards settling a claim."""
|
|
54
|
+
|
|
55
|
+
_REPUTABLE_HOSTS = frozenset(
|
|
56
|
+
{
|
|
57
|
+
"wikipedia.org",
|
|
58
|
+
"britannica.com",
|
|
59
|
+
"bbc.com",
|
|
60
|
+
"bbc.co.uk",
|
|
61
|
+
"reuters.com",
|
|
62
|
+
"apnews.com",
|
|
63
|
+
"nytimes.com",
|
|
64
|
+
"theguardian.com",
|
|
65
|
+
"washingtonpost.com",
|
|
66
|
+
"ft.com",
|
|
67
|
+
"economist.com",
|
|
68
|
+
"espn.com",
|
|
69
|
+
"skysports.com",
|
|
70
|
+
"uefa.com",
|
|
71
|
+
"fifa.com",
|
|
72
|
+
"premierleague.com",
|
|
73
|
+
"history.com",
|
|
74
|
+
"nature.com",
|
|
75
|
+
"science.org",
|
|
76
|
+
"who.int",
|
|
77
|
+
"un.org",
|
|
78
|
+
}
|
|
79
|
+
)
|
|
80
|
+
"""Edited reference works, major news organisations and official bodies. One
|
|
81
|
+
of these is enough to verify a claim nothing contradicts."""
|
|
82
|
+
|
|
83
|
+
_INJECTION = re.compile(
|
|
84
|
+
r"ignore\s+(all\s+|any\s+)?(previous|prior|above)\s+instructions"
|
|
85
|
+
r"|disregard\s+(all\s+|the\s+)?(previous|prior|above)"
|
|
86
|
+
r"|(mark|label|rate|classify)\s+(this|the)\s+(claim\s+)?(as\s+)?(verified|true|correct)"
|
|
87
|
+
r"|you\s+are\s+now\s+"
|
|
88
|
+
r"|system\s+prompt"
|
|
89
|
+
r"|as\s+an?\s+(ai|language\s+model|fact-?checker)[, ]+you\s+must",
|
|
90
|
+
re.IGNORECASE,
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def domain(url: str) -> str:
|
|
95
|
+
"""Host of ``url`` without a leading ``www.`` (used for source independence)."""
|
|
96
|
+
host = (urlsplit(url).hostname or url).lower()
|
|
97
|
+
return host.removeprefix("www.")
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def _host_in(url: str, hosts: frozenset[str]) -> bool:
|
|
101
|
+
host = domain(url)
|
|
102
|
+
return any(host == h or host.endswith("." + h) for h in hosts)
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def is_low_quality(url: str) -> bool:
|
|
106
|
+
"""True for social and user-generated hosts."""
|
|
107
|
+
return _host_in(url, _LOW_QUALITY_HOSTS)
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def is_reputable(url: str) -> bool:
|
|
111
|
+
"""True for edited reference works, major news and official bodies."""
|
|
112
|
+
host = domain(url)
|
|
113
|
+
return (
|
|
114
|
+
_host_in(url, _REPUTABLE_HOSTS)
|
|
115
|
+
or host in _PRIMARY_HOSTS
|
|
116
|
+
or host.endswith(_PRIMARY_HOST_SUFFIXES)
|
|
117
|
+
)
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def is_http_url(url: str) -> bool:
|
|
121
|
+
"""True for absolute http(s) URLs."""
|
|
122
|
+
parts = urlsplit(url)
|
|
123
|
+
return parts.scheme in {"http", "https"} and bool(parts.hostname)
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def primary_hint(url: str) -> bool:
|
|
127
|
+
"""Cheap guess that a URL is a primary source (used only to order fetches)."""
|
|
128
|
+
host = domain(url)
|
|
129
|
+
path = urlsplit(url).path.lower()
|
|
130
|
+
return (
|
|
131
|
+
host in _PRIMARY_HOSTS
|
|
132
|
+
or host.endswith(_PRIMARY_HOST_SUFFIXES)
|
|
133
|
+
or host.startswith("data.")
|
|
134
|
+
or path.endswith(".pdf")
|
|
135
|
+
)
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def looks_like_injection(text: str) -> bool:
|
|
139
|
+
"""True if the text contains instructions aimed at the reviewer.
|
|
140
|
+
|
|
141
|
+
Such pages are dropped as evidence: a source that tries to steer the
|
|
142
|
+
fact-checker is not trustworthy evidence of anything.
|
|
143
|
+
"""
|
|
144
|
+
return bool(_INJECTION.search(text))
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
_TERM_PREFIX = 6
|
|
148
|
+
"""Words are compared on their first letters, so spelling and inflection
|
|
149
|
+
variants match ("refueling"/"refuelling", "charter"/"chartered")."""
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def _terms(text: str) -> set[str]:
|
|
153
|
+
terms: set[str] = set()
|
|
154
|
+
for raw in _WORD.findall(text):
|
|
155
|
+
word = raw.strip(".,-").lower()
|
|
156
|
+
if len(word) <= 1 or word in _STOPWORDS:
|
|
157
|
+
continue
|
|
158
|
+
terms.add(word if any(ch.isdigit() for ch in word) else word[:_TERM_PREFIX])
|
|
159
|
+
return terms
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
_NUMBER_WORDS = (
|
|
163
|
+
r"one|two|three|four|five|six|seven|eight|nine|ten|eleven|twelve|twenty|thirty|"
|
|
164
|
+
r"forty|fifty|hundred|thousand|million|billion|half|third|quarter|twice|percent"
|
|
165
|
+
)
|
|
166
|
+
_CHECKABLE_DETAIL = re.compile(rf"\d|\b({_NUMBER_WORDS})\b", re.IGNORECASE)
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def has_checkable_detail(text: str) -> bool:
|
|
170
|
+
"""True if ``text`` states a number, year or quantity that can be checked.
|
|
171
|
+
|
|
172
|
+
Used to fact-check argumentative claims that still carry facts ("Spain won
|
|
173
|
+
Euro 2008 by passing teams to death"), not just claims typed as factual.
|
|
174
|
+
"""
|
|
175
|
+
return bool(_CHECKABLE_DETAIL.search(text))
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def find_in_page(text: str, claim: str, query: str = "") -> list[str]:
|
|
179
|
+
"""Return up to ``MAX_PASSAGES`` sentences of ``text`` most relevant to the claim.
|
|
180
|
+
|
|
181
|
+
Scores sentences by term overlap with the claim (and query); numbers count
|
|
182
|
+
double since factual claims usually hinge on them. Passages keep page
|
|
183
|
+
order so context reads naturally.
|
|
184
|
+
"""
|
|
185
|
+
wanted = _terms(claim) | _terms(query)
|
|
186
|
+
if not wanted:
|
|
187
|
+
return []
|
|
188
|
+
numbers = {t for t in wanted if any(ch.isdigit() for ch in t)}
|
|
189
|
+
scored: list[tuple[float, int, str]] = []
|
|
190
|
+
for index, raw in enumerate(_SENTENCE.split(text)):
|
|
191
|
+
sentence = " ".join(raw.split())
|
|
192
|
+
if not sentence:
|
|
193
|
+
continue
|
|
194
|
+
terms = _terms(sentence)
|
|
195
|
+
score = len(terms & wanted) + len(terms & numbers)
|
|
196
|
+
if score > 0:
|
|
197
|
+
scored.append((score, index, sentence[:MAX_PASSAGE_CHARS]))
|
|
198
|
+
best = sorted(scored, key=lambda s: (-s[0], s[1]))[:MAX_PASSAGES]
|
|
199
|
+
return [s for _, _, s in sorted(best, key=lambda s: s[1])]
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def _norm(text: str) -> str:
|
|
203
|
+
return " ".join(text.split()).casefold()
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def verified_quote(quote: str, source_text: str, fallback: str) -> str:
|
|
207
|
+
"""``quote`` if it appears verbatim in ``source_text``, else ``fallback``.
|
|
208
|
+
|
|
209
|
+
Keeps evidence excerpts honest: an excerpt is always text we retrieved.
|
|
210
|
+
"""
|
|
211
|
+
quote = " ".join(quote.split())
|
|
212
|
+
if quote and _norm(quote) in _norm(source_text):
|
|
213
|
+
return quote[:MAX_EXCERPT_CHARS]
|
|
214
|
+
return " ".join(fallback.split())[:MAX_EXCERPT_CHARS]
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
@dataclass
|
|
218
|
+
class Source:
|
|
219
|
+
"""One retrieved source the loop has read (a fetched page or a snippet)."""
|
|
220
|
+
|
|
221
|
+
url: str
|
|
222
|
+
title: str
|
|
223
|
+
text: str
|
|
224
|
+
"""The retrieved text excerpts are validated against (untrusted)."""
|
|
225
|
+
passages: list[str]
|
|
226
|
+
"""What the model is shown (a few relevant sentences)."""
|
|
227
|
+
kind: str = "page"
|
|
228
|
+
"""``page``, ``snippet``, ``prior-evidence`` or ``counter-evidence``."""
|
|
229
|
+
can_be_primary: bool = True
|
|
230
|
+
"""False when we only saw a snippet, so we cannot confirm it is primary."""
|
|
231
|
+
suspicious: bool = False
|
|
232
|
+
retrieved_at: datetime = field(default_factory=utcnow)
|
|
233
|
+
stance: Stance | None = None
|
|
234
|
+
evidence: Evidence | None = None
|
|
235
|
+
source_type: SourceType = SourceType.UNKNOWN
|
|
236
|
+
"""The model's reading of what kind of publication this is."""
|
|
237
|
+
|
|
238
|
+
@property
|
|
239
|
+
def domain(self) -> str:
|
|
240
|
+
"""Host used to judge independence between sources."""
|
|
241
|
+
return domain(self.url)
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
def relevant(sources: list[Source], stance: Stance) -> list[Source]:
|
|
245
|
+
"""Assessed sources with ``stance`` and evidence, primary sources first."""
|
|
246
|
+
hits = [s for s in sources if s.stance is stance and s.evidence is not None]
|
|
247
|
+
return sorted(hits, key=lambda s: not (s.evidence and s.evidence.is_primary))
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
def settles(sources: list[Source], stance: Stance) -> bool:
|
|
251
|
+
"""True if a primary source, or two independent sources, take ``stance``.
|
|
252
|
+
|
|
253
|
+
Social and user-generated hosts do not count towards the two.
|
|
254
|
+
"""
|
|
255
|
+
hits = relevant(sources, stance)
|
|
256
|
+
if any(s.evidence is not None and s.evidence.is_primary for s in hits):
|
|
257
|
+
return True
|
|
258
|
+
return len({s.domain for s in hits if not is_low_quality(s.url)}) >= 2
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
def is_reputable_source(source: Source) -> bool:
|
|
262
|
+
"""True if one fetched ``source`` can verify or refute an uncontested claim.
|
|
263
|
+
|
|
264
|
+
Either its host is on the known list, or the assessing model classed it as
|
|
265
|
+
a primary record, reference work, major news organisation or official
|
|
266
|
+
body. Social and user-generated hosts never qualify, whatever the model
|
|
267
|
+
says, and search snippets never do (the page itself must have been read).
|
|
268
|
+
"""
|
|
269
|
+
if not source.can_be_primary or is_low_quality(source.url):
|
|
270
|
+
return False
|
|
271
|
+
return is_reputable(source.url) or source.source_type in REPUTABLE_SOURCE_TYPES
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
def reputably_supported(sources: list[Source]) -> bool:
|
|
275
|
+
"""True if a reputable source supports the claim and nothing contradicts it.
|
|
276
|
+
|
|
277
|
+
The relaxed rule for uncontested facts: one edited, reputable source that
|
|
278
|
+
was actually fetched (not just a search snippet) is enough when no
|
|
279
|
+
retrieved source disagrees.
|
|
280
|
+
"""
|
|
281
|
+
if relevant(sources, Stance.CONTRADICTS):
|
|
282
|
+
return False
|
|
283
|
+
return any(is_reputable_source(s) for s in relevant(sources, Stance.SUPPORTS))
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
def reputably_contradicted(sources: list[Source]) -> bool:
|
|
287
|
+
"""True if a reputable source that was actually fetched contradicts the claim."""
|
|
288
|
+
return any(is_reputable_source(s) for s in relevant(sources, Stance.CONTRADICTS))
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
def shared_terms(claim: str, text: str) -> int:
|
|
292
|
+
"""How many of the claim's terms appear in ``text`` (numbers count double)."""
|
|
293
|
+
wanted = _terms(claim)
|
|
294
|
+
found = wanted & _terms(text)
|
|
295
|
+
return len(found) + sum(1 for t in found if any(ch.isdigit() for ch in t))
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
"""Originality checker agent.
|
|
2
|
+
|
|
3
|
+
Samples distinctive sentences, searches for near-matches and reports possible
|
|
4
|
+
matches with URLs. Every finding is heuristic (``Finding.heuristic``) with
|
|
5
|
+
``Severity.CONSIDER``; no ledger updates.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from secondread.agents.originality.agent import (
|
|
9
|
+
DEFAULT_SAMPLE_SIZE,
|
|
10
|
+
DEFAULT_THRESHOLD,
|
|
11
|
+
OriginalityAgent,
|
|
12
|
+
build_query,
|
|
13
|
+
)
|
|
14
|
+
from secondread.agents.originality.sentences import Sentence, distinctive_sentences, split_sentences
|
|
15
|
+
from secondread.agents.originality.similarity import Match, normalize_url, same_url, similarity
|
|
16
|
+
|
|
17
|
+
__all__ = [
|
|
18
|
+
"DEFAULT_SAMPLE_SIZE",
|
|
19
|
+
"DEFAULT_THRESHOLD",
|
|
20
|
+
"Match",
|
|
21
|
+
"OriginalityAgent",
|
|
22
|
+
"Sentence",
|
|
23
|
+
"build_query",
|
|
24
|
+
"distinctive_sentences",
|
|
25
|
+
"normalize_url",
|
|
26
|
+
"same_url",
|
|
27
|
+
"similarity",
|
|
28
|
+
"split_sentences",
|
|
29
|
+
]
|