sift-summarizer 0.1.95__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 (47) hide show
  1. sift/__init__.py +16 -0
  2. sift/__main__.py +7 -0
  3. sift/cache.py +412 -0
  4. sift/categories.py +348 -0
  5. sift/chunk.py +159 -0
  6. sift/cli.py +754 -0
  7. sift/config.py +529 -0
  8. sift/deadline.py +157 -0
  9. sift/detect.py +1229 -0
  10. sift/embeddings.py +133 -0
  11. sift/extract/__init__.py +417 -0
  12. sift/extract/active.py +2334 -0
  13. sift/extract/base.py +3515 -0
  14. sift/extract/email_msg.py +1352 -0
  15. sift/extract/fallback.py +899 -0
  16. sift/extract/html_doc.py +229 -0
  17. sift/extract/image.py +305 -0
  18. sift/extract/iso9660.py +771 -0
  19. sift/extract/legacy.py +1088 -0
  20. sift/extract/members.py +85 -0
  21. sift/extract/office.py +2898 -0
  22. sift/extract/pdf.py +4920 -0
  23. sift/extract/rtf.py +357 -0
  24. sift/extract/sevenzip.py +973 -0
  25. sift/extract/sqlite_db.py +289 -0
  26. sift/extract/structured.py +393 -0
  27. sift/extract/tabular.py +1370 -0
  28. sift/extract/text.py +546 -0
  29. sift/llm/__init__.py +68 -0
  30. sift/llm/base.py +1109 -0
  31. sift/llm/ollama.py +240 -0
  32. sift/llm/openai_compat.py +448 -0
  33. sift/llm/prompts.py +600 -0
  34. sift/natural_language.py +108 -0
  35. sift/report.py +389 -0
  36. sift/server.py +806 -0
  37. sift/summarize.py +4904 -0
  38. sift/webui/app.js +254 -0
  39. sift/webui/index.html +80 -0
  40. sift/webui/style.css +137 -0
  41. sift/zipguard.py +125 -0
  42. sift_summarizer-0.1.95.dist-info/METADATA +758 -0
  43. sift_summarizer-0.1.95.dist-info/RECORD +47 -0
  44. sift_summarizer-0.1.95.dist-info/WHEEL +5 -0
  45. sift_summarizer-0.1.95.dist-info/entry_points.txt +2 -0
  46. sift_summarizer-0.1.95.dist-info/licenses/LICENSE +21 -0
  47. sift_summarizer-0.1.95.dist-info/top_level.txt +1 -0
sift/__init__.py ADDED
@@ -0,0 +1,16 @@
1
+ """sift — recognize & summarize files with a local LLM (offline-first).
2
+
3
+ sift detects a file's true type (by extension *and* content), extracts its
4
+ text/structure with a purpose-built extractor, and asks a locally-hosted LLM
5
+ (Ollama, LM Studio, or MLX-LM) to classify and summarize it. Everything runs
6
+ on your machine; no data leaves the host.
7
+
8
+ The core has **zero third-party dependencies** — csv, eml, json, html, docx,
9
+ xlsx, pptx and image metadata are all parsed with the standard library. Optional
10
+ packages (pypdf, Pillow, PyYAML, rich, …) are used automatically when present to
11
+ raise fidelity, but nothing breaks when they are absent.
12
+ """
13
+ from __future__ import annotations
14
+
15
+ __version__ = "0.1.95"
16
+ __all__ = ["__version__"]
sift/__main__.py ADDED
@@ -0,0 +1,7 @@
1
+ """Enable ``python -m sift``."""
2
+ from __future__ import annotations
3
+
4
+ from .cli import main
5
+
6
+ if __name__ == "__main__":
7
+ raise SystemExit(main())
sift/cache.py ADDED
@@ -0,0 +1,412 @@
1
+ """Summary cache, keyed by what actually determines the answer.
2
+
3
+ Summarizing a directory with a local model costs minutes: the operator's 40B
4
+ took 663 seconds for nine small files. Re-running after changing one file
5
+ should cost one file, not nine. That only holds if the key is exact — a cache
6
+ that returns a summary produced by a different model, or from a file that has
7
+ since changed, is worse than no cache, because the answer looks fresh.
8
+
9
+ So the key covers everything that can change the answer: the file's content
10
+ (hashed, not its mtime — a restored backup has a new mtime and identical
11
+ bytes), the provider and model, the generation settings, and a version stamp
12
+ that is bumped whenever prompts or extraction change. Nothing else is stored,
13
+ and an unreadable or corrupt entry is simply a miss.
14
+ """
15
+ from __future__ import annotations
16
+
17
+ import hashlib
18
+ import json
19
+ import os
20
+ import tempfile
21
+ import time
22
+ from pathlib import Path
23
+ from typing import Optional
24
+
25
+ #: Bumped by hand when the storage format changes. It is not the whole story:
26
+ #: what actually decides whether an old entry is still right is the code that
27
+ #: produced it, and that is fingerprinted below.
28
+ CACHE_FORMAT = 5
29
+
30
+ #: A shape such as {"cache_version": ..., "summary": ...} can occur in a
31
+ #: project JSON document by accident. Ownership must be explicit before sift
32
+ #: deletes anything below an operator-selected SIFT_CACHE_DIR.
33
+ CACHE_OWNER = "sift-summary-cache"
34
+
35
+ _CHUNK = 1 << 20
36
+ #: Cache entries are bounded by the extracted text and metadata. Refuse to
37
+ #: parse anything larger while deciding whether clear() owns it: a user may
38
+ #: point SIFT_CACHE_DIR at a directory containing arbitrary files.
39
+ _MAX_ENTRY_FILE = 8 * 1024 * 1024
40
+
41
+ #: Modules whose behaviour determines a summary. Anything else — the CLI, the
42
+ #: report renderer, the server — changes how a summary is displayed, not what
43
+ #: it says.
44
+ _ANSWER_MODULES = ("summarize.py", "detect.py", "chunk.py",
45
+ "llm", "extract")
46
+
47
+
48
+ def _code_fingerprint() -> str:
49
+ """A digest of the code that decides what a summary says.
50
+
51
+ Relying on someone remembering to bump a constant is how a cache goes
52
+ stale: the heuristics were rewritten twice in one session and the old
53
+ answers kept being served, which looked exactly like the rewrite not
54
+ working. Deriving the version from the source makes forgetting impossible.
55
+ Unreadable sources (a zipped install) fall back to the manual number.
56
+ """
57
+ here = Path(__file__).resolve().parent
58
+ digest = hashlib.sha256()
59
+ try:
60
+ paths: list[Path] = []
61
+ for entry in _ANSWER_MODULES:
62
+ target = here / entry
63
+ paths.extend(sorted(target.rglob("*.py")) if target.is_dir()
64
+ else [target])
65
+ for path in paths:
66
+ digest.update(path.read_bytes())
67
+ except OSError: # pragma: no cover - installed without sources
68
+ return "nosrc"
69
+ return digest.hexdigest()[:12]
70
+
71
+
72
+ #: What the cache may occupy before the oldest entries are evicted. A
73
+ #: summary is a few kilobytes, so this is room for tens of thousands of
74
+ #: them — and a hard stop on an unauthenticated disk-fill.
75
+ MAX_CACHE_BYTES = 256 * 1024 * 1024
76
+
77
+ def _occupied(info) -> int:
78
+ """What an entry costs the volume, not what it says it contains.
79
+
80
+ A 900-byte summary occupies a 4 KB block. Counting st_size understated a
81
+ 60,000-entry cache by 2.6x — 94.7 MB claimed against 245.8 MB allocated.
82
+ st_blocks is in 512-byte units by POSIX definition and absent on
83
+ Windows, where st_size is the best available answer.
84
+ """
85
+ blocks = getattr(info, "st_blocks", None)
86
+ return blocks * 512 if blocks else info.st_size
87
+
88
+
89
+ CACHE_VERSION = f"{CACHE_FORMAT}-{_code_fingerprint()}"
90
+
91
+
92
+ def cache_dir() -> Path:
93
+ base = os.environ.get("SIFT_CACHE_DIR")
94
+ if base:
95
+ return Path(base)
96
+ xdg = os.environ.get("XDG_CACHE_HOME")
97
+ root = Path(xdg) if xdg else Path.home() / ".cache"
98
+ return root / "sift"
99
+
100
+
101
+ def file_digest(path: Path) -> str:
102
+ """SHA-256 of the file's bytes, read in chunks so size does not matter."""
103
+ digest = hashlib.sha256()
104
+ with path.open("rb") as fh:
105
+ while True:
106
+ block = fh.read(_CHUNK)
107
+ if not block:
108
+ break
109
+ digest.update(block)
110
+ return digest.hexdigest()
111
+
112
+
113
+ def cache_key(content_hash: str, config, provider: str, model: str,
114
+ endpoint: str = "", filename: str = "") -> str:
115
+ """One key per (content, engine, settings) triple.
116
+
117
+ The settings are in the key rather than the value because a summary made
118
+ with a 2000-token ceiling is a different summary from one made with 800,
119
+ and silently serving the wrong one is the failure this whole module exists
120
+ to avoid.
121
+
122
+ The endpoint is in the key too, and that is not belt-and-braces. When the
123
+ model is auto-picked it is still unresolved at lookup time — empty for
124
+ every server — so without the address, two different machines answering on
125
+ the same provider shared one entry. The suite caught it: a summary produced
126
+ by a stub server was served for a run against a dead one.
127
+
128
+ So is the filename, for two reasons: the extension steers detection, and
129
+ the name is in the prompt. Identical bytes named t.txt and t.csv are a
130
+ text file and a table, and the cache served the first answer for both —
131
+ "Plain text with 3 lines" for a CSV.
132
+ """
133
+ parts = [
134
+ str(CACHE_VERSION), content_hash, provider or "-", model or "-",
135
+ endpoint or "-", filename or "-",
136
+ str(config.context_tokens), str(config.max_output_tokens),
137
+ f"{config.temperature:.3f}", str(config.max_bytes),
138
+ "vision" if config.vision else "novision",
139
+ "llm" if not config.no_llm else "heuristic",
140
+ ]
141
+ # What changes the answer only when it is asked for, and only then: a
142
+ # default run's key is the one it had before these settings existed.
143
+ # The active-content scan reads further than max_bytes only where
144
+ # active_max_bytes asks it to (extract/active.py reach).
145
+ from .extract.active import reach
146
+ scanned = reach(config.max_bytes, getattr(config, "active_max_bytes", 0))
147
+ if scanned != config.max_bytes:
148
+ parts.append(f"active={scanned}")
149
+ tags = getattr(config, "tags", 0)
150
+ if tags:
151
+ parts.append(f"tags={tags}")
152
+ categories = getattr(config, "categories", None) or []
153
+ rules = getattr(config, "category_rules", None) or []
154
+ if categories or rules:
155
+ from .categories import category_set_hash
156
+ parts.append(f"categories={category_set_hash(categories, rules)}")
157
+ material = "\x1f".join(parts)
158
+ # surrogateescape, not plain utf-8: a filename holding an invalid byte
159
+ # arrives as a lone surrogate, which utf-8 refuses to encode. That raised
160
+ # a UnicodeEncodeError — a ValueError, not an OSError — straight past
161
+ # summarize_path's promise never to raise for a bad file, and the whole
162
+ # run died on a directory listing.
163
+ return hashlib.sha256(
164
+ material.encode("utf-8", "surrogateescape")).hexdigest()
165
+
166
+
167
+ class SummaryCache:
168
+ """A directory of JSON files. Absent, unreadable and stale all mean miss."""
169
+
170
+ def __init__(self, enabled: bool = True, directory: Optional[Path] = None):
171
+ self.enabled = enabled
172
+ self.dir = Path(directory) if directory else cache_dir()
173
+ self.hits = 0
174
+ self.evicted = 0
175
+ #: Running total of what the cache occupies, so the ordinary write
176
+ #: does not walk the directory. None until the first write, which
177
+ #: measures what is already there — a fresh SummaryCache per CLI run
178
+ #: otherwise starts at zero and never notices the ceiling.
179
+ self._estimate = None
180
+ self.misses = 0
181
+
182
+ def _path(self, key: str) -> Path:
183
+ # Two levels of fan-out: one directory with 100k entries is slow to
184
+ # list and unpleasant to inspect by hand.
185
+ return self.dir / key[:2] / f"{key}.json"
186
+
187
+ def get(self, key: str) -> Optional[dict]:
188
+ if not self.enabled:
189
+ return None
190
+ try:
191
+ # surrogateescape on both sides. Writing with it and reading
192
+ # without meant an entry naming an undecodable file was written
193
+ # once and never readable again: every run a miss, every run
194
+ # another write. Half a fix is worse than none — it looks like a
195
+ # working cache and is a growing pile of unreadable files.
196
+ with self._path(key).open("r", encoding="utf-8",
197
+ errors="surrogateescape") as fh:
198
+ entry = json.load(fh)
199
+ except (OSError, ValueError):
200
+ self.misses += 1
201
+ return None
202
+ if (not isinstance(entry, dict)
203
+ or entry.get("cache_owner") != CACHE_OWNER
204
+ or entry.get("cache_version") != CACHE_VERSION):
205
+ self.misses += 1
206
+ return None
207
+ self.hits += 1
208
+ # Mark it used, so eviction runs least-recently-*used* rather than
209
+ # oldest-written. Without this an attacker's fresh uploads evicted
210
+ # the entries the defender actually re-reads — the wrong end of the
211
+ # cache. Explicit rather than relying on st_atime, which most
212
+ # filesystems are mounted not to keep.
213
+ try:
214
+ os.utime(self._path(key), None)
215
+ except OSError:
216
+ pass # a read-only cache is still a cache
217
+ return entry.get("summary")
218
+
219
+ def put(self, key: str, summary: dict) -> None:
220
+ if not self.enabled:
221
+ return
222
+ target = self._path(key)
223
+ entry = {"cache_owner": CACHE_OWNER,
224
+ "cache_version": CACHE_VERSION, "stored_at": int(time.time()),
225
+ "summary": summary}
226
+ try:
227
+ target.parent.mkdir(parents=True, exist_ok=True)
228
+ # Write-then-rename: a crash mid-write must not leave a truncated
229
+ # entry that later reads as a valid summary.
230
+ temp = None
231
+ try:
232
+ with tempfile.NamedTemporaryFile(
233
+ # errors="surrogateescape": a summary can carry an
234
+ # undecodable filename, and a cache write must never
235
+ # be the thing that ends a run.
236
+ "w", encoding="utf-8", errors="surrogateescape",
237
+ dir=target.parent,
238
+ prefix=".tmp-", delete=False) as fh:
239
+ temp = Path(fh.name)
240
+ json.dump(entry, fh, ensure_ascii=False)
241
+ temp.replace(target)
242
+ finally:
243
+ # Named before the dump, not after: when the dump raised — the
244
+ # lone-surrogate case — the file was never named and never
245
+ # removed, so every run over the same input left another one
246
+ # behind, and clear() counts *.json and could not see them.
247
+ if temp is not None and temp.exists():
248
+ temp.unlink(missing_ok=True)
249
+ # Cheap in the ordinary case: the running estimate is updated
250
+ # per write and the directory is walked only when it says the
251
+ # ceiling might have been crossed.
252
+ if self._estimate is None:
253
+ # Once per process, before the first write is counted: the
254
+ # estimate started at zero, so it only ever counted *this*
255
+ # run's writes. A CLI run over 600 files writes about 1% of
256
+ # the ceiling, so _make_room was never called and the cache
257
+ # grew without bound across runs — measured at nine times the
258
+ # ceiling after twenty short runs, with zero evictions. The
259
+ # walk is right; doing it per write was what was wrong.
260
+ self._make_room()
261
+ else:
262
+ self._estimate += _occupied(target.stat())
263
+ if self._estimate > MAX_CACHE_BYTES:
264
+ self._make_room()
265
+ except (OSError, ValueError):
266
+ # ValueError covers UnicodeEncodeError: a file whose *content* is
267
+ # a lone surrogate — 13 bytes of {"\ud800": 1} — escaped this and
268
+ # killed the whole run. surrogateescape handles a name that is not
269
+ # UTF-8; nothing handles a surrogate the JSON parser handed back
270
+ # as a genuine character. A cache that cannot write is still a
271
+ # cache.
272
+ pass
273
+
274
+ def _make_room(self) -> None:
275
+ """Keep the cache under its ceiling, least recently *used* out first.
276
+
277
+ There was no ceiling and no eviction. The key includes the filename,
278
+ so thirty uploads of identical content under thirty names wrote
279
+ thirty entries, and on a host whose local disk is the small volume
280
+ that is an unauthenticated way to fill it.
281
+
282
+ Four things this has to get right, each of which it got wrong first:
283
+
284
+ Cost. Walking the directory on every write is O(entries) forever —
285
+ measured at 613 ms per summary at 60,000 entries, which the ceiling
286
+ alone guarantees the cache will reach. The walk now happens only
287
+ when the running estimate says it might be needed, so the ordinary
288
+ write costs nothing.
289
+
290
+ Size. st_size is the logical length; a 900-byte entry occupies a
291
+ 4 KB block. Summed over 60,000 small files that is 94.7 MB counted
292
+ against 245.8 MB allocated — a "256 MB" cache was 660 MB of the
293
+ small volume. st_blocks is what the volume actually loses.
294
+
295
+ Concurrency. One `stat` of a file another process has just unlinked
296
+ raised FileNotFoundError out of the listing comprehension, and the
297
+ except-clause skipped the whole trim: a cache four times over its
298
+ ceiling stayed there. Each entry is now stat'ed on its own.
299
+
300
+ Use, not age. get() never touched the file, so eviction ran on write
301
+ time — an attacker's fresh uploads evicted the defender's most
302
+ reused entries first, which is the wrong end of the cache.
303
+ """
304
+ entries = []
305
+ total = 0
306
+ for path in self._owned_paths():
307
+ try:
308
+ info = path.stat()
309
+ except OSError:
310
+ continue # vanished under us; not our business
311
+ size = _occupied(info)
312
+ entries.append((info.st_mtime, size, path))
313
+ total += size
314
+ self._estimate = total
315
+ if total <= MAX_CACHE_BYTES:
316
+ return
317
+ # Down to three quarters, not to exactly the ceiling: evicting one
318
+ # entry per write once full would make every subsequent write pay for
319
+ # a full directory scan and a delete.
320
+ goal = MAX_CACHE_BYTES * 3 // 4
321
+ for _, size, path in sorted(entries):
322
+ if total <= goal:
323
+ break
324
+ try:
325
+ path.unlink()
326
+ total -= size
327
+ self.evicted += 1
328
+ except OSError:
329
+ continue
330
+ self._estimate = total
331
+
332
+ def _owned_paths(self):
333
+ """Yield only files sift can prove it created.
334
+
335
+ Deliberately one directory deep. ``SIFT_CACHE_DIR`` may name a project
336
+ tree, so a recursive walk would turn unrelated JSON into cache data
337
+ before its contents were even considered.
338
+ """
339
+ if not self.dir.is_dir():
340
+ return
341
+ try:
342
+ buckets = list(self.dir.iterdir())
343
+ except OSError:
344
+ return
345
+ for bucket in buckets:
346
+ if (bucket.is_symlink() or not bucket.is_dir()
347
+ or len(bucket.name) != 2
348
+ or any(c not in "0123456789abcdef" for c in bucket.name)):
349
+ continue
350
+ try:
351
+ candidates = list(bucket.iterdir())
352
+ except OSError:
353
+ continue
354
+ for path in candidates:
355
+ key = path.name[:-5] if path.name.endswith(".json") else ""
356
+ if (path.is_symlink() or len(key) != 64
357
+ or any(c not in "0123456789abcdef" for c in key)
358
+ or bucket.name != key[:2]
359
+ or not self._owns_entry(path)):
360
+ continue
361
+ yield path
362
+
363
+ def clear(self) -> int:
364
+ """Remove sift entries, never the directory named by the operator.
365
+
366
+ ``SIFT_CACHE_DIR`` is an arbitrary path. Recursively deleting that
367
+ path made a typo such as a project directory destructive. An entry is
368
+ ours only when it sits at the exact two-level path produced by
369
+ :meth:`_path` and carries sift's cache envelope. Everything else is
370
+ preserved, including symlinks and nested directories.
371
+ """
372
+ if not self.dir.is_dir():
373
+ return 0
374
+ count = 0
375
+ touched = set()
376
+ for path in list(self._owned_paths()):
377
+ try:
378
+ path.unlink()
379
+ except OSError:
380
+ continue
381
+ count += 1
382
+ touched.add(path.parent)
383
+ for bucket in touched:
384
+ try:
385
+ bucket.rmdir() # only when nothing else is there
386
+ except OSError:
387
+ pass
388
+ return count
389
+
390
+ @staticmethod
391
+ def _owns_entry(path: Path) -> bool:
392
+ """Whether *path* has the bounded on-disk envelope written by put()."""
393
+ try:
394
+ if not path.is_file() or path.stat().st_size > _MAX_ENTRY_FILE:
395
+ return False
396
+ with path.open("r", encoding="utf-8",
397
+ errors="surrogateescape") as fh:
398
+ entry = json.load(fh)
399
+ except (OSError, ValueError):
400
+ return False
401
+ return (isinstance(entry, dict)
402
+ and entry.get("cache_owner") == CACHE_OWNER
403
+ and isinstance(entry.get("cache_version"), str)
404
+ and isinstance(entry.get("stored_at"), (int, float))
405
+ and not isinstance(entry.get("stored_at"), bool)
406
+ and isinstance(entry.get("summary"), dict))
407
+
408
+ def stats(self) -> str:
409
+ total = self.hits + self.misses
410
+ if not total:
411
+ return ""
412
+ return f"{self.hits}/{total} from cache"