sugo-cli 0.1.0a1__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.
- sugo/__init__.py +1 -0
- sugo/_internal/__init__.py +1 -0
- sugo/_internal/net/__init__.py +1 -0
- sugo/analyzers/__init__.py +1 -0
- sugo/analyzers/edges.py +425 -0
- sugo/analyzers/lexicon.py +90 -0
- sugo/api/__init__.py +1 -0
- sugo/chunkers/__init__.py +1 -0
- sugo/chunkers/sections.py +300 -0
- sugo/cleaners/__init__.py +1 -0
- sugo/cleaners/decorative_images.py +31 -0
- sugo/cleaners/headers_footers.py +56 -0
- sugo/cleaners/hidden_text.py +35 -0
- sugo/cleaners/page_numbers.py +39 -0
- sugo/cleaners/text_normalization.py +177 -0
- sugo/cleaners/tracking_urls.py +110 -0
- sugo/cleaners/ui_chrome.py +32 -0
- sugo/config/__init__.py +1 -0
- sugo/config/settings.py +376 -0
- sugo/core/__init__.py +1 -0
- sugo/core/chunks.py +74 -0
- sugo/core/constants.py +743 -0
- sugo/core/decisions.py +292 -0
- sugo/core/estimate.py +97 -0
- sugo/core/models.py +350 -0
- sugo/core/pipeline.py +586 -0
- sugo/core/text.py +55 -0
- sugo/data/providers/claude.toml +27 -0
- sugo/data/providers/gemini.toml +20 -0
- sugo/data/providers/openai.toml +214 -0
- sugo/data/tokenizers/LICENSE-tiktoken +21 -0
- sugo/data/tokenizers/README.md +11 -0
- sugo/data/tokenizers/o200k_base.tiktoken +199998 -0
- sugo/exporters/__init__.py +1 -0
- sugo/exporters/files.py +35 -0
- sugo/exporters/jsonl.py +28 -0
- sugo/exporters/markdown.py +51 -0
- sugo/extractors/__init__.py +1 -0
- sugo/extractors/image/__init__.py +1 -0
- sugo/extractors/image/classifier.py +81 -0
- sugo/extractors/image/detectors.py +129 -0
- sugo/extractors/image/extract.py +46 -0
- sugo/extractors/image/layout.py +146 -0
- sugo/extractors/image/loader.py +195 -0
- sugo/extractors/image/ocr_engine.py +145 -0
- sugo/extractors/image/preprocess.py +49 -0
- sugo/extractors/office/__init__.py +1 -0
- sugo/extractors/office/docx_reader.py +783 -0
- sugo/extractors/office/extract.py +376 -0
- sugo/extractors/office/package.py +470 -0
- sugo/extractors/office/pictures.py +246 -0
- sugo/extractors/office/pptx_reader.py +999 -0
- sugo/extractors/office/protocol.py +91 -0
- sugo/extractors/office/worker.py +112 -0
- sugo/extractors/office/xlsx_reader.py +728 -0
- sugo/extractors/office/xml_reader.py +303 -0
- sugo/extractors/pymupdf/__init__.py +1 -0
- sugo/extractors/pymupdf/extract.py +336 -0
- sugo/extractors/pymupdf/guard.py +227 -0
- sugo/extractors/pymupdf/layout.py +223 -0
- sugo/extractors/pymupdf/protocol.py +144 -0
- sugo/extractors/pymupdf/worker.py +498 -0
- sugo/extractors/reading_order.py +104 -0
- sugo/extractors/worker.py +184 -0
- sugo/i18n/__init__.py +205 -0
- sugo/i18n/catalog.py +176 -0
- sugo/i18n/locales/it/sugo.po +862 -0
- sugo/i18n/locales/it/typer.po +64 -0
- sugo/languages/__init__.py +1 -0
- sugo/languages/packs.py +100 -0
- sugo/metrics/__init__.py +1 -0
- sugo/metrics/baselines.py +78 -0
- sugo/metrics/providers.py +306 -0
- sugo/metrics/tokenizer.py +83 -0
- sugo/ports/__init__.py +1 -0
- sugo/ports/cache.py +33 -0
- sugo/ports/cli/__init__.py +1 -0
- sugo/ports/cli/app.py +769 -0
- sugo/ports/cli/clipboard.py +56 -0
- sugo/ports/cli/doctor.py +212 -0
- sugo/ports/cli/translation.py +212 -0
- sugo/ports/errors.py +150 -0
- sugo/ports/mcp/__init__.py +1 -0
- sugo/ports/mcp/access.py +139 -0
- sugo/ports/mcp/pages.py +160 -0
- sugo/ports/mcp/server.py +212 -0
- sugo/ports/mcp/tools.py +257 -0
- sugo/ports/web/__init__.py +1 -0
- sugo/ports/web/page.py +257 -0
- sugo/ports/web/server.py +399 -0
- sugo/ports/web/static/app.css +308 -0
- sugo/ports/web/static/app.js +419 -0
- sugo/ports/web/static/favicon.svg +4 -0
- sugo/ports/web/static/index.html +159 -0
- sugo/py.typed +0 -0
- sugo/serializers/__init__.py +1 -0
- sugo/serializers/escape.py +87 -0
- sugo/serializers/markdown.py +146 -0
- sugo/serializers/tables.py +122 -0
- sugo/sources/__init__.py +1 -0
- sugo/storage/__init__.py +1 -0
- sugo/storage/page_cache.py +421 -0
- sugo/storage/paths.py +49 -0
- sugo_cli-0.1.0a1.dist-info/METADATA +220 -0
- sugo_cli-0.1.0a1.dist-info/RECORD +108 -0
- sugo_cli-0.1.0a1.dist-info/WHEEL +4 -0
- sugo_cli-0.1.0a1.dist-info/entry_points.txt +3 -0
- sugo_cli-0.1.0a1.dist-info/licenses/LICENSE +661 -0
sugo/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Sugo: local conversion of documents and images into compact Markdown for LLMs and RAG."""
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Private implementation details; not part of the public API."""
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Network gateway: the only path to the internet, with offline mode, address checks and logging."""
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Analyzers: gather document-wide statistics that cleaners need (ARCHITECTURE §4, pass 2)."""
|
sugo/analyzers/edges.py
ADDED
|
@@ -0,0 +1,425 @@
|
|
|
1
|
+
"""Find running headers, footers and page numbers repeated across a document.
|
|
2
|
+
|
|
3
|
+
A running header is text printed in the same band of many pages: a title, a
|
|
4
|
+
chapter name, "Confidential", "Page 3 of 10". Seen on one page it looks like
|
|
5
|
+
any other paragraph; only the whole document shows that it repeats. So the
|
|
6
|
+
analyzer observes every page once (pass 2, ADR-006) and keeps a few bytes per
|
|
7
|
+
edge block, never the text itself, and the cleaners then decide page by page
|
|
8
|
+
with the statistics it returns (ADR-044).
|
|
9
|
+
|
|
10
|
+
Two texts are the same running header when they match after normalisation,
|
|
11
|
+
with digit runs masked: "Page 3 of 10" and "Page 4 of 10" share the pattern
|
|
12
|
+
"page # of #". Masked numbers must then either stay the same on every page
|
|
13
|
+
("10") or follow the page index ("3", "4"); a heading that merely recurs
|
|
14
|
+
("Section 3", "Section 7") does neither, and stays.
|
|
15
|
+
|
|
16
|
+
Only text a reader can see counts. Removed and flagged blocks are ignored:
|
|
17
|
+
otherwise hidden copies of a clause on every page could make its one visible
|
|
18
|
+
copy look like a repeated footer, and get it removed (THREAT_MODEL C).
|
|
19
|
+
|
|
20
|
+
Some documents declare their headers and footers themselves, as Word does:
|
|
21
|
+
the reader types them, without a position and once per section, so there is
|
|
22
|
+
no position. ``declared_texts`` gives the cleaners those, and the statistics
|
|
23
|
+
record the first page of each, whose copy stays (ADR-054).
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
import hashlib
|
|
27
|
+
import json
|
|
28
|
+
import re
|
|
29
|
+
import unicodedata
|
|
30
|
+
from collections import defaultdict
|
|
31
|
+
from collections.abc import Mapping
|
|
32
|
+
from dataclasses import dataclass, field
|
|
33
|
+
from enum import StrEnum
|
|
34
|
+
from types import MappingProxyType
|
|
35
|
+
from typing import ClassVar
|
|
36
|
+
|
|
37
|
+
from sugo.core.constants import (
|
|
38
|
+
MAX_EDGE_BLOCKS_PER_BAND,
|
|
39
|
+
MAX_EDGE_TEXT_CHARS,
|
|
40
|
+
PAGE_EDGE_BAND,
|
|
41
|
+
PAGE_NUMBER_MAX_CHARS,
|
|
42
|
+
PAGE_NUMBER_MAX_DIGITS,
|
|
43
|
+
REPEAT_MIN_DENSITY,
|
|
44
|
+
REPEAT_MIN_PAGES,
|
|
45
|
+
SUBSTANTIVE_MIN_CHARS,
|
|
46
|
+
SUBSTANTIVE_MIN_WORDS,
|
|
47
|
+
)
|
|
48
|
+
from sugo.core.models import Block, BlockType, Page
|
|
49
|
+
|
|
50
|
+
# Text blocks that can be running headers. Tables, code, chat messages and
|
|
51
|
+
# captions repeating at a page edge are content, never page furniture; so are
|
|
52
|
+
# headings: a chapter title or "Part II" at the top of a page must survive
|
|
53
|
+
# even when the running header repeats its words. Running headers are set
|
|
54
|
+
# smaller than headings, so the extractors type them as paragraphs.
|
|
55
|
+
_EDGE_TYPES = frozenset(
|
|
56
|
+
{
|
|
57
|
+
BlockType.PARAGRAPH,
|
|
58
|
+
BlockType.LIST_ITEM,
|
|
59
|
+
BlockType.HEADER,
|
|
60
|
+
BlockType.FOOTER,
|
|
61
|
+
BlockType.PAGE_NUMBER,
|
|
62
|
+
}
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
_DIGITS = re.compile(r"\d+")
|
|
66
|
+
_WORD = re.compile(r"\w+")
|
|
67
|
+
# The whole text is one page number: "7", "- 7 -" (any dash), "[vii]",
|
|
68
|
+
# "Page 7 of 90", "p. 7", "Pagina 7 di 90", "Seite 7/90". Matched on short
|
|
69
|
+
# normalised text only, and without nested repetition, so it cannot
|
|
70
|
+
# backtrack badly.
|
|
71
|
+
_PAGE_NUMBER = re.compile(
|
|
72
|
+
r"[-\u2013\u2014|\[(\s]*"
|
|
73
|
+
r"(?:(?:page|pag|pg|p|pagina|página|pàgina|seite|s)\.?\s*)?"
|
|
74
|
+
r"(?P<number>\d+|[ivxlcdm]+)"
|
|
75
|
+
r"(?:\s*(?:/|of|di|de|von|sur)\s*\d+)?"
|
|
76
|
+
r"[-\u2013\u2014|\])\s]*"
|
|
77
|
+
)
|
|
78
|
+
_ROMAN = re.compile(r"m{0,3}(?:cm|cd|d?c{0,3})(?:xc|xl|l?x{0,3})(?:ix|iv|v?i{0,3})")
|
|
79
|
+
_ROMAN_VALUES = {"i": 1, "v": 5, "x": 10, "l": 50, "c": 100, "d": 500, "m": 1000}
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
class Band(StrEnum):
|
|
83
|
+
"""Edge of the page where a block sits."""
|
|
84
|
+
|
|
85
|
+
TOP = "top"
|
|
86
|
+
BOTTOM = "bottom"
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
class NumberSystem(StrEnum):
|
|
90
|
+
"""How a page number is written; front matter often uses roman numerals."""
|
|
91
|
+
|
|
92
|
+
ARABIC = "arabic"
|
|
93
|
+
ROMAN = "roman"
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
@dataclass(frozen=True)
|
|
97
|
+
class EdgeText:
|
|
98
|
+
"""What the analyzer and the cleaners read from one block at a page edge."""
|
|
99
|
+
|
|
100
|
+
block_id: str
|
|
101
|
+
band: Band
|
|
102
|
+
# Digest of the normalised text with digit runs masked: the same for every
|
|
103
|
+
# copy of a running header, and constant in size whatever the text.
|
|
104
|
+
pattern: bytes
|
|
105
|
+
# The masked digit runs, in order: short ones as integers, long ones as a
|
|
106
|
+
# digest, so statistics never hold document text.
|
|
107
|
+
numbers: tuple[int | bytes, ...]
|
|
108
|
+
# Long enough to be a clause rather than boilerplate.
|
|
109
|
+
substantive: bool
|
|
110
|
+
# Set when the whole text is a page number.
|
|
111
|
+
page_number: tuple[NumberSystem, int] | None
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
@dataclass(frozen=True)
|
|
115
|
+
class NumberRule:
|
|
116
|
+
"""What one masked number is on every copy of a repeated pattern."""
|
|
117
|
+
|
|
118
|
+
# Set when the number follows the page: it is the page index plus this.
|
|
119
|
+
offset: int | None = None
|
|
120
|
+
# Otherwise the number itself, the same on every copy.
|
|
121
|
+
constant: int | bytes | None = None
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
@dataclass(frozen=True)
|
|
125
|
+
class RepeatedEdge:
|
|
126
|
+
"""A text pattern found at the same page edge on many pages."""
|
|
127
|
+
|
|
128
|
+
pages: int
|
|
129
|
+
first_page: int
|
|
130
|
+
# Long enough to be a clause rather than boilerplate: its first copy stays.
|
|
131
|
+
substantive: bool
|
|
132
|
+
# One per masked number, in order.
|
|
133
|
+
numbers: tuple[NumberRule, ...]
|
|
134
|
+
|
|
135
|
+
def matches(self, text: EdgeText, page_index: int) -> bool:
|
|
136
|
+
"""Whether this copy has the numbers every other copy has."""
|
|
137
|
+
if len(self.numbers) != len(text.numbers):
|
|
138
|
+
return False
|
|
139
|
+
return all(
|
|
140
|
+
found == (rule.constant if rule.offset is None else page_index + rule.offset)
|
|
141
|
+
for rule, found in zip(self.numbers, text.numbers, strict=True)
|
|
142
|
+
)
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
@dataclass(frozen=True)
|
|
146
|
+
class EdgeStats:
|
|
147
|
+
"""Document-wide edge statistics, read by the header, footer and page number cleaners."""
|
|
148
|
+
|
|
149
|
+
repeated: Mapping[tuple[Band, bytes], RepeatedEdge]
|
|
150
|
+
# (band, number system, page number minus page index) of page number
|
|
151
|
+
# sequences, so "1" on the first page and "2" on the second match.
|
|
152
|
+
page_numbers: frozenset[tuple[Band, NumberSystem, int]]
|
|
153
|
+
# (type, digest) of each header and footer a document declares -> the
|
|
154
|
+
# first page it is on: that copy stays, later identical ones go.
|
|
155
|
+
declared: Mapping[tuple[BlockType, bytes], int] = field(
|
|
156
|
+
default_factory=lambda: MappingProxyType({})
|
|
157
|
+
)
|
|
158
|
+
|
|
159
|
+
def digest(self) -> str:
|
|
160
|
+
"""A digest of these statistics, the same whatever order pages were observed in.
|
|
161
|
+
|
|
162
|
+
Cleaned pages depend on the whole document, so it is part of their
|
|
163
|
+
cache key (ADR-046): if any page reads differently, every page is
|
|
164
|
+
cleaned again.
|
|
165
|
+
"""
|
|
166
|
+
canonical = repr(
|
|
167
|
+
(
|
|
168
|
+
sorted(self.repeated.items()),
|
|
169
|
+
sorted(self.page_numbers),
|
|
170
|
+
sorted(self.declared.items()),
|
|
171
|
+
)
|
|
172
|
+
)
|
|
173
|
+
return hashlib.sha256(canonical.encode()).hexdigest()
|
|
174
|
+
|
|
175
|
+
def repeated_edge(self, text: EdgeText, page_index: int) -> RepeatedEdge | None:
|
|
176
|
+
"""Return the running header or footer this text is a copy of, if any."""
|
|
177
|
+
repeated = self.repeated.get((text.band, text.pattern))
|
|
178
|
+
if repeated is None or not repeated.matches(text, page_index):
|
|
179
|
+
return None
|
|
180
|
+
return repeated
|
|
181
|
+
|
|
182
|
+
def first_declared(self, text: "DeclaredText") -> int | None:
|
|
183
|
+
"""The first page holding a declared header or footer with the same text, if seen."""
|
|
184
|
+
return self.declared.get((text.type, text.digest))
|
|
185
|
+
|
|
186
|
+
def is_page_number(self, text: EdgeText, page_index: int) -> bool:
|
|
187
|
+
"""Whether this text is the page's number in a sequence running through the document."""
|
|
188
|
+
if text.page_number is None:
|
|
189
|
+
return False
|
|
190
|
+
system, value = text.page_number
|
|
191
|
+
return (text.band, system, value - page_index) in self.page_numbers
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
class EdgeAnalyzer:
|
|
195
|
+
"""Observes every page of a document once, in any order, then returns its edge statistics."""
|
|
196
|
+
|
|
197
|
+
name: ClassVar[str] = "edges"
|
|
198
|
+
|
|
199
|
+
def __init__(self) -> None:
|
|
200
|
+
self._seen: set[int] = set()
|
|
201
|
+
# (band, pattern) -> page index -> numbers of its first copy on that page.
|
|
202
|
+
self._texts: defaultdict[tuple[Band, bytes], dict[int, tuple[int | bytes, ...]]] = (
|
|
203
|
+
defaultdict(dict)
|
|
204
|
+
)
|
|
205
|
+
# Substantive or not is a property of the pattern, the same on every copy.
|
|
206
|
+
self._substantive: dict[tuple[Band, bytes], bool] = {}
|
|
207
|
+
self._numbers: defaultdict[tuple[Band, NumberSystem, int], set[int]] = defaultdict(set)
|
|
208
|
+
self._declared: dict[tuple[BlockType, bytes], int] = {}
|
|
209
|
+
|
|
210
|
+
def observe(self, page: Page) -> None:
|
|
211
|
+
"""Record the edge text of one page.
|
|
212
|
+
|
|
213
|
+
Raises:
|
|
214
|
+
ValueError: If a page with the same index was already observed.
|
|
215
|
+
"""
|
|
216
|
+
if page.index in self._seen:
|
|
217
|
+
raise ValueError(f"page {page.index} observed twice")
|
|
218
|
+
self._seen.add(page.index)
|
|
219
|
+
for declared in declared_texts(page):
|
|
220
|
+
first = self._declared.get((declared.type, declared.digest), page.index)
|
|
221
|
+
self._declared[(declared.type, declared.digest)] = min(first, page.index)
|
|
222
|
+
for text in edge_texts(page):
|
|
223
|
+
if text.page_number is not None:
|
|
224
|
+
system, value = text.page_number
|
|
225
|
+
self._numbers[(text.band, system, value - page.index)].add(page.index)
|
|
226
|
+
else:
|
|
227
|
+
key = (text.band, text.pattern)
|
|
228
|
+
self._texts[key].setdefault(page.index, text.numbers)
|
|
229
|
+
self._substantive[key] = text.substantive
|
|
230
|
+
|
|
231
|
+
def finalize(self) -> EdgeStats:
|
|
232
|
+
"""Return the statistics; the result does not depend on the order pages were observed."""
|
|
233
|
+
repeated: dict[tuple[Band, bytes], RepeatedEdge] = {}
|
|
234
|
+
for key, copies in self._texts.items():
|
|
235
|
+
edge = _repeated_edge(copies, substantive=self._substantive[key])
|
|
236
|
+
if edge is not None:
|
|
237
|
+
repeated[key] = edge
|
|
238
|
+
page_numbers = frozenset(key for key, pages in self._numbers.items() if _repeats(pages))
|
|
239
|
+
return EdgeStats(
|
|
240
|
+
repeated=MappingProxyType(repeated),
|
|
241
|
+
page_numbers=page_numbers,
|
|
242
|
+
declared=MappingProxyType(dict(sorted(self._declared.items()))),
|
|
243
|
+
)
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
@dataclass(frozen=True)
|
|
247
|
+
class DeclaredText:
|
|
248
|
+
"""A header, footer or page number the document declares itself, with no place on the page.
|
|
249
|
+
|
|
250
|
+
Word keeps headers and footers apart from the body, so the reader types
|
|
251
|
+
them and shows each once per section; there is no position for the edge
|
|
252
|
+
rules, only copies in later sections.
|
|
253
|
+
"""
|
|
254
|
+
|
|
255
|
+
block_id: str
|
|
256
|
+
type: BlockType
|
|
257
|
+
# Digest of the normalised text, so statistics never hold document text.
|
|
258
|
+
digest: bytes
|
|
259
|
+
# Whether the whole text is a page number.
|
|
260
|
+
page_number: bool
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
_DECLARED_TYPES = frozenset({BlockType.HEADER, BlockType.FOOTER, BlockType.PAGE_NUMBER})
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
def declared_texts(page: Page) -> list[DeclaredText]:
|
|
267
|
+
"""Return the headers, footers and page numbers a page declares without a position.
|
|
268
|
+
|
|
269
|
+
Skipped like edge texts: removed, flagged, empty or long blocks.
|
|
270
|
+
"""
|
|
271
|
+
found = []
|
|
272
|
+
for block in page.blocks:
|
|
273
|
+
if block.type not in _DECLARED_TYPES or block.bbox is not None or not _furniture(block):
|
|
274
|
+
continue
|
|
275
|
+
text = _normalised(block.text)
|
|
276
|
+
found.append(
|
|
277
|
+
DeclaredText(
|
|
278
|
+
block_id=block.id,
|
|
279
|
+
type=block.type,
|
|
280
|
+
digest=hashlib.blake2b(text.encode(), digest_size=16).digest(),
|
|
281
|
+
page_number=_page_number(text) is not None,
|
|
282
|
+
)
|
|
283
|
+
)
|
|
284
|
+
return found
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
def edge_texts(page: Page) -> list[EdgeText]:
|
|
288
|
+
"""Return the text blocks inside the top and bottom bands of a page, nearest the edge first.
|
|
289
|
+
|
|
290
|
+
Removed blocks are skipped, and so are flagged ones: hidden, illegible,
|
|
291
|
+
uncertain or suspicious text must neither count as a repeat nor be
|
|
292
|
+
removed as one.
|
|
293
|
+
"""
|
|
294
|
+
top: list[tuple[float, str, Block]] = []
|
|
295
|
+
bottom: list[tuple[float, str, Block]] = []
|
|
296
|
+
for block in page.blocks:
|
|
297
|
+
if block.type not in _EDGE_TYPES or block.bbox is None or not _furniture(block):
|
|
298
|
+
continue
|
|
299
|
+
if block.bbox.y1 <= PAGE_EDGE_BAND:
|
|
300
|
+
top.append((block.bbox.y0, block.id, block))
|
|
301
|
+
elif block.bbox.y0 >= 1.0 - PAGE_EDGE_BAND:
|
|
302
|
+
bottom.append((-block.bbox.y1, block.id, block))
|
|
303
|
+
return [
|
|
304
|
+
_edge_text(block, band)
|
|
305
|
+
for band, candidates in ((Band.TOP, top), (Band.BOTTOM, bottom))
|
|
306
|
+
for _, _, block in sorted(candidates)[:MAX_EDGE_BLOCKS_PER_BAND]
|
|
307
|
+
]
|
|
308
|
+
|
|
309
|
+
|
|
310
|
+
def _furniture(block: Block) -> bool:
|
|
311
|
+
"""Whether a block can be page furniture: seen, unflagged, short and not empty.
|
|
312
|
+
|
|
313
|
+
Longer text is a paragraph, never page furniture.
|
|
314
|
+
"""
|
|
315
|
+
return (
|
|
316
|
+
not block.removed
|
|
317
|
+
and not block.flags
|
|
318
|
+
and bool(block.text.strip())
|
|
319
|
+
and len(block.text) <= MAX_EDGE_TEXT_CHARS
|
|
320
|
+
)
|
|
321
|
+
|
|
322
|
+
|
|
323
|
+
def _normalised(text: str) -> str:
|
|
324
|
+
return " ".join(unicodedata.normalize("NFKC", text).casefold().split())
|
|
325
|
+
|
|
326
|
+
|
|
327
|
+
def _edge_text(block: Block, band: Band) -> EdgeText:
|
|
328
|
+
text = _normalised(block.text)
|
|
329
|
+
# The text between digit runs, encoded unambiguously: masking with a
|
|
330
|
+
# character such as "#" would let "ref # 1" and "ref 1 1" share a
|
|
331
|
+
# pattern with a different count of numbers.
|
|
332
|
+
between = json.dumps(_DIGITS.split(text), ensure_ascii=False)
|
|
333
|
+
return EdgeText(
|
|
334
|
+
block_id=block.id,
|
|
335
|
+
band=band,
|
|
336
|
+
pattern=hashlib.blake2b(between.encode(), digest_size=16).digest(),
|
|
337
|
+
numbers=tuple(_number(digits) for digits in _DIGITS.findall(text)),
|
|
338
|
+
substantive=_substantive(text),
|
|
339
|
+
page_number=_page_number(text),
|
|
340
|
+
)
|
|
341
|
+
|
|
342
|
+
|
|
343
|
+
def _number(digits: str) -> int | bytes:
|
|
344
|
+
value = _as_int(digits)
|
|
345
|
+
return hashlib.blake2b(digits.encode(), digest_size=8).digest() if value is None else value
|
|
346
|
+
|
|
347
|
+
|
|
348
|
+
def _substantive(text: str) -> bool:
|
|
349
|
+
# Chinese and Japanese are written without spaces, so each wide character
|
|
350
|
+
# counts as a word; other scripts without spaces (Thai, Khmer) and text
|
|
351
|
+
# spaced with blank-looking letters are caught by their length.
|
|
352
|
+
words = sum(
|
|
353
|
+
sum(unicodedata.east_asian_width(char) in "WF" for char in run) or 1
|
|
354
|
+
for run in _WORD.findall(text)
|
|
355
|
+
)
|
|
356
|
+
visible = sum(not char.isspace() for char in text)
|
|
357
|
+
return words >= SUBSTANTIVE_MIN_WORDS or visible >= SUBSTANTIVE_MIN_CHARS
|
|
358
|
+
|
|
359
|
+
|
|
360
|
+
def _page_number(text: str) -> tuple[NumberSystem, int] | None:
|
|
361
|
+
if len(text) > PAGE_NUMBER_MAX_CHARS:
|
|
362
|
+
return None
|
|
363
|
+
match = _PAGE_NUMBER.fullmatch(text)
|
|
364
|
+
if match is None:
|
|
365
|
+
return None
|
|
366
|
+
number = match["number"]
|
|
367
|
+
if number[0].isdigit():
|
|
368
|
+
value = _as_int(number)
|
|
369
|
+
return None if value is None else (NumberSystem.ARABIC, value)
|
|
370
|
+
if not _ROMAN.fullmatch(number):
|
|
371
|
+
return None
|
|
372
|
+
return NumberSystem.ROMAN, _roman_value(number)
|
|
373
|
+
|
|
374
|
+
|
|
375
|
+
def _roman_value(numeral: str) -> int:
|
|
376
|
+
values = [_ROMAN_VALUES[char] for char in numeral]
|
|
377
|
+
# A smaller numeral before a larger one is subtracted: "iv" is 4.
|
|
378
|
+
return sum(
|
|
379
|
+
-value if index + 1 < len(values) and value < values[index + 1] else value
|
|
380
|
+
for index, value in enumerate(values)
|
|
381
|
+
)
|
|
382
|
+
|
|
383
|
+
|
|
384
|
+
def _as_int(digits: str) -> int | None:
|
|
385
|
+
# Long runs are never page numbers; skipping them also avoids converting
|
|
386
|
+
# huge digit strings, which Python refuses beyond a few thousand digits.
|
|
387
|
+
return int(digits) if len(digits) <= PAGE_NUMBER_MAX_DIGITS else None
|
|
388
|
+
|
|
389
|
+
|
|
390
|
+
def _repeats(pages: set[int] | Mapping[int, object]) -> bool:
|
|
391
|
+
if len(pages) < REPEAT_MIN_PAGES:
|
|
392
|
+
return False
|
|
393
|
+
span = max(pages) - min(pages) + 1
|
|
394
|
+
return len(pages) >= REPEAT_MIN_DENSITY * span
|
|
395
|
+
|
|
396
|
+
|
|
397
|
+
def _repeated_edge(
|
|
398
|
+
copies: Mapping[int, tuple[int | bytes, ...]], *, substantive: bool
|
|
399
|
+
) -> RepeatedEdge | None:
|
|
400
|
+
if not _repeats(copies):
|
|
401
|
+
return None
|
|
402
|
+
rules: list[NumberRule] = []
|
|
403
|
+
# The pattern fixes the count of digit runs, so every copy has as many.
|
|
404
|
+
for slot in zip(*copies.values(), strict=True):
|
|
405
|
+
if len(set(slot)) == 1:
|
|
406
|
+
rules.append(NumberRule(constant=slot[0]))
|
|
407
|
+
continue
|
|
408
|
+
# A substantive copy is removed only when it is identical to the one
|
|
409
|
+
# kept: numbers that change with the page could carry content.
|
|
410
|
+
if substantive:
|
|
411
|
+
return None
|
|
412
|
+
offsets = set()
|
|
413
|
+
for value, page in zip(slot, copies, strict=True):
|
|
414
|
+
if not isinstance(value, int):
|
|
415
|
+
return None # Too long to be a page-based number.
|
|
416
|
+
offsets.add(value - page)
|
|
417
|
+
if len(offsets) != 1:
|
|
418
|
+
return None
|
|
419
|
+
rules.append(NumberRule(offset=offsets.pop()))
|
|
420
|
+
return RepeatedEdge(
|
|
421
|
+
pages=len(copies),
|
|
422
|
+
first_page=min(copies),
|
|
423
|
+
substantive=substantive,
|
|
424
|
+
numbers=tuple(rules),
|
|
425
|
+
)
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
"""Remember which words a document uses, to resolve hyphens at line ends.
|
|
2
|
+
|
|
3
|
+
"infor-" at the end of a line and "mation" at the start of the next are one
|
|
4
|
+
word only if the document writes "information" somewhere else; "well-" and
|
|
5
|
+
"known" stay hyphenated if it writes "well-known". When the document says
|
|
6
|
+
neither, the hyphen stays (ADR-025: when in doubt, leave it). So the analyzer
|
|
7
|
+
observes every page once (pass 2, ADR-006) and the text normalisation cleaner
|
|
8
|
+
asks it about each line-break hyphen.
|
|
9
|
+
|
|
10
|
+
Words are kept as short hashes, never as text, like the edge statistics, and
|
|
11
|
+
their number is capped (``LEXICON_MAX_WORDS``) so a hostile document cannot
|
|
12
|
+
grow memory without bound.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
import hashlib
|
|
16
|
+
import re
|
|
17
|
+
from dataclasses import dataclass
|
|
18
|
+
from typing import ClassVar
|
|
19
|
+
|
|
20
|
+
from sugo.core.constants import LEXICON_MAX_WORDS
|
|
21
|
+
from sugo.core.models import BlockFlag, Page
|
|
22
|
+
from sugo.core.text import COMPOUND_HYPHENS, canonical, compound_key
|
|
23
|
+
|
|
24
|
+
# A word, or words joined by hyphens on the same line: "information", "well-known".
|
|
25
|
+
_WORD = re.compile(rf"[^\W\d_]+(?:[{COMPOUND_HYPHENS}][^\W\d_]+)*")
|
|
26
|
+
_HYPHENS = re.compile(f"[{COMPOUND_HYPHENS}]")
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _key(word: str) -> bytes:
|
|
30
|
+
return hashlib.blake2b(compound_key(word).encode(), digest_size=8).digest()
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@dataclass(frozen=True)
|
|
34
|
+
class Lexicon:
|
|
35
|
+
"""The words of a document, read by the text normalisation cleaner."""
|
|
36
|
+
|
|
37
|
+
_words: frozenset[bytes]
|
|
38
|
+
|
|
39
|
+
def __contains__(self, word: object) -> bool:
|
|
40
|
+
return isinstance(word, str) and _key(word) in self._words
|
|
41
|
+
|
|
42
|
+
def digest(self) -> str:
|
|
43
|
+
"""A digest of the lexicon, the same whatever order pages were observed in.
|
|
44
|
+
|
|
45
|
+
Hyphen joins depend on the whole document, so it is part of the cache
|
|
46
|
+
key of cleaned pages (ADR-046).
|
|
47
|
+
"""
|
|
48
|
+
return hashlib.sha256(b"".join(sorted(self._words))).hexdigest()
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class LexiconAnalyzer:
|
|
52
|
+
"""Observes every page of a document once, in any order, then returns its lexicon."""
|
|
53
|
+
|
|
54
|
+
name: ClassVar[str] = "lexicon"
|
|
55
|
+
|
|
56
|
+
def __init__(self) -> None:
|
|
57
|
+
self._seen: set[int] = set()
|
|
58
|
+
self._words: set[bytes] = set()
|
|
59
|
+
|
|
60
|
+
def observe(self, page: Page) -> None:
|
|
61
|
+
"""Record the words of one page's visible text.
|
|
62
|
+
|
|
63
|
+
Raises:
|
|
64
|
+
ValueError: If a page with the same index was already observed.
|
|
65
|
+
"""
|
|
66
|
+
if page.index in self._seen:
|
|
67
|
+
raise ValueError(f"page {page.index} observed twice")
|
|
68
|
+
self._seen.add(page.index)
|
|
69
|
+
for block in page.blocks:
|
|
70
|
+
# Hidden text must not vouch for a word the reader never sees.
|
|
71
|
+
if block.removed or BlockFlag.HIDDEN in block.flags:
|
|
72
|
+
continue
|
|
73
|
+
# Composed first, so "e" + U+0301 stays inside its word.
|
|
74
|
+
for match in _WORD.finditer(canonical(block.text)):
|
|
75
|
+
word = match.group()
|
|
76
|
+
# A compound vouches for its parts too: "information-theoretic"
|
|
77
|
+
# for "information".
|
|
78
|
+
parts = _HYPHENS.split(word)
|
|
79
|
+
for found in (word, *parts) if len(parts) > 1 else (word,):
|
|
80
|
+
if len(self._words) >= LEXICON_MAX_WORDS:
|
|
81
|
+
return
|
|
82
|
+
self._words.add(_key(found))
|
|
83
|
+
|
|
84
|
+
def finalize(self) -> Lexicon:
|
|
85
|
+
"""Return the lexicon.
|
|
86
|
+
|
|
87
|
+
Below ``LEXICON_MAX_WORDS`` the result does not depend on the order
|
|
88
|
+
pages were observed in; past it, the words of the first pages win.
|
|
89
|
+
"""
|
|
90
|
+
return Lexicon(frozenset(self._words))
|
sugo/api/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Public Python API: the only stable surface for users, packs and plugins."""
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Chunkers: split cleaned documents into retrieval-sized pieces with provenance."""
|