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