kd-engine 1.0.0__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 (118) hide show
  1. kd_engine-1.0.0.dist-info/METADATA +19 -0
  2. kd_engine-1.0.0.dist-info/RECORD +118 -0
  3. kd_engine-1.0.0.dist-info/WHEEL +5 -0
  4. kd_engine-1.0.0.dist-info/entry_points.txt +2 -0
  5. kd_engine-1.0.0.dist-info/licenses/LICENSE-MIT +21 -0
  6. kd_engine-1.0.0.dist-info/top_level.txt +1 -0
  7. ytk/__init__.py +2 -0
  8. ytk/__main__.py +21 -0
  9. ytk/batch/__init__.py +39 -0
  10. ytk/batch/runner.py +1149 -0
  11. ytk/cache/__init__.py +34 -0
  12. ytk/cache/v2.py +562 -0
  13. ytk/cleaning/density.py +40 -0
  14. ytk/cleaning/fillers.py +18 -0
  15. ytk/cleaning/sentence_boundary.py +30 -0
  16. ytk/cleaning/service.py +62 -0
  17. ytk/cli.py +1281 -0
  18. ytk/commands/__init__.py +3 -0
  19. ytk/commands/merge.py +600 -0
  20. ytk/config.py +757 -0
  21. ytk/dedup/service.py +166 -0
  22. ytk/doctor.py +891 -0
  23. ytk/dotenv.py +334 -0
  24. ytk/errors.py +38 -0
  25. ytk/extraction/service.py +451 -0
  26. ytk/extraction/strategies/base.py +16 -0
  27. ytk/extraction/strategies/code_block.py +285 -0
  28. ytk/extraction/strategies/heuristic.py +791 -0
  29. ytk/extraction/strategies/local_model.py +56 -0
  30. ytk/extraction/text_context.py +177 -0
  31. ytk/extraction/visual_context.py +159 -0
  32. ytk/ingestion/adapters/__init__.py +1 -0
  33. ytk/ingestion/adapters/_common.py +865 -0
  34. ytk/ingestion/adapters/base.py +160 -0
  35. ytk/ingestion/adapters/bilibili.py +271 -0
  36. ytk/ingestion/adapters/douyin.py +445 -0
  37. ytk/ingestion/adapters/tiktok.py +102 -0
  38. ytk/ingestion/adapters/xiaohongshu.py +758 -0
  39. ytk/ingestion/adapters/youtube.py +74 -0
  40. ytk/ingestion/browser_acquired.py +220 -0
  41. ytk/ingestion/http_client.py +43 -0
  42. ytk/ingestion/local_import.py +202 -0
  43. ytk/ingestion/media.py +468 -0
  44. ytk/ingestion/providers/asr.py +902 -0
  45. ytk/ingestion/providers/base.py +13 -0
  46. ytk/ingestion/providers/dtk_client.py +816 -0
  47. ytk/ingestion/providers/minimax.py +350 -0
  48. ytk/ingestion/providers/transcript_api.py +336 -0
  49. ytk/ingestion/providers/xhs_client.py +646 -0
  50. ytk/ingestion/providers/ytdlp.py +400 -0
  51. ytk/ingestion/registry.py +189 -0
  52. ytk/ingestion/resolver.py +417 -0
  53. ytk/ingestion/search.py +56 -0
  54. ytk/ingestion/service.py +167 -0
  55. ytk/ingestion/subtitles.py +602 -0
  56. ytk/ingestion/transcript_cache.py +212 -0
  57. ytk/ingestion/url_parser.py +53 -0
  58. ytk/ingestion/video_frames.py +292 -0
  59. ytk/ingestion/ytdlp_acquire.py +754 -0
  60. ytk/logging.py +383 -0
  61. ytk/models/common.py +20 -0
  62. ytk/models/content.py +451 -0
  63. ytk/models/knowledge.py +43 -0
  64. ytk/models/migrations.py +262 -0
  65. ytk/models/skill.py +13 -0
  66. ytk/models/source.py +265 -0
  67. ytk/models/topic.py +18 -0
  68. ytk/models/transcript.py +30 -0
  69. ytk/ocr/__init__.py +65 -0
  70. ytk/ocr/engine.py +245 -0
  71. ytk/ocr/layout.py +347 -0
  72. ytk/ocr/pipeline.py +440 -0
  73. ytk/ocr/rapidocr_engine.py +144 -0
  74. ytk/pipeline/acquisition_checkpoint.py +240 -0
  75. ytk/pipeline/artifacts.py +18 -0
  76. ytk/pipeline/orchestrator.py +598 -0
  77. ytk/pipeline/processing_identity.py +18 -0
  78. ytk/pipeline/retry.py +728 -0
  79. ytk/pipeline/runners.py +578 -0
  80. ytk/pipeline/state.py +1107 -0
  81. ytk/pipeline/v2.py +1863 -0
  82. ytk/py.typed +1 -0
  83. ytk/quality/__init__.py +9 -0
  84. ytk/quality/completeness.py +260 -0
  85. ytk/renderers/base.py +9 -0
  86. ytk/renderers/citation_alignment.py +617 -0
  87. ytk/renderers/evidence_formatter.py +362 -0
  88. ytk/renderers/json_renderer.py +13 -0
  89. ytk/renderers/jsonl_renderer.py +18 -0
  90. ytk/renderers/knowledge_doc_renderer.py +1984 -0
  91. ytk/renderers/markdown_renderer.py +540 -0
  92. ytk/renderers/obsidian_renderer.py +138 -0
  93. ytk/renderers/skill_package.py +21 -0
  94. ytk/renderers/source_links.py +309 -0
  95. ytk/renderers/summary_renderer.py +163 -0
  96. ytk/renderers/v2.py +171 -0
  97. ytk/renderers/v2_jsonl.py +165 -0
  98. ytk/renderers/v2_markdown.py +477 -0
  99. ytk/renderers/v2_obsidian.py +113 -0
  100. ytk/renderers/v2_summary.py +220 -0
  101. ytk/segmentation/change_points.py +50 -0
  102. ytk/segmentation/embeddings.py +45 -0
  103. ytk/segmentation/service.py +318 -0
  104. ytk/skill_builder/__init__.py +1 -0
  105. ytk/skill_builder/builder.py +419 -0
  106. ytk/skill_builder/templates/SKILL.md.j2 +85 -0
  107. ytk/skill_builder/templates/evals/evals.json.j2 +25 -0
  108. ytk/skill_builder/templates/examples/example-input.md.j2 +5 -0
  109. ytk/skill_builder/templates/examples/example-output.md.j2 +8 -0
  110. ytk/skill_builder/templates/references/commands.md.j2 +34 -0
  111. ytk/skill_builder/templates/references/concepts.md.j2 +22 -0
  112. ytk/skill_builder/templates/references/configs.md.j2 +31 -0
  113. ytk/skill_builder/templates/references/evidence-policy.md.j2 +27 -0
  114. ytk/skill_builder/templates/references/source-summary.md.j2 +24 -0
  115. ytk/skill_builder/templates/scripts/build_skill.py.j2 +14 -0
  116. ytk/skill_builder/templates/scripts/validate_output.py.j2 +136 -0
  117. ytk/skill_builder/templates.py +29 -0
  118. ytk/text_normalization.py +44 -0
ytk/cache/__init__.py ADDED
@@ -0,0 +1,34 @@
1
+ """v2 layered cache (T12.1).
2
+
3
+ The legacy transcript cache (:mod:`ytk.ingestion.transcript_cache`) keeps its
4
+ own v1 namespace and continues to *not* cache ASR/MiniMax output. This package
5
+ is the independent v2 layer cache; the two never share files.
6
+
7
+ Public API::
8
+
9
+ from ytk.cache import LayeredCache
10
+
11
+ cache = LayeredCache()
12
+ cache.put("asr", {"source_id": "...", "material_digest": "...",
13
+ "language": "zh", "model": "large-v3", "params": {"beam": 5}},
14
+ payload_bytes)
15
+ payload = cache.get("asr", {...}) # None on MISS, never raises
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ from ytk.cache.v2 import (
21
+ CACHE_SCHEMA_VERSION,
22
+ DEFAULT_MAX_BYTES,
23
+ NAMESPACES,
24
+ LayeredCache,
25
+ sanitize_key_parts,
26
+ )
27
+
28
+ __all__ = [
29
+ "CACHE_SCHEMA_VERSION",
30
+ "DEFAULT_MAX_BYTES",
31
+ "NAMESPACES",
32
+ "LayeredCache",
33
+ "sanitize_key_parts",
34
+ ]
ytk/cache/v2.py ADDED
@@ -0,0 +1,562 @@
1
+ """T12.1 — v2 layered cache (media / subtitle / asr / ocr / generation).
2
+
3
+ Design rules frozen by ``docs/implementation/five-platform/contracts-v2.md`` §1.5:
4
+
5
+ * the fingerprint is computed from **business identity only**: source id,
6
+ material digest, language, model/version, parameters and (for generation)
7
+ the prompt hash. A signed/expiring URL is transport, never identity — every
8
+ URL-ish value or query token is stripped before hashing, so rotating a
9
+ ``xsec_token`` can never fork the cache;
10
+ * the five namespaces are isolated: the same key parts in ``subtitle`` and
11
+ ``asr`` produce different fingerprints and live in different directories;
12
+ * a corrupt entry (bad JSON, wrong digest, truncated payload) is a MISS and is
13
+ removed — the cache must never make extraction less reliable than it is;
14
+ * an unwritable cache root degrades to a no-op (log + ``None``), it never
15
+ raises into the extraction path;
16
+ * capacity is bounded with LRU eviction by last access time
17
+ (``max_bytes`` default ``YTK_CACHE_MAX_BYTES`` or 2 GiB);
18
+ * the v1 :mod:`ytk.ingestion.transcript_cache` is untouched and uses different
19
+ file names/dirs, so v2 can never overwrite a v1 entry.
20
+
21
+ The on-disk entry is a single self-validating file: a one-line JSON header
22
+ (schema, namespace, fingerprint, payload sha256/size) followed by the raw
23
+ payload bytes. Writes go to a same-directory temp file and are ``os.replace``-d
24
+ into place, so a concurrent reader sees either the previous entry or the new
25
+ one, never a half-written file.
26
+ """
27
+
28
+ from __future__ import annotations
29
+
30
+ import hashlib
31
+ import json
32
+ import logging
33
+ import os
34
+ import tempfile
35
+ import time
36
+ from collections.abc import Iterable, Mapping
37
+ from pathlib import Path
38
+ from typing import Any
39
+ from urllib.parse import parse_qsl, urlsplit, urlunsplit
40
+
41
+ LOGGER = logging.getLogger(__name__)
42
+
43
+ #: Bump when the fingerprint recipe or envelope changes (invalidates entries).
44
+ CACHE_SCHEMA_VERSION = 2
45
+
46
+ #: The five cache layers. Anything else is a programming error.
47
+ NAMESPACES: frozenset[str] = frozenset({"media", "subtitle", "asr", "ocr", "generation"})
48
+
49
+ #: Default capacity. ``YTK_CACHE_MAX_BYTES`` overrides it; invalid values are
50
+ #: ignored (never crash the CLI over a bad env var).
51
+ DEFAULT_MAX_BYTES = 2 * 1024 * 1024 * 1024
52
+ MAX_BYTES_ENV_VAR = "YTK_CACHE_MAX_BYTES"
53
+ CACHE_DIR_ENV_VAR = "YTK_V2_CACHE_DIR"
54
+ CACHE_DIR_ENV_VAR_FALLBACK = "YTK_CACHE_DIR"
55
+
56
+ _ENTRY_SUFFIX = ".entry"
57
+ _TMP_SUFFIX = ".tmp"
58
+ _MAGIC = b"YTK-CACHE-V2\n"
59
+
60
+ #: Query parameters that express access rights / expiry, never business identity.
61
+ _SENSITIVE_QUERY_PARAMS: frozenset[str] = frozenset(
62
+ {
63
+ "xsec_token",
64
+ "xsec_source",
65
+ "xsec_app_secuboid",
66
+ "mstoken",
67
+ "mstoken_v2",
68
+ "share_token",
69
+ "share_source",
70
+ "sign",
71
+ "signature",
72
+ "expires",
73
+ "expire",
74
+ "expiration",
75
+ "token",
76
+ "access_token",
77
+ "refresh_token",
78
+ "sessionid",
79
+ "session_id",
80
+ "sid",
81
+ "cookie",
82
+ "auth_key",
83
+ "authkey",
84
+ "w_rid",
85
+ "wts",
86
+ "sig",
87
+ "key",
88
+ "hdnts",
89
+ "policy",
90
+ "playauth",
91
+ "nsec_token",
92
+ "appkey",
93
+ "api_key",
94
+ "apikey",
95
+ }
96
+ )
97
+
98
+ #: Key names that carry transport/credentials rather than identity. Values for
99
+ #: these names are kept only after URL sanitisation; secret-like ones are
100
+ #: dropped outright so they can never influence the fingerprint.
101
+ _URL_KEY_NAMES: frozenset[str] = frozenset(
102
+ {
103
+ "url",
104
+ "uri",
105
+ "href",
106
+ "fetch_url",
107
+ "download_url",
108
+ "media_url",
109
+ "signed_url",
110
+ "source_url",
111
+ "canonical_url",
112
+ "request_url",
113
+ "subtitle_url",
114
+ "thumbnail_url",
115
+ }
116
+ )
117
+ _SECRET_KEY_NAMES: frozenset[str] = frozenset(
118
+ {
119
+ "cookie",
120
+ "cookies",
121
+ "cookie_header",
122
+ "authorization",
123
+ "auth",
124
+ "token",
125
+ "access_token",
126
+ "refresh_token",
127
+ "api_key",
128
+ "apikey",
129
+ "secret",
130
+ "password",
131
+ "proxy",
132
+ "headers",
133
+ "signed_url",
134
+ }
135
+ )
136
+
137
+
138
+ # --------------------------------------------------------------------------- #
139
+ # Key sanitisation / canonicalisation
140
+ # --------------------------------------------------------------------------- #
141
+ def _looks_like_http_url(value: object) -> bool:
142
+ return isinstance(value, str) and value.strip().lower().startswith(("http://", "https://"))
143
+
144
+
145
+ def _strip_sensitive_query(url: str) -> str:
146
+ """Remove access/expiry query params and embedded credentials from *url*."""
147
+ try:
148
+ parts = urlsplit(url.strip())
149
+ except ValueError:
150
+ return url
151
+ if parts.scheme not in {"http", "https"} or not parts.hostname:
152
+ return url
153
+ try:
154
+ port = parts.port
155
+ except ValueError:
156
+ port = None
157
+ host = parts.hostname.lower()
158
+ netloc = host if port in (None, 80, 443) else f"{host}:{port}"
159
+ kept = [
160
+ (name, value)
161
+ for name, value in parse_qsl(parts.query, keep_blank_values=True)
162
+ if name.lower() not in _SENSITIVE_QUERY_PARAMS and name not in _SENSITIVE_QUERY_PARAMS
163
+ ]
164
+ try:
165
+ return urlunsplit((parts.scheme, netloc, parts.path or "/", _encode_query(kept), ""))
166
+ except ValueError:
167
+ return url
168
+
169
+
170
+ def _encode_query(pairs: Iterable[tuple[str, str]]) -> str:
171
+ from urllib.parse import urlencode
172
+
173
+ return urlencode(list(pairs))
174
+
175
+
176
+ def _sanitize_value(key: str | None, value: object) -> object:
177
+ """Sanitise one value before it can reach the fingerprint.
178
+
179
+ * dict/list/tuple/set are walked recursively;
180
+ * secret-named keys are dropped by :func:`sanitize_key_parts` before this;
181
+ * every http(s) URL — or any string that parses as one — loses its
182
+ access/expiry query parameters;
183
+ * ``bytes``/``Path`` are normalised to digest/string.
184
+ """
185
+ if isinstance(value, str):
186
+ if _looks_like_http_url(value):
187
+ return _strip_sensitive_query(value)
188
+ return value
189
+ if isinstance(value, bytes | bytearray | memoryview):
190
+ return {"sha256": hashlib.sha256(bytes(value)).hexdigest()}
191
+ if isinstance(value, Path):
192
+ return str(value)
193
+ if isinstance(value, Mapping):
194
+ return {
195
+ str(key2): _sanitize_value(None if key2 is None else str(key2), value2)
196
+ for key2, value2 in value.items()
197
+ }
198
+ if isinstance(value, (list, tuple)):
199
+ return [_sanitize_value(key, item) for item in value]
200
+ if isinstance(value, (set, frozenset)):
201
+ normalised = [_sanitize_value(key, item) for item in value]
202
+ return sorted(normalised, key=lambda item: _canonical_json(item))
203
+ if isinstance(value, float) and value.is_integer():
204
+ return int(value)
205
+ if isinstance(value, (int, float, bool)) or value is None:
206
+ return value
207
+ return str(value)
208
+
209
+
210
+ def sanitize_key_parts(key_parts: Mapping[str, object] | None) -> dict[str, object]:
211
+ """Return a credential-free, canonical view of *key_parts*.
212
+
213
+ - secret-like key names are dropped;
214
+ - values of URL-like key names are stripped of access/expiry parameters;
215
+ - every http(s) URL value is stripped, whatever the key;
216
+ - non-JSON values (bytes/Path/set) are normalised deterministically.
217
+ """
218
+ if key_parts is None:
219
+ return {}
220
+ if not isinstance(key_parts, Mapping):
221
+ raise TypeError("key_parts must be a mapping")
222
+ clean: dict[str, object] = {}
223
+ for raw_key, raw_value in key_parts.items():
224
+ key = str(raw_key)
225
+ lowered = key.lower()
226
+ if lowered in _SECRET_KEY_NAMES:
227
+ continue
228
+ if lowered in _URL_KEY_NAMES and raw_value is None:
229
+ continue
230
+ clean[key] = _sanitize_value(key, raw_value)
231
+ return clean
232
+
233
+
234
+ def _canonical_json(payload: object) -> str:
235
+ return json.dumps(payload, sort_keys=True, separators=(",", ":"), ensure_ascii=False)
236
+
237
+
238
+ # --------------------------------------------------------------------------- #
239
+ # Defaults
240
+ # --------------------------------------------------------------------------- #
241
+ def _default_root() -> Path:
242
+ override = os.environ.get(CACHE_DIR_ENV_VAR) or os.environ.get(CACHE_DIR_ENV_VAR_FALLBACK)
243
+ if override:
244
+ return Path(override).expanduser()
245
+ xdg = os.environ.get("XDG_CACHE_HOME")
246
+ if xdg:
247
+ return Path(xdg).expanduser() / "ytk" / "v2"
248
+ return Path.home() / ".cache" / "ytk" / "v2"
249
+
250
+
251
+ def _max_bytes_from_env() -> int:
252
+ raw = os.environ.get(MAX_BYTES_ENV_VAR)
253
+ if raw in (None, ""):
254
+ return DEFAULT_MAX_BYTES
255
+ try:
256
+ value = int(raw)
257
+ except (TypeError, ValueError):
258
+ LOGGER.warning("ignoring invalid %s=%r (using default capacity)", MAX_BYTES_ENV_VAR, raw)
259
+ return DEFAULT_MAX_BYTES
260
+ if value < 0:
261
+ LOGGER.warning("ignoring negative %s=%r (using default capacity)", MAX_BYTES_ENV_VAR, raw)
262
+ return DEFAULT_MAX_BYTES
263
+ return value
264
+
265
+
266
+ # --------------------------------------------------------------------------- #
267
+ # Cache
268
+ # --------------------------------------------------------------------------- #
269
+ class LayeredCache:
270
+ """File-per-fingerprint layered cache with atomic writes and LRU capacity."""
271
+
272
+ def __init__(
273
+ self,
274
+ root: Path | str | None = None,
275
+ enabled: bool = True,
276
+ *,
277
+ max_bytes: int | None = None,
278
+ ) -> None:
279
+ self._root = Path(root).expanduser() if root is not None else _default_root()
280
+ self._enabled = bool(enabled)
281
+ self._max_bytes = _max_bytes_from_env() if max_bytes is None else int(max_bytes)
282
+
283
+ # -------------------------------------------------------------- properties
284
+ @property
285
+ def root(self) -> Path:
286
+ return self._root
287
+
288
+ @property
289
+ def enabled(self) -> bool:
290
+ return self._enabled
291
+
292
+ @property
293
+ def max_bytes(self) -> int:
294
+ return self._max_bytes
295
+
296
+ # ------------------------------------------------------------- fingerprint
297
+ def fingerprint(self, namespace: str, key_parts: Mapping[str, object] | None) -> str:
298
+ """Stable business fingerprint (``sha256`` hex) for one cache layer."""
299
+ self._validate_namespace(namespace)
300
+ clean = sanitize_key_parts(key_parts)
301
+ payload = {
302
+ "schema": CACHE_SCHEMA_VERSION,
303
+ "namespace": namespace,
304
+ "parts": clean,
305
+ }
306
+ return hashlib.sha256(_canonical_json(payload).encode("utf-8")).hexdigest()
307
+
308
+ # ------------------------------------------------------------------- get
309
+ def get(self, namespace: str, key_parts: Mapping[str, object] | None) -> bytes | None:
310
+ """Return cached bytes, or ``None`` for a MISS / corrupt / unreadable entry."""
311
+ if not self._enabled:
312
+ return None
313
+ try:
314
+ fingerprint = self.fingerprint(namespace, key_parts)
315
+ except (TypeError, ValueError): # bad caller input is a MISS, never a crash
316
+ LOGGER.warning("cache get with invalid key parts (namespace=%s)", namespace)
317
+ return None
318
+ path = self._entry_path(namespace, fingerprint)
319
+ try:
320
+ raw = path.read_bytes()
321
+ except FileNotFoundError:
322
+ return None
323
+ except OSError as exc:
324
+ LOGGER.warning("cache read failed for %s: %s", path, exc)
325
+ return None
326
+ payload = self._decode_entry(path, raw, namespace=namespace, fingerprint=fingerprint)
327
+ if payload is None:
328
+ return None
329
+ self._touch(path)
330
+ return payload
331
+
332
+ # ------------------------------------------------------------------- put
333
+ def put(self, namespace: str, key_parts: Mapping[str, object] | None, payload: bytes) -> None:
334
+ """Store *payload* under the fingerprint; failures degrade to a no-op."""
335
+ if not self._enabled:
336
+ return
337
+ self._validate_namespace(namespace)
338
+ if not isinstance(payload, (bytes, bytearray, memoryview)):
339
+ raise TypeError("cache payload must be bytes")
340
+ data = bytes(payload)
341
+ try:
342
+ fingerprint = self.fingerprint(namespace, key_parts)
343
+ except (TypeError, ValueError):
344
+ LOGGER.warning("cache put with invalid key parts (namespace=%s)", namespace)
345
+ return
346
+ path = self._entry_path(namespace, fingerprint)
347
+ envelope = self._encode_entry(
348
+ namespace=namespace, fingerprint=fingerprint, data=data
349
+ )
350
+ try:
351
+ path.parent.mkdir(parents=True, exist_ok=True)
352
+ fd, tmp_name = tempfile.mkstemp(
353
+ dir=path.parent, prefix=f".{fingerprint[:16]}.", suffix=_TMP_SUFFIX
354
+ )
355
+ try:
356
+ with os.fdopen(fd, "wb") as handle:
357
+ handle.write(envelope)
358
+ handle.flush()
359
+ os.replace(tmp_name, path)
360
+ except BaseException:
361
+ try:
362
+ os.unlink(tmp_name)
363
+ except OSError:
364
+ pass
365
+ raise
366
+ except OSError as exc:
367
+ # Unwritable cache must never break extraction: log + drop the write.
368
+ LOGGER.warning("cache write failed for %s (ignored): %s", path, exc)
369
+ return
370
+ self._evict_if_needed()
371
+
372
+ # ----------------------------------------------------------------- stats
373
+ def stats(self) -> dict[str, Any]:
374
+ """Best-effort introspection for ``ytk cache status``; never raises."""
375
+ per_namespace: dict[str, dict[str, int]] = {
376
+ namespace: {"entries": 0, "bytes": 0} for namespace in sorted(NAMESPACES)
377
+ }
378
+ total_bytes = 0
379
+ total_entries = 0
380
+ try:
381
+ entries = list(self._iter_entries())
382
+ except OSError:
383
+ entries = []
384
+ for namespace, path in entries:
385
+ try:
386
+ size = path.stat().st_size
387
+ except OSError:
388
+ continue
389
+ bucket = per_namespace.setdefault(namespace, {"entries": 0, "bytes": 0})
390
+ bucket["entries"] += 1
391
+ bucket["bytes"] += size
392
+ total_entries += 1
393
+ total_bytes += size
394
+ return {
395
+ "root": str(self._root),
396
+ "enabled": self._enabled,
397
+ "entries": total_entries,
398
+ "total_bytes": total_bytes,
399
+ "max_bytes": self._max_bytes,
400
+ "namespaces": per_namespace,
401
+ }
402
+
403
+ # ----------------------------------------------------------------- clear
404
+ def clear(self, namespace: str | None = None) -> int:
405
+ """Remove entries (and crashed-write temp files); return how many went."""
406
+ if namespace is not None:
407
+ self._validate_namespace(namespace)
408
+ removed = 0
409
+ try:
410
+ if namespace is None:
411
+ paths = [path for _, path in self._iter_entries()]
412
+ strays = list(self._root.glob(f"*/*{_TMP_SUFFIX}")) if self._root.exists() else []
413
+ else:
414
+ ns_dir = self._root / namespace
415
+ paths = list(ns_dir.glob(f"*{_ENTRY_SUFFIX}")) if ns_dir.exists() else []
416
+ strays = list(ns_dir.glob(f"*{_TMP_SUFFIX}")) if ns_dir.exists() else []
417
+ except OSError:
418
+ return 0
419
+ for path in paths:
420
+ try:
421
+ path.unlink()
422
+ removed += 1
423
+ except OSError:
424
+ LOGGER.warning("cache: failed to remove %s", path)
425
+ for stray in strays:
426
+ try:
427
+ stray.unlink()
428
+ except OSError:
429
+ pass
430
+ return removed
431
+
432
+ # --------------------------------------------------------------- helpers
433
+ def _validate_namespace(self, namespace: str) -> None:
434
+ if namespace not in NAMESPACES:
435
+ raise ValueError(
436
+ f"unknown cache namespace {namespace!r}; expected one of: "
437
+ f"{', '.join(sorted(NAMESPACES))}"
438
+ )
439
+
440
+ def _entry_path(self, namespace: str, fingerprint: str) -> Path:
441
+ return self._root / namespace / f"{fingerprint}{_ENTRY_SUFFIX}"
442
+
443
+ def _iter_entries(self) -> list[tuple[str, Path]]:
444
+ found: list[tuple[str, Path]] = []
445
+ if not self._root.exists():
446
+ return found
447
+ for namespace in sorted(NAMESPACES):
448
+ directory = self._root / namespace
449
+ if not directory.is_dir():
450
+ continue
451
+ for path in directory.glob(f"*{_ENTRY_SUFFIX}"):
452
+ found.append((namespace, path))
453
+ return found
454
+
455
+ @staticmethod
456
+ def _encode_entry(*, namespace: str, fingerprint: str, data: bytes) -> bytes:
457
+ header = {
458
+ "schema": CACHE_SCHEMA_VERSION,
459
+ "namespace": namespace,
460
+ "fingerprint": fingerprint,
461
+ "sha256": hashlib.sha256(data).hexdigest(),
462
+ "size": len(data),
463
+ "created_at": int(time.time()),
464
+ }
465
+ return _MAGIC + _canonical_json(header).encode("utf-8") + b"\n" + data
466
+
467
+ def _decode_entry(
468
+ self, path: Path, raw: bytes, *, namespace: str, fingerprint: str
469
+ ) -> bytes | None:
470
+ def corrupt(reason: str) -> None:
471
+ LOGGER.warning("cache entry %s is corrupt (%s): treating as miss", path, reason)
472
+ try:
473
+ path.unlink()
474
+ except OSError:
475
+ pass
476
+
477
+ if not raw.startswith(_MAGIC):
478
+ corrupt("bad magic")
479
+ return None
480
+ body = raw[len(_MAGIC) :]
481
+ header_raw, separator, data = body.partition(b"\n")
482
+ if not separator:
483
+ corrupt("missing header separator")
484
+ return None
485
+ try:
486
+ header = json.loads(header_raw.decode("utf-8"))
487
+ except (UnicodeDecodeError, json.JSONDecodeError):
488
+ corrupt("unreadable header")
489
+ return None
490
+ if not isinstance(header, dict):
491
+ corrupt("header is not an object")
492
+ return None
493
+ if header.get("schema") != CACHE_SCHEMA_VERSION:
494
+ corrupt(f"schema {header.get('schema')!r} != {CACHE_SCHEMA_VERSION}")
495
+ return None
496
+ if header.get("namespace") != namespace:
497
+ corrupt("namespace mismatch")
498
+ return None
499
+ if header.get("fingerprint") != fingerprint:
500
+ corrupt("fingerprint mismatch")
501
+ return None
502
+ try:
503
+ expected_size = int(header.get("size", -1))
504
+ except (TypeError, ValueError):
505
+ corrupt("invalid size")
506
+ return None
507
+ if expected_size != len(data):
508
+ corrupt(f"size mismatch ({expected_size} != {len(data)})")
509
+ return None
510
+ expected_digest = header.get("sha256")
511
+ if not isinstance(expected_digest, str) or hashlib.sha256(data).hexdigest() != expected_digest:
512
+ corrupt("payload digest mismatch")
513
+ return None
514
+ return data
515
+
516
+ @staticmethod
517
+ def _touch(path: Path) -> None:
518
+ """Mark an entry as recently used (LRU bookkeeping); best-effort."""
519
+ try:
520
+ os.utime(path, None)
521
+ except OSError:
522
+ pass
523
+
524
+ def _evict_if_needed(self) -> None:
525
+ """Bound the cache by total bytes, evicting least-recently-used entries."""
526
+ if self._max_bytes < 0:
527
+ return
528
+ try:
529
+ entries = self._iter_entries()
530
+ except OSError:
531
+ return
532
+ sized: list[tuple[float, int, Path]] = []
533
+ total = 0
534
+ for _, path in entries:
535
+ try:
536
+ stat = path.stat()
537
+ except OSError:
538
+ continue
539
+ sized.append((stat.st_mtime, stat.st_size, path))
540
+ total += stat.st_size
541
+ if total <= self._max_bytes:
542
+ return
543
+ sized.sort(key=lambda item: item[0]) # oldest access first
544
+ for _, size, path in sized:
545
+ if total <= self._max_bytes:
546
+ break
547
+ try:
548
+ path.unlink()
549
+ total -= size
550
+ except OSError:
551
+ continue
552
+
553
+
554
+ __all__ = [
555
+ "CACHE_DIR_ENV_VAR",
556
+ "CACHE_SCHEMA_VERSION",
557
+ "DEFAULT_MAX_BYTES",
558
+ "MAX_BYTES_ENV_VAR",
559
+ "NAMESPACES",
560
+ "LayeredCache",
561
+ "sanitize_key_parts",
562
+ ]
@@ -0,0 +1,40 @@
1
+ import re
2
+
3
+ TECHNICAL_TERMS = {
4
+ "api",
5
+ "async",
6
+ "await",
7
+ "class",
8
+ "cli",
9
+ "container",
10
+ "coroutine",
11
+ "def",
12
+ "docker",
13
+ "function",
14
+ "http",
15
+ "json",
16
+ "method",
17
+ "module",
18
+ "python",
19
+ "sql",
20
+ }
21
+
22
+ PATHISH_PATTERN = re.compile(r"[A-Za-z0-9]+(?:[/_-][A-Za-z0-9.]+)+")
23
+ TOKEN_PATTERN = re.compile(r"[A-Za-z0-9]+")
24
+ FLAG_PATTERNS = (
25
+ re.compile(r"`[^`]+`"),
26
+ re.compile(r"\w+\(\)"),
27
+ re.compile(r"\w+::\w+"),
28
+ )
29
+
30
+
31
+ def technical_density_score(text: str) -> float:
32
+ lowered = text.lower()
33
+ tokens = {token.lower() for token in TOKEN_PATTERN.findall(lowered)}
34
+ score = 0.0
35
+ score += float(len(tokens & TECHNICAL_TERMS))
36
+ score += 0.5 * len(PATHISH_PATTERN.findall(text))
37
+ for pattern in FLAG_PATTERNS:
38
+ if pattern.search(text):
39
+ score += 0.5
40
+ return score
@@ -0,0 +1,18 @@
1
+ import re
2
+
3
+ FILLER_PREFIX_PATTERN = re.compile(
4
+ r"^\s*(?:so basically|let'?s go ahead and|you know|"
5
+ r"okay so|alright so|right so|"
6
+ r"um+|uh+|ah+|"
7
+ r"like basically|i mean|you see|"
8
+ r"basically|actually|literally|honestly|"
9
+ r"now let'?s|so let'?s|"
10
+ r"and then|so then|"
11
+ r"right,?\s*so|okay,?\s*so|alright,?\s*so)"
12
+ r"(?:,\s*|\s+)",
13
+ re.IGNORECASE,
14
+ )
15
+
16
+
17
+ def strip_fillers(text: str) -> str:
18
+ return FILLER_PREFIX_PATTERN.sub("", text, count=1)
@@ -0,0 +1,30 @@
1
+ from typing import Protocol
2
+
3
+ from ytk.cleaning.fillers import strip_fillers
4
+ from ytk.models.transcript import TranscriptSegment
5
+ from ytk.text_normalization import normalize_known_term_noise
6
+
7
+
8
+ class SentenceBoundaryBackend(Protocol):
9
+ def clean_segment(self, segment: TranscriptSegment) -> TranscriptSegment:
10
+ """Return a cleaned transcript segment while preserving evidence mapping."""
11
+
12
+
13
+ class RuleBasedSentenceBoundaryBackend:
14
+ def normalize_text(self, text: str) -> str:
15
+ cleaned_text = normalize_known_term_noise(strip_fillers(text)).strip()
16
+ if cleaned_text:
17
+ cleaned_text = cleaned_text[0].upper() + cleaned_text[1:]
18
+ if cleaned_text and cleaned_text[-1] not in ".!?。!?":
19
+ if any("\u4e00" <= char <= "\u9fff" for char in cleaned_text):
20
+ cleaned_text = f"{cleaned_text}。"
21
+ else:
22
+ cleaned_text = f"{cleaned_text}."
23
+ return cleaned_text
24
+
25
+ def clean_segment(self, segment: TranscriptSegment) -> TranscriptSegment:
26
+ return segment.model_copy(
27
+ update={
28
+ "cleaned_text": self.normalize_text(segment.text),
29
+ }
30
+ )