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
|
@@ -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]
|