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.
- kd_engine-1.0.0.dist-info/METADATA +19 -0
- kd_engine-1.0.0.dist-info/RECORD +118 -0
- kd_engine-1.0.0.dist-info/WHEEL +5 -0
- kd_engine-1.0.0.dist-info/entry_points.txt +2 -0
- kd_engine-1.0.0.dist-info/licenses/LICENSE-MIT +21 -0
- kd_engine-1.0.0.dist-info/top_level.txt +1 -0
- ytk/__init__.py +2 -0
- ytk/__main__.py +21 -0
- ytk/batch/__init__.py +39 -0
- ytk/batch/runner.py +1149 -0
- ytk/cache/__init__.py +34 -0
- ytk/cache/v2.py +562 -0
- ytk/cleaning/density.py +40 -0
- ytk/cleaning/fillers.py +18 -0
- ytk/cleaning/sentence_boundary.py +30 -0
- ytk/cleaning/service.py +62 -0
- ytk/cli.py +1281 -0
- ytk/commands/__init__.py +3 -0
- ytk/commands/merge.py +600 -0
- ytk/config.py +757 -0
- ytk/dedup/service.py +166 -0
- ytk/doctor.py +891 -0
- ytk/dotenv.py +334 -0
- ytk/errors.py +38 -0
- ytk/extraction/service.py +451 -0
- ytk/extraction/strategies/base.py +16 -0
- ytk/extraction/strategies/code_block.py +285 -0
- ytk/extraction/strategies/heuristic.py +791 -0
- ytk/extraction/strategies/local_model.py +56 -0
- ytk/extraction/text_context.py +177 -0
- ytk/extraction/visual_context.py +159 -0
- ytk/ingestion/adapters/__init__.py +1 -0
- ytk/ingestion/adapters/_common.py +865 -0
- ytk/ingestion/adapters/base.py +160 -0
- ytk/ingestion/adapters/bilibili.py +271 -0
- ytk/ingestion/adapters/douyin.py +445 -0
- ytk/ingestion/adapters/tiktok.py +102 -0
- ytk/ingestion/adapters/xiaohongshu.py +758 -0
- ytk/ingestion/adapters/youtube.py +74 -0
- ytk/ingestion/browser_acquired.py +220 -0
- ytk/ingestion/http_client.py +43 -0
- ytk/ingestion/local_import.py +202 -0
- ytk/ingestion/media.py +468 -0
- ytk/ingestion/providers/asr.py +902 -0
- ytk/ingestion/providers/base.py +13 -0
- ytk/ingestion/providers/dtk_client.py +816 -0
- ytk/ingestion/providers/minimax.py +350 -0
- ytk/ingestion/providers/transcript_api.py +336 -0
- ytk/ingestion/providers/xhs_client.py +646 -0
- ytk/ingestion/providers/ytdlp.py +400 -0
- ytk/ingestion/registry.py +189 -0
- ytk/ingestion/resolver.py +417 -0
- ytk/ingestion/search.py +56 -0
- ytk/ingestion/service.py +167 -0
- ytk/ingestion/subtitles.py +602 -0
- ytk/ingestion/transcript_cache.py +212 -0
- ytk/ingestion/url_parser.py +53 -0
- ytk/ingestion/video_frames.py +292 -0
- ytk/ingestion/ytdlp_acquire.py +754 -0
- ytk/logging.py +383 -0
- ytk/models/common.py +20 -0
- ytk/models/content.py +451 -0
- ytk/models/knowledge.py +43 -0
- ytk/models/migrations.py +262 -0
- ytk/models/skill.py +13 -0
- ytk/models/source.py +265 -0
- ytk/models/topic.py +18 -0
- ytk/models/transcript.py +30 -0
- ytk/ocr/__init__.py +65 -0
- ytk/ocr/engine.py +245 -0
- ytk/ocr/layout.py +347 -0
- ytk/ocr/pipeline.py +440 -0
- ytk/ocr/rapidocr_engine.py +144 -0
- ytk/pipeline/acquisition_checkpoint.py +240 -0
- ytk/pipeline/artifacts.py +18 -0
- ytk/pipeline/orchestrator.py +598 -0
- ytk/pipeline/processing_identity.py +18 -0
- ytk/pipeline/retry.py +728 -0
- ytk/pipeline/runners.py +578 -0
- ytk/pipeline/state.py +1107 -0
- ytk/pipeline/v2.py +1863 -0
- ytk/py.typed +1 -0
- ytk/quality/__init__.py +9 -0
- ytk/quality/completeness.py +260 -0
- ytk/renderers/base.py +9 -0
- ytk/renderers/citation_alignment.py +617 -0
- ytk/renderers/evidence_formatter.py +362 -0
- ytk/renderers/json_renderer.py +13 -0
- ytk/renderers/jsonl_renderer.py +18 -0
- ytk/renderers/knowledge_doc_renderer.py +1984 -0
- ytk/renderers/markdown_renderer.py +540 -0
- ytk/renderers/obsidian_renderer.py +138 -0
- ytk/renderers/skill_package.py +21 -0
- ytk/renderers/source_links.py +309 -0
- ytk/renderers/summary_renderer.py +163 -0
- ytk/renderers/v2.py +171 -0
- ytk/renderers/v2_jsonl.py +165 -0
- ytk/renderers/v2_markdown.py +477 -0
- ytk/renderers/v2_obsidian.py +113 -0
- ytk/renderers/v2_summary.py +220 -0
- ytk/segmentation/change_points.py +50 -0
- ytk/segmentation/embeddings.py +45 -0
- ytk/segmentation/service.py +318 -0
- ytk/skill_builder/__init__.py +1 -0
- ytk/skill_builder/builder.py +419 -0
- ytk/skill_builder/templates/SKILL.md.j2 +85 -0
- ytk/skill_builder/templates/evals/evals.json.j2 +25 -0
- ytk/skill_builder/templates/examples/example-input.md.j2 +5 -0
- ytk/skill_builder/templates/examples/example-output.md.j2 +8 -0
- ytk/skill_builder/templates/references/commands.md.j2 +34 -0
- ytk/skill_builder/templates/references/concepts.md.j2 +22 -0
- ytk/skill_builder/templates/references/configs.md.j2 +31 -0
- ytk/skill_builder/templates/references/evidence-policy.md.j2 +27 -0
- ytk/skill_builder/templates/references/source-summary.md.j2 +24 -0
- ytk/skill_builder/templates/scripts/build_skill.py.j2 +14 -0
- ytk/skill_builder/templates/scripts/validate_output.py.j2 +136 -0
- ytk/skill_builder/templates.py +29 -0
- 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
|
+
]
|
ytk/cleaning/density.py
ADDED
|
@@ -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
|
ytk/cleaning/fillers.py
ADDED
|
@@ -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
|
+
)
|