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.
Files changed (108) hide show
  1. sugo/__init__.py +1 -0
  2. sugo/_internal/__init__.py +1 -0
  3. sugo/_internal/net/__init__.py +1 -0
  4. sugo/analyzers/__init__.py +1 -0
  5. sugo/analyzers/edges.py +425 -0
  6. sugo/analyzers/lexicon.py +90 -0
  7. sugo/api/__init__.py +1 -0
  8. sugo/chunkers/__init__.py +1 -0
  9. sugo/chunkers/sections.py +300 -0
  10. sugo/cleaners/__init__.py +1 -0
  11. sugo/cleaners/decorative_images.py +31 -0
  12. sugo/cleaners/headers_footers.py +56 -0
  13. sugo/cleaners/hidden_text.py +35 -0
  14. sugo/cleaners/page_numbers.py +39 -0
  15. sugo/cleaners/text_normalization.py +177 -0
  16. sugo/cleaners/tracking_urls.py +110 -0
  17. sugo/cleaners/ui_chrome.py +32 -0
  18. sugo/config/__init__.py +1 -0
  19. sugo/config/settings.py +376 -0
  20. sugo/core/__init__.py +1 -0
  21. sugo/core/chunks.py +74 -0
  22. sugo/core/constants.py +743 -0
  23. sugo/core/decisions.py +292 -0
  24. sugo/core/estimate.py +97 -0
  25. sugo/core/models.py +350 -0
  26. sugo/core/pipeline.py +586 -0
  27. sugo/core/text.py +55 -0
  28. sugo/data/providers/claude.toml +27 -0
  29. sugo/data/providers/gemini.toml +20 -0
  30. sugo/data/providers/openai.toml +214 -0
  31. sugo/data/tokenizers/LICENSE-tiktoken +21 -0
  32. sugo/data/tokenizers/README.md +11 -0
  33. sugo/data/tokenizers/o200k_base.tiktoken +199998 -0
  34. sugo/exporters/__init__.py +1 -0
  35. sugo/exporters/files.py +35 -0
  36. sugo/exporters/jsonl.py +28 -0
  37. sugo/exporters/markdown.py +51 -0
  38. sugo/extractors/__init__.py +1 -0
  39. sugo/extractors/image/__init__.py +1 -0
  40. sugo/extractors/image/classifier.py +81 -0
  41. sugo/extractors/image/detectors.py +129 -0
  42. sugo/extractors/image/extract.py +46 -0
  43. sugo/extractors/image/layout.py +146 -0
  44. sugo/extractors/image/loader.py +195 -0
  45. sugo/extractors/image/ocr_engine.py +145 -0
  46. sugo/extractors/image/preprocess.py +49 -0
  47. sugo/extractors/office/__init__.py +1 -0
  48. sugo/extractors/office/docx_reader.py +783 -0
  49. sugo/extractors/office/extract.py +376 -0
  50. sugo/extractors/office/package.py +470 -0
  51. sugo/extractors/office/pictures.py +246 -0
  52. sugo/extractors/office/pptx_reader.py +999 -0
  53. sugo/extractors/office/protocol.py +91 -0
  54. sugo/extractors/office/worker.py +112 -0
  55. sugo/extractors/office/xlsx_reader.py +728 -0
  56. sugo/extractors/office/xml_reader.py +303 -0
  57. sugo/extractors/pymupdf/__init__.py +1 -0
  58. sugo/extractors/pymupdf/extract.py +336 -0
  59. sugo/extractors/pymupdf/guard.py +227 -0
  60. sugo/extractors/pymupdf/layout.py +223 -0
  61. sugo/extractors/pymupdf/protocol.py +144 -0
  62. sugo/extractors/pymupdf/worker.py +498 -0
  63. sugo/extractors/reading_order.py +104 -0
  64. sugo/extractors/worker.py +184 -0
  65. sugo/i18n/__init__.py +205 -0
  66. sugo/i18n/catalog.py +176 -0
  67. sugo/i18n/locales/it/sugo.po +862 -0
  68. sugo/i18n/locales/it/typer.po +64 -0
  69. sugo/languages/__init__.py +1 -0
  70. sugo/languages/packs.py +100 -0
  71. sugo/metrics/__init__.py +1 -0
  72. sugo/metrics/baselines.py +78 -0
  73. sugo/metrics/providers.py +306 -0
  74. sugo/metrics/tokenizer.py +83 -0
  75. sugo/ports/__init__.py +1 -0
  76. sugo/ports/cache.py +33 -0
  77. sugo/ports/cli/__init__.py +1 -0
  78. sugo/ports/cli/app.py +769 -0
  79. sugo/ports/cli/clipboard.py +56 -0
  80. sugo/ports/cli/doctor.py +212 -0
  81. sugo/ports/cli/translation.py +212 -0
  82. sugo/ports/errors.py +150 -0
  83. sugo/ports/mcp/__init__.py +1 -0
  84. sugo/ports/mcp/access.py +139 -0
  85. sugo/ports/mcp/pages.py +160 -0
  86. sugo/ports/mcp/server.py +212 -0
  87. sugo/ports/mcp/tools.py +257 -0
  88. sugo/ports/web/__init__.py +1 -0
  89. sugo/ports/web/page.py +257 -0
  90. sugo/ports/web/server.py +399 -0
  91. sugo/ports/web/static/app.css +308 -0
  92. sugo/ports/web/static/app.js +419 -0
  93. sugo/ports/web/static/favicon.svg +4 -0
  94. sugo/ports/web/static/index.html +159 -0
  95. sugo/py.typed +0 -0
  96. sugo/serializers/__init__.py +1 -0
  97. sugo/serializers/escape.py +87 -0
  98. sugo/serializers/markdown.py +146 -0
  99. sugo/serializers/tables.py +122 -0
  100. sugo/sources/__init__.py +1 -0
  101. sugo/storage/__init__.py +1 -0
  102. sugo/storage/page_cache.py +421 -0
  103. sugo/storage/paths.py +49 -0
  104. sugo_cli-0.1.0a1.dist-info/METADATA +220 -0
  105. sugo_cli-0.1.0a1.dist-info/RECORD +108 -0
  106. sugo_cli-0.1.0a1.dist-info/WHEEL +4 -0
  107. sugo_cli-0.1.0a1.dist-info/entry_points.txt +3 -0
  108. 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)."""
@@ -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."""