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
@@ -0,0 +1,300 @@
1
+ """Split cleaned pages into chunks along the document's sections (ADR-047).
2
+
3
+ Blocks are added in reading order, page after page, and grouped into chunks
4
+ of at most ``max_tokens`` o200k_base tokens:
5
+
6
+ - a heading closes the chunk before it, unless that chunk holds only
7
+ headings that the new one does not close: "# Part I" followed by
8
+ "## Chapter 1" and its text stay together;
9
+ - a heading always travels with the text after it, never alone: when the
10
+ first block of a section does not fit beside its headings, that block is
11
+ split so its first piece does;
12
+ - a block that does not fit closes the chunk and starts the next one;
13
+ - a table is never split, even past the limit (ARCHITECTURE §4): half a
14
+ table cannot be read;
15
+ - a longer paragraph is split after sentence ends or line breaks, code after
16
+ line breaks, then between words, and only as a last resort inside a word.
17
+ Pieces keep the text exactly, spaces and line breaks included, except at
18
+ their two ends. After the first piece of a list item, the rest reads as
19
+ plain text, so one bullet never turns into several.
20
+
21
+ Every chunk carries the headings above it and the pages it comes from, so
22
+ it can be traced back to its source (principle 5). Removed blocks are left
23
+ out, exactly as in the Markdown output; nothing else is dropped.
24
+ """
25
+
26
+ import hashlib
27
+ import json
28
+ import re
29
+ from collections import Counter
30
+ from collections.abc import Iterator
31
+
32
+ from sugo.core.chunks import Chunk
33
+ from sugo.core.constants import CHUNK_MAX_TOKENS, CHUNK_MIN_TOKENS, OUTPUT_ID_HEX_DIGITS
34
+ from sugo.core.models import Block, BlockType, Page
35
+ from sugo.metrics.tokenizer import count_tokens
36
+ from sugo.serializers.escape import drop_controls
37
+ from sugo.serializers.markdown import render, render_widest
38
+
39
+ # Where a paragraph may be cut: after a sentence end and the spaces after
40
+ # it ("3.14" is not one), after CJK sentence punctuation (ideographic full
41
+ # stop, fullwidth ! and ?, written without a space), or after a line break.
42
+ # The match stays with the text before it. A sentence end starts only at the
43
+ # beginning of a run of punctuation and never gives characters back, so a
44
+ # long run without a space after it costs linear time, not quadratic.
45
+ _SENTENCE_BREAK = re.compile(r"(?<![.!?;:])[.!?;:]++\s+|[\u3002\uff01\uff1f]+\s*|\n+")
46
+ # Where a run of text may be cut between words: after its spaces.
47
+ _WORD_BREAK = re.compile(r"\s+")
48
+ # Below this many tokens from the limit, the open chunk is counted exactly.
49
+ _ESTIMATE_MARGIN = 16
50
+ # Headings may take at most this share of the limit before the text that
51
+ # follows them; beyond it they form a chunk of their own.
52
+ _HEADINGS_MAX_SHARE = 0.5
53
+
54
+
55
+ def document_id(source: str | None, source_sha256: str) -> str:
56
+ """The document's id: from its name when it has one, so editions share it."""
57
+ basis = f"source:{source}" if source is not None else f"sha256:{source_sha256}"
58
+ return "d_" + _hex(basis)
59
+
60
+
61
+ class SectionChunker:
62
+ """Turns the cleaned pages of one document into chunks, streaming."""
63
+
64
+ def __init__(
65
+ self,
66
+ *,
67
+ source: str | None,
68
+ source_sha256: str,
69
+ sugo_version: str,
70
+ config_hash: str,
71
+ max_tokens: int = CHUNK_MAX_TOKENS,
72
+ ) -> None:
73
+ if max_tokens < CHUNK_MIN_TOKENS:
74
+ raise ValueError(f"max_tokens must be at least {CHUNK_MIN_TOKENS}")
75
+ self._max = max_tokens
76
+ self._source = source
77
+ self._source_sha256 = source_sha256
78
+ self._sugo_version = sugo_version
79
+ self._config_hash = config_hash
80
+ self._document_id = document_id(source, source_sha256)
81
+ # Blocks of the open chunk, each with its page number (from 1).
82
+ self._open: list[tuple[Block, int]] = []
83
+ # Tokens of the open chunk, estimated from its blocks (see _append).
84
+ self._estimate = 0
85
+ # Levels of the headings in the open chunk, while it holds nothing else.
86
+ self._open_levels: list[int] | None = []
87
+ self._headings: list[tuple[int, str]] = []
88
+ # Occurrences of each text so far: repeated text gets distinct ids.
89
+ self._seen: Counter[str] = Counter()
90
+
91
+ def add(self, page: Page) -> Iterator[Chunk]:
92
+ """Add the next page; yield the chunks it completes."""
93
+ for block in page.blocks:
94
+ if block.removed:
95
+ continue
96
+ yield from self._add_block(block, page.index + 1)
97
+
98
+ def finish(self) -> Iterator[Chunk]:
99
+ """Yield the last chunk, once every page has been added."""
100
+ yield from self._close()
101
+
102
+ def _add_block(self, block: Block, page_number: int) -> Iterator[Chunk]:
103
+ tokens = count_tokens(render_widest(block))
104
+ if block.type is BlockType.HEADING:
105
+ yield from self._add_heading(block, page_number, tokens)
106
+ return
107
+ headings_only = bool(self._open) and self._open_levels is not None
108
+ if block.type is BlockType.TABLE:
109
+ # Whole, with its section's headings if any, even past the limit.
110
+ if self._open and not headings_only and not self._fits(block, tokens):
111
+ yield from self._close()
112
+ self._append(block, page_number, tokens)
113
+ self._open_levels = None
114
+ return
115
+ if headings_only and self._fits(block, tokens):
116
+ self._append(block, page_number, tokens)
117
+ self._open_levels = None
118
+ return
119
+ if headings_only or tokens > self._max:
120
+ yield from self._add_in_pieces(block, page_number, headings_only)
121
+ return
122
+ if self._open and not self._fits(block, tokens):
123
+ yield from self._close()
124
+ self._append(block, page_number, tokens)
125
+ self._open_levels = None
126
+
127
+ def _add_heading(self, block: Block, page_number: int, tokens: int) -> Iterator[Chunk]:
128
+ level = block.heading_level or 1
129
+ # A heading at the level of one already in the chunk, or above it,
130
+ # ends that heading's (empty) section: it closes the chunk, so the
131
+ # chunk's heading path always names every heading it contains.
132
+ if self._open_levels is None or any(seen >= level for seen in self._open_levels):
133
+ yield from self._close()
134
+ levels = self._open_levels if self._open_levels is not None else []
135
+ while self._headings and self._headings[-1][0] >= level:
136
+ self._headings.pop()
137
+ # Controls dropped as in the Markdown: the path is shown to people too.
138
+ title = " ".join(drop_controls(block.text).split())
139
+ if title:
140
+ self._headings.append((level, title))
141
+ self._append(block, page_number, tokens)
142
+ levels.append(level)
143
+ self._open_levels = levels
144
+
145
+ def _add_in_pieces(
146
+ self, block: Block, page_number: int, headings_only: bool
147
+ ) -> Iterator[Chunk]:
148
+ """Split a block too big for the room left, keeping a section's headings with it."""
149
+ room = self._max
150
+ if headings_only:
151
+ headings = count_tokens(render(b for b, _ in self._open))
152
+ if headings + 1 <= self._max * _HEADINGS_MAX_SHARE:
153
+ room = self._max - headings - 1
154
+ else:
155
+ yield from self._close()
156
+ headings_only = False
157
+ elif self._open:
158
+ yield from self._close()
159
+ for index, piece in enumerate(_pieces(block, self._max, first_max=room)):
160
+ if index == 0 and headings_only:
161
+ self._open.append((piece, page_number))
162
+ else:
163
+ self._open = [(piece, page_number)]
164
+ self._open_levels = None
165
+ yield from self._close()
166
+
167
+ def _fits(self, block: Block, tokens: int) -> bool:
168
+ """Whether the block fits in the open chunk; exact near the limit."""
169
+ estimate = self._estimate + 1 + tokens
170
+ if estimate <= self._max - _ESTIMATE_MARGIN:
171
+ return True
172
+ return count_tokens(render([*(b for b, _ in self._open), block])) <= self._max
173
+
174
+ def _append(self, block: Block, page_number: int, tokens: int) -> None:
175
+ # Blocks are joined by a line break, one token, and o200k_base never
176
+ # merges tokens across a line break, so the sum is a close estimate.
177
+ self._estimate = tokens if not self._open else self._estimate + 1 + tokens
178
+ self._open.append((block, page_number))
179
+
180
+ def _close(self) -> Iterator[Chunk]:
181
+ blocks, self._open, self._open_levels = self._open, [], []
182
+ self._estimate = 0
183
+ text = render(block for block, _ in blocks)
184
+ if not text:
185
+ return
186
+ occurrence = self._seen[text]
187
+ self._seen[text] += 1
188
+ yield Chunk(
189
+ id="c_" + _hex(self._document_id, text, occurrence),
190
+ document_id=self._document_id,
191
+ text=text,
192
+ tokens=count_tokens(text),
193
+ heading_path=tuple(title for _, title in self._headings),
194
+ pages=tuple(sorted({page for _, page in blocks})),
195
+ source=self._source,
196
+ source_sha256=self._source_sha256,
197
+ sugo_version=self._sugo_version,
198
+ config_hash=self._config_hash,
199
+ )
200
+
201
+
202
+ def _pieces(block: Block, max_tokens: int, *, first_max: int) -> Iterator[Block]:
203
+ """Split one long block into blocks that each render within the limit."""
204
+ code = block.type is BlockType.CODE
205
+ texts = _split(block.text, block, max_tokens, first_max=first_max, lines_only=code)
206
+ for index, text in enumerate(texts):
207
+ # The rest of a list item is plain text: a second bullet would be a
208
+ # second item. Chat messages keep their speaker on every piece.
209
+ continued = index > 0 and block.type is BlockType.LIST_ITEM
210
+ yield _with_text(block, text, index, as_paragraph=continued)
211
+
212
+
213
+ def _split(
214
+ text: str, block: Block, max_tokens: int, *, first_max: int, lines_only: bool
215
+ ) -> list[str]:
216
+ pieces: list[str] = []
217
+ current = ""
218
+ # Sum of each unit rendered alone: never below the real count, since a
219
+ # unit alone pays its own prefix and escapes; counted exactly near the limit.
220
+ estimate = 0
221
+ for unit in _units(text, lines_only):
222
+ limit = first_max if not pieces else max_tokens
223
+ current += unit
224
+ estimate += _cost(unit, block, len(pieces))
225
+ if estimate <= limit - _ESTIMATE_MARGIN:
226
+ continue
227
+ estimate = _cost(current, block, len(pieces))
228
+ if estimate <= limit:
229
+ continue
230
+ previous = current[: len(current) - len(unit)]
231
+ if previous.strip():
232
+ pieces.append(previous)
233
+ current = unit
234
+ limit = max_tokens
235
+ estimate = _cost(current, block, len(pieces))
236
+ while estimate > limit:
237
+ # One unit alone is too long: cut it until the head fits.
238
+ head, current = _cut(current, limit, block, len(pieces))
239
+ pieces.append(head)
240
+ limit = max_tokens
241
+ estimate = _cost(current, block, len(pieces))
242
+ if current.strip():
243
+ pieces.append(current)
244
+ edges = "\n" if lines_only else None
245
+ return [piece.strip(edges) for piece in pieces if piece.strip()]
246
+
247
+
248
+ def _units(text: str, lines_only: bool) -> Iterator[str]:
249
+ """Consecutive slices of ``text`` ending at a break; they join back to it exactly."""
250
+ if lines_only:
251
+ yield from text.splitlines(keepends=True)
252
+ return
253
+ start = 0
254
+ for match in _SENTENCE_BREAK.finditer(text):
255
+ if match.end() > start:
256
+ yield text[start : match.end()]
257
+ start = match.end()
258
+ if start < len(text):
259
+ yield text[start:]
260
+
261
+
262
+ def _cut(text: str, max_tokens: int, block: Block, index: int) -> tuple[str, str]:
263
+ """Split ``text`` into a head within the limit and the rest; the head is never empty."""
264
+ breaks = [match.end() for match in _WORD_BREAK.finditer(text) if match.end() < len(text)]
265
+ # The last word break whose head fits, found by bisection: a longer head
266
+ # never costs fewer tokens.
267
+ low, high = 0, len(breaks)
268
+ while low < high:
269
+ middle = (low + high) // 2
270
+ if _cost(text[: breaks[middle]], block, index) <= max_tokens:
271
+ low = middle + 1
272
+ else:
273
+ high = middle
274
+ if low:
275
+ cut = breaks[low - 1]
276
+ return text[:cut], text[cut:]
277
+ # No word fits whole (a URL, an encoded blob): cut inside it, by halving.
278
+ cut = len(text)
279
+ while cut > 1 and _cost(text[:cut], block, index) > max_tokens:
280
+ cut //= 2
281
+ return text[:cut], text[cut:]
282
+
283
+
284
+ def _cost(text: str, block: Block, index: int) -> int:
285
+ continued = index > 0 and block.type is BlockType.LIST_ITEM
286
+ return count_tokens(render_widest(_with_text(block, text, index, as_paragraph=continued)))
287
+
288
+
289
+ def _with_text(block: Block, text: str, index: int, *, as_paragraph: bool = False) -> Block:
290
+ # Only blocks with text are split: an illegible block has none.
291
+ fields = {**block.model_dump(), "id": f"{block.id}#{index}", "text": text}
292
+ if as_paragraph:
293
+ fields["type"] = BlockType.PARAGRAPH
294
+ fields["list_level"] = None
295
+ return Block.model_validate(fields)
296
+
297
+
298
+ def _hex(*parts: str | int) -> str:
299
+ encoded = json.dumps(parts, ensure_ascii=False).encode()
300
+ return hashlib.sha256(encoded).hexdigest()[:OUTPUT_ID_HEX_DIGITS]
@@ -0,0 +1 @@
1
+ """Cleaners: propose removals of noise from IR pages; the core applies them (ADR-028)."""
@@ -0,0 +1,31 @@
1
+ """Remove decorative pictures: bullets, rules and icons too small to hold content.
2
+
3
+ The Office reader flags a picture under 32 x 32 pixels ``decorative``
4
+ (ADR-054); this cleaner only proposes to remove those blocks, and the core
5
+ applies the removal as a soft deletion with the reason below (principle 3).
6
+ """
7
+
8
+ from typing import ClassVar
9
+
10
+ from sugo.core.decisions import Decision
11
+ from sugo.core.models import BlockFlag, Page
12
+
13
+ REASON = "decorative picture under 32 x 32 pixels, too small to hold content"
14
+
15
+
16
+ class DecorativeImageCleaner:
17
+ """Proposes the removal of every decorative picture still present on a page."""
18
+
19
+ name: ClassVar[str] = "decorative_images"
20
+
21
+ def propose(self, page: Page, stats: object = None) -> list[Decision]:
22
+ """Return one removal decision per decorative block not yet removed.
23
+
24
+ ``stats`` is part of the cleaner contract and unused here: this
25
+ cleaner needs no document-wide context.
26
+ """
27
+ return [
28
+ Decision(block_id=block.id, reason=REASON)
29
+ for block in page.blocks
30
+ if BlockFlag.DECORATIVE in block.flags and not block.removed
31
+ ]
@@ -0,0 +1,56 @@
1
+ """Remove running headers and footers: text repeated at the same page edge.
2
+
3
+ The edge analyzer finds which edge texts repeat across the document; this
4
+ cleaner proposes to remove each copy on a page, and the core applies the
5
+ removal as a soft deletion with a reason (principle 3, ADR-041).
6
+
7
+ Short boilerplate ("Annual Report 2026", "Confidential") goes from every
8
+ page. Longer text may be a clause someone repeated on every page so that it
9
+ would be dropped as a footer (THREAT_MODEL C): its first copy is kept, and
10
+ only the later identical copies go, so no content leaves the output.
11
+
12
+ Headers and footers a document declares itself, such as Word's, have no
13
+ place on the page and appear once per section: the first copy stays, even a
14
+ short one, which may say what the document is ("DRAFT"), and the identical
15
+ copies of later sections go (ADR-054).
16
+ """
17
+
18
+ from typing import ClassVar
19
+
20
+ from sugo.analyzers.edges import Band, EdgeStats, declared_texts, edge_texts
21
+ from sugo.core.decisions import Decision
22
+ from sugo.core.models import BlockType, Page
23
+
24
+ _NAMES = {Band.TOP: "header", Band.BOTTOM: "footer"}
25
+ _DECLARED = {
26
+ BlockType.HEADER: "running header declared by the document",
27
+ BlockType.FOOTER: "running footer declared by the document",
28
+ }
29
+
30
+
31
+ class HeaderFooterCleaner:
32
+ """Proposes the removal of running headers and footers on a page."""
33
+
34
+ name: ClassVar[str] = "headers_footers"
35
+
36
+ def propose(self, page: Page, stats: EdgeStats) -> list[Decision]:
37
+ """Return one removal decision per copy of a running header or footer."""
38
+ decisions = []
39
+ for text in edge_texts(page):
40
+ if text.page_number is not None:
41
+ continue # Left to the page number cleaner.
42
+ repeated = stats.repeated_edge(text, page.index)
43
+ if repeated is None:
44
+ continue
45
+ reason = f"running {_NAMES[text.band]} repeated on {repeated.pages} pages"
46
+ if repeated.substantive:
47
+ if page.index == repeated.first_page:
48
+ continue
49
+ reason += f"; first copy kept on page {repeated.first_page + 1}"
50
+ decisions.append(Decision(block_id=text.block_id, reason=reason))
51
+ for declared in declared_texts(page):
52
+ first = stats.first_declared(declared)
53
+ if declared.type in _DECLARED and first is not None and first < page.index:
54
+ reason = f"{_DECLARED[declared.type]}; first copy kept on page {first + 1}"
55
+ decisions.append(Decision(block_id=declared.block_id, reason=reason))
56
+ return decisions
@@ -0,0 +1,35 @@
1
+ """Remove text a reader cannot see on the page: white on white, microscopic,
2
+ off the page, drawn invisible, or the unverified text layer of a scan.
3
+
4
+ Hidden text is a classic way to slip instructions to an LLM that no person
5
+ reviewing the document would notice. The PDF extractor flags such text
6
+ ``hidden`` and keeps it apart from visible text (ADR-043); this cleaner only
7
+ proposes to remove those blocks, and the core applies the removal as a soft
8
+ deletion with the reason below (principle 3). The text stays in the IR, so
9
+ preview and explain can still show what was hidden.
10
+ """
11
+
12
+ from typing import ClassVar
13
+
14
+ from sugo.core.decisions import Decision
15
+ from sugo.core.models import BlockFlag, Page
16
+
17
+ REASON = "text not visible on the rendered page (hidden, invisible or an unverified text layer)"
18
+
19
+
20
+ class HiddenTextCleaner:
21
+ """Proposes the removal of every hidden block still present on a page."""
22
+
23
+ name: ClassVar[str] = "hidden_text"
24
+
25
+ def propose(self, page: Page, stats: object = None) -> list[Decision]:
26
+ """Return one removal decision per hidden block not yet removed.
27
+
28
+ ``stats`` is part of the cleaner contract and unused here: this
29
+ cleaner needs no document-wide context.
30
+ """
31
+ return [
32
+ Decision(block_id=block.id, reason=REASON)
33
+ for block in page.blocks
34
+ if BlockFlag.HIDDEN in block.flags and not block.removed
35
+ ]
@@ -0,0 +1,39 @@
1
+ """Remove page numbers that follow the page sequence of the document.
2
+
3
+ A lone "7" at the bottom of a page is a page number only if the pages
4
+ around it carry 6 and 8 in the same place; the edge analyzer checks that.
5
+ This cleaner proposes to remove those numbers, and the core applies the
6
+ removal as a soft deletion with the reason below (principle 3, ADR-041).
7
+ """
8
+
9
+ from typing import ClassVar
10
+
11
+ from sugo.analyzers.edges import EdgeStats, declared_texts, edge_texts
12
+ from sugo.core.decisions import Decision
13
+ from sugo.core.models import BlockType, Page
14
+
15
+ REASON = "page number"
16
+
17
+
18
+ class PageNumberCleaner:
19
+ """Proposes the removal of the page number printed on a page."""
20
+
21
+ name: ClassVar[str] = "page_numbers"
22
+
23
+ def propose(self, page: Page, stats: EdgeStats) -> list[Decision]:
24
+ """Return one removal decision per page number in the document's sequence.
25
+
26
+ A page number the document declares itself, with no place on the page
27
+ (a slide number placeholder), goes when its text is a page number.
28
+ """
29
+ decisions = [
30
+ Decision(block_id=text.block_id, reason=REASON)
31
+ for text in edge_texts(page)
32
+ if stats.is_page_number(text, page.index)
33
+ ]
34
+ decisions.extend(
35
+ Decision(block_id=declared.block_id, reason=REASON)
36
+ for declared in declared_texts(page)
37
+ if declared.type is BlockType.PAGE_NUMBER and declared.page_number
38
+ )
39
+ return decisions
@@ -0,0 +1,177 @@
1
+ """Normalise text that costs tokens without saying anything (ADR-050).
2
+
3
+ PDFs and OCR leave text that reads the same but tokenizes worse: ligatures
4
+ ("fi"), no-break and fixed-width spaces, zero-width characters, soft hyphens,
5
+ runs of spaces, trailing spaces, accents stored as two characters, words cut
6
+ by a hyphen at the end of a line, and in Italian and French a stray space
7
+ after an elision ("dell' albero"). This cleaner proposes one edit per such
8
+ spot; the core checks that each edit is of the kind it claims and applies it,
9
+ keeping the original on the block (principle 3).
10
+
11
+ Every rule keeps the letters, digits and signs a reader sees, with one
12
+ exception the core checks separately: a line-break hyphen inside a word.
13
+ "infor-" + "mation" becomes "information" only if the document writes
14
+ "information" elsewhere; "well-" + "known" becomes "well-known" if it writes
15
+ that; otherwise the hyphen stays (ADR-025: when in doubt, leave it). A soft
16
+ hyphen is always a word break, so it always joins.
17
+
18
+ Code, tables and formulas are left alone: their spacing can matter.
19
+ """
20
+
21
+ import re
22
+ import unicodedata
23
+ from typing import ClassVar
24
+
25
+ from sugo.analyzers.lexicon import Lexicon
26
+ from sugo.core.constants import INVISIBLE_CHARS, LIGATURES, MAX_EDITS_PER_BLOCK, SPECIAL_SPACES
27
+ from sugo.core.decisions import EditDecision, within_limits
28
+ from sugo.core.models import BlockType, EditKind, Page
29
+ from sugo.core.text import APOSTROPHES, HYPHEN_BREAK, is_letter
30
+ from sugo.languages.packs import elisions
31
+
32
+ # Block types whose text is prose.
33
+ TEXT_TYPES = frozenset(
34
+ {
35
+ BlockType.HEADING,
36
+ BlockType.PARAGRAPH,
37
+ BlockType.LIST_ITEM,
38
+ BlockType.CHAT_MESSAGE,
39
+ BlockType.CAPTION,
40
+ BlockType.FOOTNOTE,
41
+ }
42
+ )
43
+
44
+ REASON_HYPHEN = "word split by a hyphen at the end of a line"
45
+ REASON_COMPOUND = "hyphenated word split at the end of a line"
46
+ REASON_SPACES = "spacing normalised"
47
+ REASON_INVISIBLE = "invisible character"
48
+ REASON_LIGATURE = "typographic ligature"
49
+ REASON_COMPOSED = "accented letter stored as two characters"
50
+ REASON_ELISION = "space after an elision"
51
+
52
+ _LETTER = r"[^\W\d_]"
53
+ _SPACE = f"[ {SPECIAL_SPACES}]"
54
+ # One pass over the text; alternatives never overlap, earlier ones win.
55
+ _RULES = re.compile(
56
+ "|".join(
57
+ (
58
+ f"(?P<hyphen>{HYPHEN_BREAK})",
59
+ rf"(?P<elision>(?<=[{APOSTROPHES}]){_SPACE}+(?={_LETTER}))",
60
+ rf"(?P<trailing>(?<=\S){_SPACE}+(?=\n|\Z))",
61
+ rf"(?P<spaces>(?<=\S)(?:{_SPACE}{{2,}}|[{SPECIAL_SPACES}]))",
62
+ f"(?P<invisible>[{INVISIBLE_CHARS}])",
63
+ f"(?P<ligature>[{''.join(LIGATURES)}])",
64
+ r"(?P<composed>.[\u0300-\u036f]+)",
65
+ )
66
+ )
67
+ )
68
+ # Longer "words" are not words; capping the scan keeps it linear on hostile text.
69
+ _MAX_WORD = 64
70
+
71
+
72
+ class TextNormalizationCleaner:
73
+ """Proposes normalisation edits for the prose of a page."""
74
+
75
+ name: ClassVar[str] = "text_normalization"
76
+
77
+ def __init__(self, languages: tuple[str, ...] = ("en",)) -> None:
78
+ """
79
+ Raises:
80
+ ValueError: If a language has no pack.
81
+ """
82
+ self._elisions = elisions(languages)
83
+
84
+ def propose(self, page: Page, stats: Lexicon) -> list[EditDecision]:
85
+ """Return one edit per spot to normalise in the page's prose."""
86
+ return within_limits(
87
+ self._block_edits(block.id, block.text, stats)
88
+ for block in page.blocks
89
+ if not block.removed and block.type in TEXT_TYPES
90
+ )
91
+
92
+ def _block_edits(self, block_id: str, text: str, stats: Lexicon) -> list[EditDecision]:
93
+ decisions: list[EditDecision] = []
94
+ for match in _RULES.finditer(text):
95
+ edit = self._edit(text, match, stats)
96
+ if edit is not None:
97
+ replacement, kind, reason = edit
98
+ decisions.append(
99
+ EditDecision(
100
+ block_id=block_id,
101
+ start=match.start(),
102
+ end=match.end(),
103
+ replacement=replacement,
104
+ kind=kind,
105
+ reason=reason,
106
+ )
107
+ )
108
+ if len(decisions) > MAX_EDITS_PER_BLOCK:
109
+ # Left as it is anyway (within_limits): stop early.
110
+ break
111
+ return decisions
112
+
113
+ def _edit(
114
+ self, text: str, match: re.Match[str], lexicon: Lexicon
115
+ ) -> tuple[str, EditKind, str] | None:
116
+ rule, found = match.lastgroup, match.group()
117
+ if rule == "hyphen":
118
+ return _hyphen(text, match, lexicon)
119
+ if rule == "elision":
120
+ apostrophe = match.start() - 1
121
+ before = _word_before(text, apostrophe)
122
+ if before is None or before.casefold() not in self._elisions:
123
+ return None
124
+ # "'all' keyword" and "rock 'n' roll" close a quote: no elision.
125
+ opening = apostrophe - len(before) - 1
126
+ if opening >= 0 and text[opening] in APOSTROPHES:
127
+ return None
128
+ return "", EditKind.NORMALIZE, REASON_ELISION
129
+ if rule == "trailing":
130
+ return "", EditKind.NORMALIZE, REASON_SPACES
131
+ if rule == "spaces":
132
+ return " ", EditKind.NORMALIZE, REASON_SPACES
133
+ if rule == "invisible":
134
+ return "", EditKind.NORMALIZE, REASON_INVISIBLE
135
+ if rule == "ligature":
136
+ return LIGATURES[found], EditKind.NORMALIZE, REASON_LIGATURE
137
+ composed = unicodedata.normalize("NFC", found)
138
+ return None if composed == found else (composed, EditKind.NORMALIZE, REASON_COMPOSED)
139
+
140
+
141
+ def _hyphen(text: str, match: re.Match[str], lexicon: Lexicon) -> tuple[str, EditKind, str] | None:
142
+ """Join a word split at a line end, keep a compound's hyphen, or leave it."""
143
+ head = _word_before(text, match.start())
144
+ tail = _word_after(text, match.end())
145
+ if head is None or tail is None:
146
+ # Not inside a word: "2024-\n2025", "x²-\n", a dash at a line end.
147
+ return None
148
+ # The last hyphen before the line break; soft hyphens may come before it.
149
+ hyphen = match.group().rstrip(" \t\n")[-1]
150
+ if hyphen == "\u00ad":
151
+ # A soft hyphen marks where a word may break: always one word.
152
+ return "", EditKind.HYPHEN_JOIN, REASON_HYPHEN
153
+ if head + tail in lexicon:
154
+ return "", EditKind.HYPHEN_JOIN, REASON_HYPHEN
155
+ if f"{head}-{tail}" in lexicon:
156
+ return hyphen, EditKind.NORMALIZE, REASON_COMPOUND
157
+ return None
158
+
159
+
160
+ def _word_before(text: str, end: int) -> str | None:
161
+ """The whole word ending at ``end``, or None if there is none or it is too long."""
162
+ start = end
163
+ while start > 0 and end - start <= _MAX_WORD and is_letter(text[start - 1]):
164
+ start -= 1
165
+ if start == end or end - start > _MAX_WORD:
166
+ return None
167
+ return text[start:end]
168
+
169
+
170
+ def _word_after(text: str, start: int) -> str | None:
171
+ """The whole word starting at ``start``, or None if there is none or it is too long."""
172
+ end = start
173
+ while end < len(text) and end - start <= _MAX_WORD and is_letter(text[end]):
174
+ end += 1
175
+ if end == start or end - start > _MAX_WORD:
176
+ return None
177
+ return text[start:end]