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,277 @@
|
|
|
1
|
+
"""The originality checker agent.
|
|
2
|
+
|
|
3
|
+
Deterministic-first: sentences are sampled in code (``sentences``), each is
|
|
4
|
+
searched as an exact phrase, and search snippets (optionally the fetched page
|
|
5
|
+
of the most promising hit) are compared to the sentence in code
|
|
6
|
+
(``similarity``). No LLM is called. Every finding is ``heuristic=True`` with
|
|
7
|
+
``Severity.CONSIDER``: a match is a prompt to check attribution, not a verdict.
|
|
8
|
+
|
|
9
|
+
Search results and pages are untrusted data: they are only compared and
|
|
10
|
+
quoted as excerpts, never interpreted.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import logging
|
|
16
|
+
from dataclasses import dataclass, field
|
|
17
|
+
|
|
18
|
+
from secondread.agents.originality.sentences import Sentence, sample_sentences
|
|
19
|
+
from secondread.agents.originality.similarity import Match, same_url, similarity
|
|
20
|
+
from secondread.contracts.errors import BudgetExceeded, FetchError, ProviderError
|
|
21
|
+
from secondread.contracts.interfaces import ReviewContext, SearchResult
|
|
22
|
+
from secondread.contracts.models import (
|
|
23
|
+
AgentName,
|
|
24
|
+
AgentResult,
|
|
25
|
+
Evidence,
|
|
26
|
+
Finding,
|
|
27
|
+
ProgressEvent,
|
|
28
|
+
ProgressStep,
|
|
29
|
+
Severity,
|
|
30
|
+
Usage,
|
|
31
|
+
utcnow,
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
log = logging.getLogger(__name__)
|
|
35
|
+
|
|
36
|
+
DEFAULT_SAMPLE_SIZE = 8
|
|
37
|
+
"""Sentences searched per document (further capped by the search budget)."""
|
|
38
|
+
|
|
39
|
+
DEFAULT_THRESHOLD = 0.6
|
|
40
|
+
"""Minimum similarity for a possible match."""
|
|
41
|
+
|
|
42
|
+
FETCH_FLOOR_RATIO = 0.5
|
|
43
|
+
"""Default ``fetch_floor`` as a share of ``threshold``: snippets scoring at least
|
|
44
|
+
this (but under the threshold) may trigger a page fetch."""
|
|
45
|
+
|
|
46
|
+
DEFAULT_RESULTS_PER_QUERY = 5
|
|
47
|
+
MAX_QUERY_WORDS = 32
|
|
48
|
+
MAX_EVIDENCE = 3
|
|
49
|
+
|
|
50
|
+
_SEARCH_USAGE = Usage(search_calls=1)
|
|
51
|
+
_FETCH_USAGE = Usage(fetch_calls=1)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
@dataclass
|
|
55
|
+
class _Hit:
|
|
56
|
+
url: str
|
|
57
|
+
title: str
|
|
58
|
+
match: Match
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
@dataclass
|
|
62
|
+
class _Run:
|
|
63
|
+
findings: list[Finding] = field(default_factory=list)
|
|
64
|
+
usage: Usage = field(default_factory=Usage)
|
|
65
|
+
checked: int = 0
|
|
66
|
+
own_skipped: int = 0
|
|
67
|
+
fetch_failed: int = 0
|
|
68
|
+
error: str | None = None
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def build_query(sentence: Sentence) -> str:
|
|
72
|
+
"""Exact-phrase query for a sentence (first ``MAX_QUERY_WORDS`` words)."""
|
|
73
|
+
tokens = sentence.text.split()
|
|
74
|
+
phrase = " ".join(tokens[:MAX_QUERY_WORDS]).strip(" \"'“”‘’.,;:!?")
|
|
75
|
+
phrase = phrase.replace('"', "").replace("“", "").replace("”", "")
|
|
76
|
+
return f'"{phrase}"'
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
class OriginalityAgent:
|
|
80
|
+
"""Searches the web for near-copies of the document's most distinctive sentences.
|
|
81
|
+
|
|
82
|
+
Implements ``Agent`` with ``name == AgentName.ORIGINALITY``. Returns
|
|
83
|
+
findings only (no ledger updates). ``sample_size`` caps the sentences
|
|
84
|
+
searched, and never more than the searches left in the job budget.
|
|
85
|
+
With ``fetch_pages``, a hit whose snippet is suggestive (at least
|
|
86
|
+
``fetch_floor``, default half the threshold) but under ``threshold`` is
|
|
87
|
+
fetched, once per sentence, and compared against the page text. Never
|
|
88
|
+
raises for provider or budget failures: returns partial findings with ``AgentResult.error`` set.
|
|
89
|
+
"""
|
|
90
|
+
|
|
91
|
+
def __init__(
|
|
92
|
+
self,
|
|
93
|
+
*,
|
|
94
|
+
sample_size: int = DEFAULT_SAMPLE_SIZE,
|
|
95
|
+
threshold: float = DEFAULT_THRESHOLD,
|
|
96
|
+
results_per_query: int = DEFAULT_RESULTS_PER_QUERY,
|
|
97
|
+
fetch_pages: bool = True,
|
|
98
|
+
fetch_floor: float | None = None,
|
|
99
|
+
) -> None:
|
|
100
|
+
if sample_size < 0 or results_per_query <= 0:
|
|
101
|
+
raise ValueError("sample_size must be >= 0 and results_per_query > 0")
|
|
102
|
+
if fetch_floor is None:
|
|
103
|
+
fetch_floor = threshold * FETCH_FLOOR_RATIO
|
|
104
|
+
if not 0.0 < threshold <= 1.0 or not 0.0 <= fetch_floor <= threshold:
|
|
105
|
+
raise ValueError("need 0 < threshold <= 1 and 0 <= fetch_floor <= threshold")
|
|
106
|
+
self.sample_size = sample_size
|
|
107
|
+
self.threshold = threshold
|
|
108
|
+
self.results_per_query = results_per_query
|
|
109
|
+
self.fetch_pages = fetch_pages
|
|
110
|
+
self.fetch_floor = fetch_floor
|
|
111
|
+
|
|
112
|
+
@property
|
|
113
|
+
def name(self) -> str:
|
|
114
|
+
"""``AgentName.ORIGINALITY``."""
|
|
115
|
+
return AgentName.ORIGINALITY
|
|
116
|
+
|
|
117
|
+
def sample(self, ctx: ReviewContext) -> tuple[list[Sentence], int]:
|
|
118
|
+
"""``(sentences to search, sentences wanted)``: off-topic and distinctive ones.
|
|
119
|
+
|
|
120
|
+
``wanted`` is capped by ``sample_size``; the returned sample is further
|
|
121
|
+
capped by the searches left in the job budget.
|
|
122
|
+
"""
|
|
123
|
+
candidates = sample_sentences(ctx.document.text, self.sample_size)
|
|
124
|
+
wanted = len(candidates)
|
|
125
|
+
return candidates[: min(wanted, ctx.meter.searches_left())], wanted
|
|
126
|
+
|
|
127
|
+
async def run(self, ctx: ReviewContext) -> AgentResult:
|
|
128
|
+
"""Sample, search and compare; return heuristic findings."""
|
|
129
|
+
sample, wanted = self.sample(ctx)
|
|
130
|
+
state = _Run()
|
|
131
|
+
if wanted and not sample:
|
|
132
|
+
state.error = "originality check skipped: search budget exhausted"
|
|
133
|
+
self._emit(ctx, f"Checking originality of {len(sample)} sentence(s)", 0.0)
|
|
134
|
+
for i, sentence in enumerate(sample):
|
|
135
|
+
self._emit(
|
|
136
|
+
ctx,
|
|
137
|
+
f"Checking originality of sentence {i + 1} of {len(sample)}",
|
|
138
|
+
100.0 * i / max(1, len(sample)),
|
|
139
|
+
)
|
|
140
|
+
if not await self._check(ctx, sentence, state):
|
|
141
|
+
break
|
|
142
|
+
notes = self._notes(state, wanted)
|
|
143
|
+
self._emit(ctx, f"Originality: {len(state.findings)} possible match(es)", 100.0)
|
|
144
|
+
log.info(
|
|
145
|
+
"originality: %d checked, %d findings, error=%s",
|
|
146
|
+
state.checked,
|
|
147
|
+
len(state.findings),
|
|
148
|
+
bool(state.error),
|
|
149
|
+
)
|
|
150
|
+
return AgentResult(
|
|
151
|
+
agent=self.name,
|
|
152
|
+
findings=state.findings,
|
|
153
|
+
usage=state.usage,
|
|
154
|
+
error=state.error,
|
|
155
|
+
notes=notes,
|
|
156
|
+
)
|
|
157
|
+
|
|
158
|
+
async def _check(self, ctx: ReviewContext, sentence: Sentence, state: _Run) -> bool:
|
|
159
|
+
"""Check one sentence. Returns False when the run must stop."""
|
|
160
|
+
try:
|
|
161
|
+
ctx.meter.require_search()
|
|
162
|
+
except BudgetExceeded as exc:
|
|
163
|
+
state.error = f"budget exhausted: {exc}"
|
|
164
|
+
return False
|
|
165
|
+
try:
|
|
166
|
+
results = await ctx.search.search(build_query(sentence), k=self.results_per_query)
|
|
167
|
+
except ProviderError as exc:
|
|
168
|
+
state.error = f"originality search failed: {exc}"
|
|
169
|
+
return False
|
|
170
|
+
finally:
|
|
171
|
+
ctx.meter.charge(_SEARCH_USAGE)
|
|
172
|
+
state.usage = state.usage + _SEARCH_USAGE
|
|
173
|
+
state.checked += 1
|
|
174
|
+
|
|
175
|
+
hits, to_fetch = self._score_results(ctx, sentence, results, state)
|
|
176
|
+
if not hits and to_fetch is not None and self.fetch_pages:
|
|
177
|
+
try:
|
|
178
|
+
ctx.meter.require_time()
|
|
179
|
+
except BudgetExceeded as exc:
|
|
180
|
+
state.error = f"budget exhausted: {exc}"
|
|
181
|
+
return False
|
|
182
|
+
hit = await self._fetch_and_compare(ctx, sentence, to_fetch, state)
|
|
183
|
+
if hit is not None:
|
|
184
|
+
hits.append(hit)
|
|
185
|
+
if hits:
|
|
186
|
+
state.findings.append(self._finding(sentence, hits))
|
|
187
|
+
return True
|
|
188
|
+
|
|
189
|
+
def _score_results(
|
|
190
|
+
self,
|
|
191
|
+
ctx: ReviewContext,
|
|
192
|
+
sentence: Sentence,
|
|
193
|
+
results: list[SearchResult],
|
|
194
|
+
state: _Run,
|
|
195
|
+
) -> tuple[list[_Hit], SearchResult | None]:
|
|
196
|
+
hits: list[_Hit] = []
|
|
197
|
+
seen: set[str] = set()
|
|
198
|
+
best_near: tuple[float, SearchResult] | None = None
|
|
199
|
+
for result in results:
|
|
200
|
+
if same_url(result.url, ctx.document.source_url):
|
|
201
|
+
state.own_skipped += 1
|
|
202
|
+
continue
|
|
203
|
+
if any(same_url(result.url, u) for u in seen):
|
|
204
|
+
continue
|
|
205
|
+
seen.add(result.url)
|
|
206
|
+
match = max(
|
|
207
|
+
similarity(sentence.text, result.snippet),
|
|
208
|
+
similarity(sentence.text, result.title),
|
|
209
|
+
key=lambda m: m.score,
|
|
210
|
+
)
|
|
211
|
+
if match.score >= self.threshold:
|
|
212
|
+
hits.append(_Hit(result.url, result.title, match))
|
|
213
|
+
elif match.score >= self.fetch_floor and (
|
|
214
|
+
best_near is None or match.score > best_near[0]
|
|
215
|
+
):
|
|
216
|
+
best_near = (match.score, result)
|
|
217
|
+
return hits, (best_near[1] if best_near else None)
|
|
218
|
+
|
|
219
|
+
async def _fetch_and_compare(
|
|
220
|
+
self, ctx: ReviewContext, sentence: Sentence, result: SearchResult, state: _Run
|
|
221
|
+
) -> _Hit | None:
|
|
222
|
+
try:
|
|
223
|
+
page = await ctx.fetcher.fetch(result.url)
|
|
224
|
+
except FetchError:
|
|
225
|
+
state.fetch_failed += 1
|
|
226
|
+
return None
|
|
227
|
+
finally:
|
|
228
|
+
ctx.meter.charge(_FETCH_USAGE)
|
|
229
|
+
state.usage = state.usage + _FETCH_USAGE
|
|
230
|
+
if same_url(page.final_url, ctx.document.source_url):
|
|
231
|
+
state.own_skipped += 1
|
|
232
|
+
return None
|
|
233
|
+
match = similarity(sentence.text, page.text)
|
|
234
|
+
if match.score < self.threshold:
|
|
235
|
+
return None
|
|
236
|
+
return _Hit(result.url, page.title or result.title, match)
|
|
237
|
+
|
|
238
|
+
def _finding(self, sentence: Sentence, hits: list[_Hit]) -> Finding:
|
|
239
|
+
hits = sorted(hits, key=lambda h: (-h.match.score, h.url))[:MAX_EVIDENCE]
|
|
240
|
+
top = hits[0].match.score
|
|
241
|
+
now = utcnow()
|
|
242
|
+
kind = "near-verbatim" if top >= 0.95 else "close"
|
|
243
|
+
others = f" and {len(hits) - 1} other source(s)" if len(hits) > 1 else ""
|
|
244
|
+
return Finding(
|
|
245
|
+
agent=self.name,
|
|
246
|
+
severity=Severity.CONSIDER,
|
|
247
|
+
span=sentence.span,
|
|
248
|
+
message=(
|
|
249
|
+
f"Possible {kind} match online (similarity {top:.2f}) at "
|
|
250
|
+
f"{hits[0].url}{others}. Heuristic check, not proof of copying."
|
|
251
|
+
),
|
|
252
|
+
evidence=[
|
|
253
|
+
Evidence(url=h.url, title=h.title, excerpt=h.match.excerpt, retrieved_at=now)
|
|
254
|
+
for h in hits
|
|
255
|
+
],
|
|
256
|
+
suggestion="If this wording comes from the source, quote and cite it.",
|
|
257
|
+
heuristic=True,
|
|
258
|
+
)
|
|
259
|
+
|
|
260
|
+
def _notes(self, state: _Run, wanted: int) -> list[str]:
|
|
261
|
+
notes: list[str] = []
|
|
262
|
+
unchecked = wanted - state.checked
|
|
263
|
+
if unchecked > 0:
|
|
264
|
+
notes.append(f"{unchecked} sampled sentence(s) not checked for originality")
|
|
265
|
+
if state.own_skipped:
|
|
266
|
+
notes.append(f"{state.own_skipped} result(s) from the document's own URL ignored")
|
|
267
|
+
if state.fetch_failed:
|
|
268
|
+
notes.append(f"{state.fetch_failed} page(s) could not be fetched")
|
|
269
|
+
return notes
|
|
270
|
+
|
|
271
|
+
def _emit(self, ctx: ReviewContext, message: str, percent: float) -> None:
|
|
272
|
+
try:
|
|
273
|
+
ctx.emit_progress(
|
|
274
|
+
ProgressEvent(step=ProgressStep.REVIEWING, message=message, percent=percent)
|
|
275
|
+
)
|
|
276
|
+
except Exception: # progress must never break the check
|
|
277
|
+
log.warning("originality: progress callback raised", exc_info=True)
|
|
@@ -0,0 +1,311 @@
|
|
|
1
|
+
"""Deterministic sampling of distinctive sentences.
|
|
2
|
+
|
|
3
|
+
The document is split into prose sentences with exact character spans
|
|
4
|
+
(``doc.text[span.start:span.end] == sentence.text``). Headings, code blocks,
|
|
5
|
+
block quotes, table rows, sentences that mostly quote someone else and common
|
|
6
|
+
boilerplate are skipped. The rest are scored for distinctiveness (long and
|
|
7
|
+
heavy in uncommon words) and ranked; ties break by position, so the same
|
|
8
|
+
document always yields the same sample.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import re
|
|
14
|
+
from collections.abc import Iterator
|
|
15
|
+
from dataclasses import dataclass
|
|
16
|
+
|
|
17
|
+
from secondread.contracts.models import Span
|
|
18
|
+
|
|
19
|
+
MIN_WORDS = 8
|
|
20
|
+
"""Sentences with fewer words are too short to be distinctive."""
|
|
21
|
+
|
|
22
|
+
MIN_UNCOMMON_WORDS = 4
|
|
23
|
+
"""Minimum distinct uncommon words for a sentence to qualify."""
|
|
24
|
+
|
|
25
|
+
MIN_UNCOMMON_RATIO = 0.3
|
|
26
|
+
"""Minimum share of uncommon words among all words."""
|
|
27
|
+
|
|
28
|
+
MAX_QUOTED_RATIO = 0.5
|
|
29
|
+
"""Sentences with more than this share of characters inside quotes are skipped."""
|
|
30
|
+
|
|
31
|
+
_COMMON_WORDS_TEXT = """
|
|
32
|
+
a about above after again against all also although always am an and any are
|
|
33
|
+
around as at away back be because been before being below between both but by
|
|
34
|
+
can could did do does doing done down during each either else even ever every
|
|
35
|
+
few first for from further get gets getting go goes going good got great had has
|
|
36
|
+
have having he her here hers herself him himself his how however i if in into is
|
|
37
|
+
it its itself just know last less like little long made make makes many may me
|
|
38
|
+
might more most much must my myself never new next no nor not now of off often
|
|
39
|
+
on once one only or other others our ours ourselves out over own people really
|
|
40
|
+
right same say says said see seem seems she should since so some something still
|
|
41
|
+
such take than that the their theirs them themselves then there these they thing
|
|
42
|
+
things think this those though through thus time to too two under until up upon
|
|
43
|
+
us use used very want was way we well were what when where whether which while
|
|
44
|
+
who whom whose why will with within without would yes yet you your yours
|
|
45
|
+
yourself yourselves year years day days today
|
|
46
|
+
"""
|
|
47
|
+
COMMON_WORDS: frozenset[str] = frozenset(_COMMON_WORDS_TEXT.split())
|
|
48
|
+
"""Very common English words; everything else counts as uncommon."""
|
|
49
|
+
|
|
50
|
+
_BOILERPLATE = re.compile(
|
|
51
|
+
r"all rights reserved|copyright|\(c\)\s*\d{4}|©|subscribe|sign up|newsletter|"
|
|
52
|
+
r"click here|read more|table of contents|cookie|privacy policy|terms of (?:use|service)|"
|
|
53
|
+
r"share this|follow us|posted (?:on|by)|last updated|this article (?:was|has been)",
|
|
54
|
+
re.IGNORECASE,
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
_WORD = re.compile(r"[^\W_]+(?:['’][^\W_]+)*")
|
|
58
|
+
_SENTENCE_END = re.compile(r"[.!?]+[\"'”’)\]]*(?=\s|$)")
|
|
59
|
+
_BULLET = re.compile(r"^\s*(?:[-*+•]|\d{1,3}[.)])\s+")
|
|
60
|
+
_TABLE_OR_RULE = re.compile(r"^\s*(?:\||[-=*_]{3,}\s*$)")
|
|
61
|
+
_ABBREVIATIONS_TEXT = (
|
|
62
|
+
"mr mrs ms dr prof sr jr st vs etc e.g i.e cf fig no inc ltd co corp approx dept est"
|
|
63
|
+
)
|
|
64
|
+
_ABBREVIATIONS = frozenset(_ABBREVIATIONS_TEXT.split())
|
|
65
|
+
_QUOTED = re.compile(r"\"[^\"]*\"|“[^”]*”")
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
@dataclass(frozen=True)
|
|
69
|
+
class Sentence:
|
|
70
|
+
"""A candidate sentence with its exact span and distinctiveness score."""
|
|
71
|
+
|
|
72
|
+
span: Span
|
|
73
|
+
text: str
|
|
74
|
+
word_count: int
|
|
75
|
+
uncommon: int
|
|
76
|
+
score: float
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def words(text: str) -> list[str]:
|
|
80
|
+
"""Lower-cased word tokens of ``text``."""
|
|
81
|
+
return [m.group(0).lower() for m in _WORD.finditer(text)]
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def is_uncommon(word: str) -> bool:
|
|
85
|
+
"""True for words that are not very common (numbers always count)."""
|
|
86
|
+
if any(ch.isdigit() for ch in word):
|
|
87
|
+
return True
|
|
88
|
+
return len(word) >= 4 and word.replace("’", "'") not in COMMON_WORDS
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _prose_blocks(text: str) -> Iterator[tuple[int, int]]:
|
|
92
|
+
"""Yield ``(start, end)`` of prose paragraphs, skipping non-prose lines.
|
|
93
|
+
|
|
94
|
+
Headings, fenced code, indented code, block quotes, table rows and
|
|
95
|
+
horizontal rules end the current paragraph and are excluded.
|
|
96
|
+
"""
|
|
97
|
+
block_start: int | None = None
|
|
98
|
+
block_end = 0
|
|
99
|
+
in_fence = False
|
|
100
|
+
pos = 0
|
|
101
|
+
for line in text.splitlines(keepends=True):
|
|
102
|
+
line_start, pos = pos, pos + len(line)
|
|
103
|
+
body = line.rstrip("\r\n")
|
|
104
|
+
stripped = body.strip()
|
|
105
|
+
if stripped.startswith(("```", "~~~")):
|
|
106
|
+
in_fence = not in_fence
|
|
107
|
+
skip = True
|
|
108
|
+
else:
|
|
109
|
+
skip = (
|
|
110
|
+
in_fence
|
|
111
|
+
or not stripped
|
|
112
|
+
or stripped.startswith(("#", ">"))
|
|
113
|
+
or body.startswith((" ", "\t"))
|
|
114
|
+
or bool(_TABLE_OR_RULE.match(body))
|
|
115
|
+
)
|
|
116
|
+
if skip:
|
|
117
|
+
if block_start is not None:
|
|
118
|
+
yield block_start, block_end
|
|
119
|
+
block_start = None
|
|
120
|
+
continue
|
|
121
|
+
if _BULLET.match(body) and block_start is not None:
|
|
122
|
+
# Each list item is its own paragraph.
|
|
123
|
+
yield block_start, block_end
|
|
124
|
+
block_start = None
|
|
125
|
+
if block_start is None:
|
|
126
|
+
block_start = line_start
|
|
127
|
+
block_end = line_start + len(body)
|
|
128
|
+
if block_start is not None:
|
|
129
|
+
yield block_start, block_end
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def _is_boundary(block: str, match: re.Match[str]) -> bool:
|
|
133
|
+
before = block[: match.start()]
|
|
134
|
+
last = before.rsplit(None, 1)[-1] if before.split() else ""
|
|
135
|
+
if match.group(0).startswith(".") and last.lower().rstrip(".") in _ABBREVIATIONS:
|
|
136
|
+
return False
|
|
137
|
+
if len(last) == 1 and last.isupper() and match.group(0) == ".":
|
|
138
|
+
return False # an initial, e.g. "J. Smith"
|
|
139
|
+
rest = block[match.end() :].lstrip()
|
|
140
|
+
return not rest or not rest[0].islower()
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def split_sentences(text: str) -> list[tuple[Span, str]]:
|
|
144
|
+
"""Split the prose of ``text`` into sentences with exact spans.
|
|
145
|
+
|
|
146
|
+
Leading list markers and surrounding whitespace are excluded from spans.
|
|
147
|
+
"""
|
|
148
|
+
out: list[tuple[Span, str]] = []
|
|
149
|
+
for b_start, b_end in _prose_blocks(text):
|
|
150
|
+
block = text[b_start:b_end]
|
|
151
|
+
pieces: list[tuple[int, int]] = []
|
|
152
|
+
prev = 0
|
|
153
|
+
for match in _SENTENCE_END.finditer(block):
|
|
154
|
+
if _is_boundary(block, match):
|
|
155
|
+
pieces.append((prev, match.end()))
|
|
156
|
+
prev = match.end()
|
|
157
|
+
if prev < len(block):
|
|
158
|
+
pieces.append((prev, len(block)))
|
|
159
|
+
for p_start, p_end in pieces:
|
|
160
|
+
piece = block[p_start:p_end]
|
|
161
|
+
bullet = _BULLET.match(piece)
|
|
162
|
+
lead = bullet.end() if bullet else len(piece) - len(piece.lstrip())
|
|
163
|
+
trimmed = piece[lead:].rstrip()
|
|
164
|
+
if not trimmed:
|
|
165
|
+
continue
|
|
166
|
+
start = b_start + p_start + lead
|
|
167
|
+
span = Span(start=start, end=start + len(trimmed))
|
|
168
|
+
out.append((span, trimmed))
|
|
169
|
+
return out
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def quoted_ratio(text: str) -> float:
|
|
173
|
+
"""Share of characters of ``text`` inside double quotes."""
|
|
174
|
+
if not text:
|
|
175
|
+
return 0.0
|
|
176
|
+
return sum(len(m.group(0)) for m in _QUOTED.finditer(text)) / len(text)
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def score_sentence(span: Span, text: str) -> Sentence | None:
|
|
180
|
+
"""Score one sentence, or None if it is not a useful sample."""
|
|
181
|
+
tokens = words(text)
|
|
182
|
+
if len(tokens) < MIN_WORDS:
|
|
183
|
+
return None
|
|
184
|
+
if quoted_ratio(text) > MAX_QUOTED_RATIO or _BOILERPLATE.search(text):
|
|
185
|
+
return None
|
|
186
|
+
uncommon = {t for t in tokens if is_uncommon(t)}
|
|
187
|
+
ratio = sum(1 for t in tokens if is_uncommon(t)) / len(tokens)
|
|
188
|
+
if len(uncommon) < MIN_UNCOMMON_WORDS or ratio < MIN_UNCOMMON_RATIO:
|
|
189
|
+
return None
|
|
190
|
+
# Longer sentences with more distinct uncommon words are more
|
|
191
|
+
# fingerprint-like; cap length so run-ons do not dominate.
|
|
192
|
+
score = len(uncommon) * (0.5 + ratio) + min(len(tokens), 40) * 0.1
|
|
193
|
+
return Sentence(
|
|
194
|
+
span=span, text=text, word_count=len(tokens), uncommon=len(uncommon), score=score
|
|
195
|
+
)
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def distinctive_sentences(text: str) -> list[Sentence]:
|
|
199
|
+
"""All qualifying sentences, most distinctive first (ties by position).
|
|
200
|
+
|
|
201
|
+
Sentences with identical wording are kept once (the first occurrence).
|
|
202
|
+
"""
|
|
203
|
+
seen: set[tuple[str, ...]] = set()
|
|
204
|
+
ranked: list[Sentence] = []
|
|
205
|
+
for span, sentence in split_sentences(text):
|
|
206
|
+
scored = score_sentence(span, sentence)
|
|
207
|
+
if scored is None:
|
|
208
|
+
continue
|
|
209
|
+
key = tuple(words(sentence))
|
|
210
|
+
if key in seen:
|
|
211
|
+
continue
|
|
212
|
+
seen.add(key)
|
|
213
|
+
ranked.append(scored)
|
|
214
|
+
ranked.sort(key=lambda s: (-s.score, s.span.start))
|
|
215
|
+
return ranked
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
# ---------------------------------------------------------------------------
|
|
219
|
+
# Off-topic sentences
|
|
220
|
+
# ---------------------------------------------------------------------------
|
|
221
|
+
|
|
222
|
+
OFF_TOPIC_MIN_WORDS = 6
|
|
223
|
+
"""Shortest sentence considered as an off-topic candidate."""
|
|
224
|
+
|
|
225
|
+
OFF_TOPIC_MIN_UNCOMMON = 2
|
|
226
|
+
"""Minimum distinct uncommon words for an off-topic candidate."""
|
|
227
|
+
|
|
228
|
+
OFF_TOPIC_MAX_OVERLAP = 0.5
|
|
229
|
+
"""Candidates sharing more than this share of topic words with the rest of the
|
|
230
|
+
document are on-topic and not sampled as off-topic."""
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
def _stem(word: str) -> str:
|
|
234
|
+
"""Crude stem so ``battles``/``battle`` and ``wins``/``winning`` match."""
|
|
235
|
+
word = word.replace("’", "'").split("'")[0]
|
|
236
|
+
for suffix in ("ing", "ies", "ed", "es", "s"):
|
|
237
|
+
if len(word) - len(suffix) >= 4 and word.endswith(suffix):
|
|
238
|
+
return word[: -len(suffix)]
|
|
239
|
+
return word
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
def _topic_stems(text: str) -> set[str]:
|
|
243
|
+
return {_stem(t) for t in words(text) if is_uncommon(t) and not t[0].isdigit()}
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
def off_topic_sentences(text: str) -> list[Sentence]:
|
|
247
|
+
"""Sentences that share few topic words with the rest of the document.
|
|
248
|
+
|
|
249
|
+
A borrowed line (a proverb, a famous quotation, a passage lifted from
|
|
250
|
+
elsewhere) often talks about something other than the article around it
|
|
251
|
+
and is too short or too plainly worded to rank as "distinctive". These are
|
|
252
|
+
ranked least-related first (ties: longer first, then position). ``score``
|
|
253
|
+
is ``1 - overlap``.
|
|
254
|
+
"""
|
|
255
|
+
sentences = [
|
|
256
|
+
(span, sentence, _topic_stems(sentence)) for span, sentence in split_sentences(text)
|
|
257
|
+
]
|
|
258
|
+
counts: dict[str, int] = {}
|
|
259
|
+
for _span, _sentence, stems in sentences:
|
|
260
|
+
for stem in stems:
|
|
261
|
+
counts[stem] = counts.get(stem, 0) + 1
|
|
262
|
+
seen: set[tuple[str, ...]] = set()
|
|
263
|
+
ranked: list[Sentence] = []
|
|
264
|
+
for span, sentence, stems in sentences:
|
|
265
|
+
tokens = words(sentence)
|
|
266
|
+
if len(tokens) < OFF_TOPIC_MIN_WORDS or len(stems) < OFF_TOPIC_MIN_UNCOMMON:
|
|
267
|
+
continue
|
|
268
|
+
if quoted_ratio(sentence) > MAX_QUOTED_RATIO or _BOILERPLATE.search(sentence):
|
|
269
|
+
continue
|
|
270
|
+
key = tuple(tokens)
|
|
271
|
+
if key in seen:
|
|
272
|
+
continue
|
|
273
|
+
seen.add(key)
|
|
274
|
+
shared = sum(1 for stem in stems if counts[stem] > 1)
|
|
275
|
+
overlap = shared / len(stems)
|
|
276
|
+
if overlap > OFF_TOPIC_MAX_OVERLAP:
|
|
277
|
+
continue
|
|
278
|
+
ranked.append(
|
|
279
|
+
Sentence(
|
|
280
|
+
span=span,
|
|
281
|
+
text=sentence,
|
|
282
|
+
word_count=len(tokens),
|
|
283
|
+
uncommon=len(stems),
|
|
284
|
+
score=1.0 - overlap,
|
|
285
|
+
)
|
|
286
|
+
)
|
|
287
|
+
ranked.sort(key=lambda s: (-s.score, -s.word_count, s.span.start))
|
|
288
|
+
return ranked
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
def sample_sentences(text: str, limit: int) -> list[Sentence]:
|
|
292
|
+
"""Up to ``limit`` sentences to check, alternating the two rankings.
|
|
293
|
+
|
|
294
|
+
Takes the most distinctive sentence, then the least on-topic, and so on,
|
|
295
|
+
skipping repeats, so both a lifted aphorism and a lifted dense passage
|
|
296
|
+
are likely to be sampled. Deterministic for a given text.
|
|
297
|
+
"""
|
|
298
|
+
pools = [distinctive_sentences(text), off_topic_sentences(text)]
|
|
299
|
+
chosen: list[Sentence] = []
|
|
300
|
+
taken: set[Span] = set()
|
|
301
|
+
indexes = [0, 0]
|
|
302
|
+
while len(chosen) < limit and any(indexes[i] < len(pools[i]) for i in (0, 1)):
|
|
303
|
+
for i in (0, 1):
|
|
304
|
+
while indexes[i] < len(pools[i]) and pools[i][indexes[i]].span in taken:
|
|
305
|
+
indexes[i] += 1
|
|
306
|
+
if indexes[i] < len(pools[i]) and len(chosen) < limit:
|
|
307
|
+
sentence = pools[i][indexes[i]]
|
|
308
|
+
indexes[i] += 1
|
|
309
|
+
taken.add(sentence.span)
|
|
310
|
+
chosen.append(sentence)
|
|
311
|
+
return chosen
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
"""Near-match detection between a sentence and untrusted web text.
|
|
2
|
+
|
|
3
|
+
The score is the share of the sentence's words that fall inside runs of at
|
|
4
|
+
least ``min_run`` consecutive words also found, in order, in the other text
|
|
5
|
+
(``difflib.SequenceMatcher`` over word tokens, case- and punctuation-
|
|
6
|
+
insensitive). 1.0 means the sentence appears verbatim; light edits keep the
|
|
7
|
+
score high; a shared topic with different wording scores near 0.
|
|
8
|
+
|
|
9
|
+
For long texts (fetched pages) the comparison runs on the window with the most
|
|
10
|
+
shared word trigrams, which keeps it fast and locates the excerpt.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import re
|
|
16
|
+
from dataclasses import dataclass
|
|
17
|
+
from difflib import SequenceMatcher
|
|
18
|
+
from urllib.parse import urlsplit
|
|
19
|
+
|
|
20
|
+
_WORD = re.compile(r"[^\W_]+(?:['’][^\W_]+)*")
|
|
21
|
+
|
|
22
|
+
MAX_EXCERPT_CHARS = 300
|
|
23
|
+
"""Excerpts longer than this are truncated with an ellipsis."""
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@dataclass(frozen=True)
|
|
27
|
+
class _Token:
|
|
28
|
+
word: str
|
|
29
|
+
start: int
|
|
30
|
+
end: int
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@dataclass(frozen=True)
|
|
34
|
+
class Match:
|
|
35
|
+
"""Similarity of a sentence to some text, with the matching excerpt."""
|
|
36
|
+
|
|
37
|
+
score: float
|
|
38
|
+
excerpt: str
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _tokens(text: str) -> list[_Token]:
|
|
42
|
+
return [
|
|
43
|
+
_Token(m.group(0).lower().replace("’", "'"), m.start(), m.end())
|
|
44
|
+
for m in _WORD.finditer(text)
|
|
45
|
+
]
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _best_window(sentence: list[str], other: list[str], size: int, n: int) -> tuple[int, int]:
|
|
49
|
+
"""``(start, end)`` of the ``size``-token window of ``other`` sharing most n-grams."""
|
|
50
|
+
grams = {tuple(sentence[i : i + n]) for i in range(len(sentence) - n + 1)}
|
|
51
|
+
hits = [i for i in range(len(other) - n + 1) if tuple(other[i : i + n]) in grams]
|
|
52
|
+
if not hits:
|
|
53
|
+
return 0, 0
|
|
54
|
+
best_start, best_count, lo = hits[0], 0, 0
|
|
55
|
+
for hi, pos in enumerate(hits):
|
|
56
|
+
while pos - hits[lo] >= size:
|
|
57
|
+
lo += 1
|
|
58
|
+
if hi - lo + 1 > best_count:
|
|
59
|
+
best_count, best_start = hi - lo + 1, hits[lo]
|
|
60
|
+
start = max(0, best_start - len(sentence) // 2)
|
|
61
|
+
return start, min(len(other), start + size)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def similarity(
|
|
65
|
+
sentence: str, other: str, *, min_run: int = 3, max_direct_tokens: int = 400
|
|
66
|
+
) -> Match:
|
|
67
|
+
"""Score how closely ``other`` reproduces ``sentence`` (0.0 to 1.0)."""
|
|
68
|
+
s_tokens = _tokens(sentence)
|
|
69
|
+
o_tokens = _tokens(other)
|
|
70
|
+
if not s_tokens or not o_tokens:
|
|
71
|
+
return Match(0.0, "")
|
|
72
|
+
s_words = [t.word for t in s_tokens]
|
|
73
|
+
run = max(1, min(min_run, len(s_words)))
|
|
74
|
+
if len(o_tokens) > max_direct_tokens:
|
|
75
|
+
size = 2 * len(s_words) + 10
|
|
76
|
+
lo, hi = _best_window(s_words, [t.word for t in o_tokens], size, run)
|
|
77
|
+
o_tokens = o_tokens[lo:hi]
|
|
78
|
+
if not o_tokens:
|
|
79
|
+
return Match(0.0, "")
|
|
80
|
+
o_words = [t.word for t in o_tokens]
|
|
81
|
+
matcher = SequenceMatcher(None, s_words, o_words, autojunk=False)
|
|
82
|
+
blocks = [b for b in matcher.get_matching_blocks() if b.size >= run]
|
|
83
|
+
if not blocks:
|
|
84
|
+
return Match(0.0, "")
|
|
85
|
+
matched = sum(b.size for b in blocks)
|
|
86
|
+
first, last = blocks[0], blocks[-1]
|
|
87
|
+
start = o_tokens[first.b].start
|
|
88
|
+
end = o_tokens[last.b + last.size - 1].end
|
|
89
|
+
return Match(round(matched / len(s_words), 4), _truncate(other[start:end]))
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def _truncate(text: str) -> str:
|
|
93
|
+
text = " ".join(text.split())
|
|
94
|
+
if len(text) <= MAX_EXCERPT_CHARS:
|
|
95
|
+
return text
|
|
96
|
+
return text[: MAX_EXCERPT_CHARS - 1].rstrip() + "…"
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def normalize_url(url: str) -> str:
|
|
100
|
+
"""Comparable form of a URL: no scheme, ``www.``, fragment or trailing slash."""
|
|
101
|
+
parts = urlsplit(url.strip())
|
|
102
|
+
host = (parts.hostname or "").lower().removeprefix("www.")
|
|
103
|
+
if not host:
|
|
104
|
+
return url.strip().lower().rstrip("/")
|
|
105
|
+
path = parts.path.rstrip("/")
|
|
106
|
+
query = f"?{parts.query}" if parts.query else ""
|
|
107
|
+
return f"{host}{path}{query}"
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def same_url(a: str | None, b: str | None) -> bool:
|
|
111
|
+
"""True if two URLs point at the same page (by ``normalize_url``)."""
|
|
112
|
+
if not a or not b:
|
|
113
|
+
return False
|
|
114
|
+
return normalize_url(a) == normalize_url(b)
|