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