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
@@ -0,0 +1,262 @@
1
+ """T11.1/A26 — strict v1 -> v2 migration.
2
+
3
+ v1 artifacts never carry ``schema_version``; they are validated by the *old*
4
+ models first and only then converted. Unknown versions are rejected instead of
5
+ being guessed at, and the original file is never rewritten by this module.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import hashlib
11
+ import json
12
+ from pathlib import Path
13
+ from typing import Any
14
+
15
+ from ytk.errors import UserFacingError
16
+ from ytk.models.content import (
17
+ ContentBlock,
18
+ ContentBundle,
19
+ ContentCompleteness,
20
+ EvidenceRefV2,
21
+ ExtractionBundleV2,
22
+ KnowledgeAtomV2,
23
+ Locator,
24
+ ParagraphLocator,
25
+ Processor,
26
+ TimeRange,
27
+ TimeRangeLocator,
28
+ TopicGroup,
29
+ make_block_id,
30
+ )
31
+ from ytk.models.knowledge import ExtractionBundle
32
+ from ytk.models.source import ContentRef, compute_source_revision
33
+ from ytk.models.transcript import TranscriptBundle
34
+
35
+ #: v1 transcript source -> v2 evidence processor (provider name is recorded in
36
+ #: ``provider_trace``/metadata instead; evidence type and provider stay separate).
37
+ _V1_PROCESSOR: dict[str, Processor] = {
38
+ "manual": "subtitle_manual",
39
+ "auto": "subtitle_auto",
40
+ "yt_dlp": "subtitle_json3",
41
+ "asr": "asr",
42
+ "minimax": "asr",
43
+ }
44
+
45
+
46
+ class UnsupportedSchemaVersionError(UserFacingError):
47
+ """The artifact declares a schema version this build does not understand."""
48
+
49
+
50
+ def _digest(payload: object) -> str:
51
+ return hashlib.sha256(
52
+ json.dumps(payload, ensure_ascii=False, sort_keys=True, default=str).encode("utf-8")
53
+ ).hexdigest()
54
+
55
+
56
+ def _video_ref(video_id: str, metadata: dict[str, Any]) -> ContentRef:
57
+ canonical = metadata.get("canonical_url")
58
+ if not isinstance(canonical, str) or not canonical:
59
+ canonical = f"https://www.youtube.com/watch?v={video_id}"
60
+ return ContentRef(platform="youtube", content_id=video_id, canonical_url=canonical)
61
+
62
+
63
+ def migrate_transcript_bundle_v1(bundle: TranscriptBundle) -> tuple[ContentRef, ContentBundle]:
64
+ """Convert a v1 transcript bundle into one v2 video content bundle."""
65
+ ref = _video_ref(bundle.video_id, bundle.metadata)
66
+ revision = "v1:" + _digest(
67
+ {
68
+ "video_id": bundle.video_id,
69
+ "transcript_source": bundle.transcript_source,
70
+ "segments": [segment.model_dump(mode="json") for segment in bundle.segments],
71
+ }
72
+ )[:32]
73
+
74
+ blocks: list[ContentBlock] = []
75
+ order = 0
76
+ for segment in bundle.segments:
77
+ raw_text = segment.raw_text or segment.text
78
+ if not raw_text:
79
+ continue
80
+ locator = TimeRangeLocator(time=TimeRange(start=segment.start, end=segment.end))
81
+ blocks.append(
82
+ ContentBlock(
83
+ block_id=make_block_id(ref.source_id, locator, raw_text),
84
+ source_id=ref.source_id,
85
+ kind="transcript",
86
+ raw_text=raw_text,
87
+ cleaned_text=segment.cleaned_text,
88
+ order=order,
89
+ locator=locator,
90
+ )
91
+ )
92
+ order += 1
93
+
94
+ content = ContentBundle(
95
+ source=ref,
96
+ source_revision=revision,
97
+ blocks=blocks,
98
+ completeness=ContentCompleteness(status="complete", checked=["transcript_segments"]),
99
+ metadata={"transcript_source": bundle.transcript_source},
100
+ )
101
+ return ref, content
102
+
103
+
104
+ def migrate_extraction_bundle_v1(bundle: ExtractionBundle) -> ExtractionBundleV2:
105
+ """Convert a v1 extraction bundle into a v2 bundle without losing evidence."""
106
+ ref = _video_ref(bundle.video_id, dict(bundle.metadata))
107
+ source_id = ref.source_id
108
+
109
+ revision = compute_source_revision(
110
+ source_id,
111
+ {
112
+ "schema": 1,
113
+ "video_id": bundle.video_id,
114
+ "transcript_source": bundle.transcript_source,
115
+ "atoms": [atom.model_dump(mode="json") for atom in bundle.knowledge_atoms],
116
+ "topics": [topic.model_dump(mode="json") for topic in bundle.topic_segments],
117
+ },
118
+ )
119
+
120
+ blocks: list[ContentBlock] = []
121
+ evidence_index: dict[str, EvidenceRefV2] = {}
122
+ # (atom_index, evidence_index_in_atom) -> evidence id
123
+ atom_evidence_ids: list[list[str]] = []
124
+ evidence_by_digest: dict[str, str] = {}
125
+ block_by_digest: dict[str, str] = {}
126
+ order = 0
127
+
128
+ for atom in bundle.knowledge_atoms:
129
+ ids: list[str] = []
130
+ for evidence in atom.evidence:
131
+ digest = _digest([evidence.text, evidence.start, evidence.end])
132
+ evidence_id = evidence_by_digest.get(digest)
133
+ if evidence_id is None:
134
+ locator: Locator = TimeRangeLocator(time=TimeRange(start=evidence.start, end=evidence.end))
135
+ block_id = make_block_id(source_id, locator, evidence.text)
136
+ block_digest = _digest([block_id])
137
+ if block_digest not in block_by_digest:
138
+ blocks.append(
139
+ ContentBlock(
140
+ block_id=block_id,
141
+ source_id=source_id,
142
+ kind="transcript",
143
+ raw_text=evidence.text,
144
+ order=order,
145
+ locator=locator,
146
+ )
147
+ )
148
+ block_by_digest[block_digest] = block_id
149
+ order += 1
150
+ evidence_id = f"{source_id}@{revision}#ev-{len(evidence_index) + 1:04d}"
151
+ evidence_index[evidence_id] = EvidenceRefV2(
152
+ evidence_id=evidence_id,
153
+ source_id=source_id,
154
+ source_revision=revision,
155
+ block_id=block_id,
156
+ quote=evidence.text,
157
+ locator=locator,
158
+ processor=_V1_PROCESSOR.get(evidence.source, "v1_migration"),
159
+ )
160
+ evidence_by_digest[digest] = evidence_id
161
+ ids.append(evidence_id)
162
+ atom_evidence_ids.append(ids)
163
+
164
+ # Topic segments carry sentence lists but no per-sentence timings. Attach
165
+ # the evidence blocks that fall inside the topic window; when a topic has no
166
+ # timed evidence, keep its text as an explicit paragraph block so no v1
167
+ # information is dropped.
168
+ blocks_by_time: list[tuple[float, float, str]] = []
169
+ for block in blocks:
170
+ locator = block.locator
171
+ assert isinstance(locator, TimeRangeLocator) # migration only creates these
172
+ blocks_by_time.append((locator.time.start, locator.time.end, block.block_id))
173
+
174
+ topics: list[TopicGroup] = []
175
+ for topic_index, topic in enumerate(bundle.topic_segments):
176
+ member_ids = [
177
+ block_id
178
+ for (start, end, block_id) in blocks_by_time
179
+ if start < topic.end and end > topic.start
180
+ ]
181
+ if not member_ids:
182
+ paragraph_text = " ".join(sentence for sentence in topic.sentences if sentence).strip()
183
+ if not paragraph_text:
184
+ paragraph_text = topic.title
185
+ locator = ParagraphLocator(paragraph=topic_index)
186
+ block_id = make_block_id(source_id, locator, paragraph_text)
187
+ blocks.append(
188
+ ContentBlock(
189
+ block_id=block_id,
190
+ source_id=source_id,
191
+ kind="paragraph",
192
+ raw_text=paragraph_text,
193
+ order=order,
194
+ locator=locator,
195
+ )
196
+ )
197
+ order += 1
198
+ member_ids = [block_id]
199
+ topics.append(
200
+ TopicGroup(
201
+ topic_id=topic.id,
202
+ title=topic.title,
203
+ block_ids=member_ids,
204
+ time_range=TimeRange(start=topic.start, end=topic.end),
205
+ metadata={"embedding_metadata": dict(topic.embedding_metadata)},
206
+ )
207
+ )
208
+
209
+ knowledge: list[KnowledgeAtomV2] = []
210
+ for atom_index, atom in enumerate(bundle.knowledge_atoms):
211
+ knowledge.append(
212
+ KnowledgeAtomV2(
213
+ atom_id=atom.id,
214
+ atom_type=atom.atom_type,
215
+ content=atom.content,
216
+ explanation=atom.explanation,
217
+ source_ids=[source_id],
218
+ evidence_ids=atom_evidence_ids[atom_index],
219
+ confidence=atom.confidence,
220
+ topic_path=list(atom.topic_path),
221
+ metadata={"timestamps": list(atom.timestamps), "source_video": atom.source_video},
222
+ )
223
+ )
224
+
225
+ content = ContentBundle(
226
+ source=ref,
227
+ source_revision=revision,
228
+ blocks=blocks,
229
+ completeness=ContentCompleteness(status="complete", checked=["v1_migration"]),
230
+ metadata={"transcript_source": bundle.transcript_source},
231
+ )
232
+ return ExtractionBundleV2(
233
+ sources=[ref],
234
+ contents=[content],
235
+ evidence_index=evidence_index,
236
+ topics=topics,
237
+ knowledge=knowledge,
238
+ metadata=dict(bundle.metadata),
239
+ skill_spec=bundle.skill_spec,
240
+ )
241
+
242
+
243
+ def load_bundle_any(path: Path) -> ExtractionBundleV2:
244
+ """Load a knowledge bundle of any supported schema version.
245
+
246
+ ``schema_version == 2`` -> strict v2 validation; missing version -> strict v1
247
+ validation followed by migration; anything else -> ``UnsupportedSchemaVersionError``.
248
+ """
249
+ payload = json.loads(Path(path).read_text(encoding="utf-8"))
250
+ if not isinstance(payload, dict):
251
+ raise UnsupportedSchemaVersionError(f"artifact {path} is not a JSON object")
252
+
253
+ if "schema_version" in payload:
254
+ version = payload["schema_version"]
255
+ if version != 2:
256
+ raise UnsupportedSchemaVersionError(
257
+ f"artifact {path} declares unsupported schema_version={version!r}"
258
+ )
259
+ return ExtractionBundleV2.model_validate(payload)
260
+
261
+ v1 = ExtractionBundle.model_validate(payload)
262
+ return migrate_extraction_bundle_v1(v1)
ytk/models/skill.py ADDED
@@ -0,0 +1,13 @@
1
+ from pydantic import BaseModel, ConfigDict, Field
2
+
3
+
4
+ class SkillSpec(BaseModel):
5
+ model_config = ConfigDict(extra="forbid")
6
+
7
+ skill_name: str
8
+ purpose: str
9
+ trigger_description: str
10
+ target_agents: list[str]
11
+ required_files: list[str]
12
+ generated_files: list[str]
13
+ references_map: dict[str, str] = Field(default_factory=dict)
ytk/models/source.py ADDED
@@ -0,0 +1,265 @@
1
+ """T10.1 — unified source identity (platform + canonical content id + variant).
2
+
3
+ Design rules frozen by the 2026-11-02 implementation plan §4.1:
4
+
5
+ * ``source_id`` is ``"<platform>:<content_id>"`` plus ``":<variant_id>"`` when the
6
+ platform has a sub-unit (currently only Bilibili's ``cid``). The id mixes the
7
+ platform name in, so the *same numeric id on two platforms can never collide*.
8
+ * ids are strings and keep their case (``BV1GJ411x7h7`` is not ``bv1gj411x7h7``).
9
+ * ``canonical_url`` is the public reference; access tokens (``xsec_token`` …) are
10
+ stripped from it, and any credential/access data lives in :class:`AccessContext`
11
+ which is never serialized into artifacts or logs.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import hashlib
17
+ import json
18
+ from collections.abc import Callable, Mapping
19
+ from typing import Literal
20
+ from urllib.parse import parse_qsl, urlencode, urlsplit, urlunsplit
21
+
22
+ from pydantic import BaseModel, ConfigDict, Field, ValidationInfo, field_validator, model_validator
23
+
24
+ from ytk.ingestion.url_parser import ParsedYouTubeUrl
25
+
26
+ Platform = Literal["youtube", "tiktok", "douyin", "bilibili", "xiaohongshu", "local"]
27
+ ContentType = Literal["video", "image_note"]
28
+
29
+ SUPPORTED_PLATFORMS: tuple[str, ...] = ("youtube", "tiktok", "douyin", "bilibili", "xiaohongshu")
30
+ SUPPORTED_CONTENT_TYPES: tuple[str, ...] = ("video", "image_note")
31
+
32
+ #: Query parameters that carry access rights or tracking; never public.
33
+ SENSITIVE_QUERY_PARAMS: frozenset[str] = frozenset(
34
+ {
35
+ "xsec_token",
36
+ "xsec_source",
37
+ "xsec_app_secuboid",
38
+ "mstoken",
39
+ "share_token",
40
+ "share_source",
41
+ "share_session_id",
42
+ "access_key",
43
+ "sessdata",
44
+ "bili_jct",
45
+ "dedeuserid",
46
+ "dedeuserid__ckmd5",
47
+ "vd_source",
48
+ "verifyfp",
49
+ "s_v_web_id",
50
+ "x-bogus",
51
+ "x_bogus",
52
+ "_signature",
53
+ "ttwid",
54
+ "appuid",
55
+ "apptime",
56
+ "shareredid",
57
+ "sign",
58
+ "signature",
59
+ "expires",
60
+ "token",
61
+ "access_token",
62
+ "sessionid",
63
+ "sid",
64
+ "cookie",
65
+ "aci",
66
+ "buvid",
67
+ "w_rid",
68
+ "wts",
69
+ "auth_key",
70
+ "sig",
71
+ "key",
72
+ "expire",
73
+ "a_bogus",
74
+ "x-amz-signature",
75
+ "x-amz-credential",
76
+ "x-amz-security-token",
77
+ "mstoken_v2",
78
+ "expiration",
79
+ "refresh_token",
80
+ "session_id",
81
+ "authkey",
82
+ "hdnts",
83
+ "policy",
84
+ "playauth",
85
+ "nsec_token",
86
+ "appkey",
87
+ "api_key",
88
+ "apikey",
89
+ "id_token",
90
+ }
91
+ )
92
+
93
+
94
+ def sanitize_public_url(url: str) -> str:
95
+ """Return *url* with access/tracking parameters and userinfo removed.
96
+
97
+ The renderers and knowledge artifacts only ever get this form. Raises
98
+ ``ValueError`` for anything that is not an absolute http(s) URL.
99
+ """
100
+ if not isinstance(url, str) or not url.strip():
101
+ raise ValueError("url must be a non-empty string")
102
+ parts = urlsplit(url.strip())
103
+ if parts.scheme not in {"http", "https"}:
104
+ raise ValueError(f"unsupported URL scheme: {parts.scheme or '<missing>'}")
105
+ if not parts.hostname:
106
+ raise ValueError("URL is missing a host")
107
+ if parts.username or parts.password:
108
+ raise ValueError("URL must not embed credentials")
109
+ host = parts.hostname.lower()
110
+ netloc = host if parts.port in (None, 80, 443) else f"{host}:{parts.port}"
111
+ kept = [
112
+ (name, value)
113
+ for name, value in parse_qsl(parts.query, keep_blank_values=True)
114
+ if name.casefold() not in SENSITIVE_QUERY_PARAMS
115
+ ]
116
+ path = parts.path or "/"
117
+ return urlunsplit((parts.scheme, netloc, path, urlencode(kept), ""))
118
+
119
+
120
+ class AccessContext(BaseModel):
121
+ """Credentials/proxy/header context for one acquisition task.
122
+
123
+ Deliberately frozen and never embedded in ``ContentRef`` / ``ContentBundle`` /
124
+ renderer output. ``redacted()`` is the only shape allowed in logs/diagnostics.
125
+ """
126
+
127
+ model_config = ConfigDict(extra="forbid", frozen=True, hide_input_in_errors=True)
128
+
129
+ cookies_file: str | None = Field(default=None, exclude=True, repr=False)
130
+ cookies_from_browser: str | None = Field(default=None, exclude=True, repr=False)
131
+ proxy: str | None = Field(default=None, exclude=True, repr=False)
132
+ headers: Mapping[str, str] = Field(default_factory=dict, exclude=True, repr=False)
133
+ tokens: Mapping[str, str] = Field(default_factory=dict, exclude=True, repr=False)
134
+ resolved_url_callback: Callable[[str], None] | None = Field(default=None, exclude=True, repr=False)
135
+
136
+ def redacted(self) -> dict[str, object]:
137
+ """Credential-free summary safe for logs, manifests and reports."""
138
+ proxy_host = None
139
+ if self.proxy:
140
+ proxy_host = urlsplit(self.proxy).hostname or "<invalid>"
141
+ return {
142
+ "cookies_file": "<configured>" if self.cookies_file else None,
143
+ "cookies_from_browser": "<configured>" if self.cookies_from_browser else None,
144
+ "proxy_host": proxy_host,
145
+ "header_names": sorted(self.headers),
146
+ "token_names": sorted(self.tokens),
147
+ }
148
+
149
+
150
+ class ContentRef(BaseModel):
151
+ """Stable identity of one content unit (a video, or an image-note)."""
152
+
153
+ model_config = ConfigDict(extra="forbid", frozen=True)
154
+
155
+ platform: Platform
156
+ content_id: str = Field(min_length=1)
157
+ variant_id: str | None = None
158
+ content_type: ContentType = "video"
159
+ canonical_url: str
160
+ alias_urls: list[str] = Field(default_factory=list)
161
+ display: Mapping[str, str] = Field(default_factory=dict)
162
+
163
+ @model_validator(mode="before")
164
+ @classmethod
165
+ def _validate_public_source_id(cls, value: object) -> object:
166
+ if not isinstance(value, Mapping) or "source_id" not in value:
167
+ return value
168
+ fields = dict(value)
169
+ supplied = fields.pop("source_id")
170
+ expected = f"{fields.get('platform')}:{fields.get('content_id')}"
171
+ if fields.get("variant_id"):
172
+ expected += f":{fields['variant_id']}"
173
+ if supplied != expected:
174
+ raise ValueError("source_id does not match platform/content_id/variant_id")
175
+ return fields
176
+
177
+ @field_validator("canonical_url")
178
+ @classmethod
179
+ def _validate_canonical_url(cls, value: str, info: ValidationInfo) -> str:
180
+ if info.data.get("platform") == "local":
181
+ parts = urlsplit(value)
182
+ if parts.scheme != "file" or parts.netloc or not parts.path.startswith("/") or parts.query or parts.fragment:
183
+ raise ValueError("local sources require an absolute local file URI")
184
+ return value
185
+ return sanitize_public_url(value)
186
+
187
+ @field_validator("alias_urls")
188
+ @classmethod
189
+ def _validate_alias_urls(cls, values: list[str]) -> list[str]:
190
+ sanitized: list[str] = []
191
+ for value in values:
192
+ cleaned = sanitize_public_url(value)
193
+ if cleaned not in sanitized:
194
+ sanitized.append(cleaned)
195
+ return sanitized
196
+
197
+ @field_validator("content_id", "variant_id")
198
+ @classmethod
199
+ def _validate_identity_component(cls, value: str | None) -> str | None:
200
+ if value is not None and (not value or any(char.isspace() for char in value) or ":" in value):
201
+ raise ValueError("source identity components must be non-empty and contain no whitespace or colon")
202
+ return value
203
+
204
+ @model_validator(mode="after")
205
+ def _validate_variant(self) -> ContentRef:
206
+ if self.variant_id is not None and not self.variant_id.strip():
207
+ raise ValueError("variant_id must be non-empty when present")
208
+ return self
209
+
210
+ @property
211
+ def source_id(self) -> str:
212
+ base = f"{self.platform}:{self.content_id}"
213
+ return f"{base}:{self.variant_id}" if self.variant_id else base
214
+
215
+ def with_variant(self, variant_id: str, canonical_url: str | None = None) -> ContentRef:
216
+ """Return a copy bound to a platform sub-unit (Bilibili ``cid``).
217
+
218
+ Revalidate the complete reference: ``model_copy(update=...)`` skips both
219
+ identity checks and public URL sanitization.
220
+ """
221
+ update = self.model_dump()
222
+ update["variant_id"] = variant_id
223
+ if canonical_url is not None:
224
+ update["canonical_url"] = canonical_url
225
+ return type(self).model_validate(update)
226
+
227
+ def public_dict(self) -> dict[str, object]:
228
+ """JSON-safe public shape (no access parameters by construction)."""
229
+ return {
230
+ "platform": self.platform,
231
+ "content_id": self.content_id,
232
+ "variant_id": self.variant_id,
233
+ "content_type": self.content_type,
234
+ "source_id": self.source_id,
235
+ "canonical_url": self.canonical_url,
236
+ "alias_urls": list(self.alias_urls),
237
+ "display": dict(self.display),
238
+ }
239
+
240
+
241
+ def from_youtube_parsed(parsed: ParsedYouTubeUrl) -> ContentRef:
242
+ """v1 compatibility: turn the legacy parsed YouTube URL into a ContentRef."""
243
+ return ContentRef(
244
+ platform="youtube",
245
+ content_id=parsed.video_id,
246
+ canonical_url=parsed.canonical_url,
247
+ alias_urls=[] if parsed.raw_url == parsed.canonical_url else [parsed.raw_url],
248
+ )
249
+
250
+
251
+ def compute_source_revision(source_id: str, fingerprint_parts: Mapping[str, object]) -> str:
252
+ """Deterministic source revision from the *acquired evidence* fingerprint.
253
+
254
+ Only facts that change the delivered content may feed this hash (subtitle
255
+ language/format, media digest, note text digest …). Renderer settings and
256
+ prompt/model versions must NOT be included: changing a template must not
257
+ invalidate acquired content.
258
+ """
259
+ payload = json.dumps(
260
+ {"source_id": source_id, "parts": {key: fingerprint_parts[key] for key in sorted(fingerprint_parts)}},
261
+ ensure_ascii=False,
262
+ sort_keys=True,
263
+ separators=(",", ":"),
264
+ )
265
+ return "sha256:" + hashlib.sha256(payload.encode("utf-8")).hexdigest()
ytk/models/topic.py ADDED
@@ -0,0 +1,18 @@
1
+ from pydantic import BaseModel, ConfigDict, Field, model_validator
2
+
3
+
4
+ class TopicSegment(BaseModel):
5
+ model_config = ConfigDict(extra="forbid")
6
+
7
+ id: str
8
+ title: str
9
+ start: float = Field(ge=0)
10
+ end: float = Field(ge=0)
11
+ sentences: list[str]
12
+ embedding_metadata: dict[str, object] = Field(default_factory=dict)
13
+
14
+ @model_validator(mode="after")
15
+ def validate_time_range(self) -> "TopicSegment":
16
+ if self.end < self.start:
17
+ raise ValueError("end must be greater than or equal to start")
18
+ return self
@@ -0,0 +1,30 @@
1
+ from pydantic import BaseModel, ConfigDict, Field, model_validator
2
+
3
+ from ytk.models.common import TranscriptSource
4
+
5
+
6
+ class TranscriptSegment(BaseModel):
7
+ model_config = ConfigDict(extra="forbid")
8
+
9
+ text: str
10
+ start: float = Field(ge=0)
11
+ end: float = Field(ge=0)
12
+ source: TranscriptSource
13
+ raw_text: str
14
+ cleaned_text: str | None = None
15
+
16
+ @model_validator(mode="after")
17
+ def validate_time_range(self) -> "TranscriptSegment":
18
+ if self.end < self.start:
19
+ raise ValueError("end must be greater than or equal to start")
20
+ return self
21
+
22
+
23
+ class TranscriptBundle(BaseModel):
24
+ model_config = ConfigDict(extra="forbid")
25
+
26
+ video_id: str
27
+ canonical_url: str
28
+ transcript_source: TranscriptSource
29
+ segments: list[TranscriptSegment]
30
+ metadata: dict[str, object]
ytk/ocr/__init__.py ADDED
@@ -0,0 +1,65 @@
1
+ """YTK OCR: local-only image/video-frame recognition (RapidOCR) with location evidence.
2
+
3
+ Public surface:
4
+
5
+ * :mod:`ytk.ocr.engine` — ``OCRLine``, ``OCREngine`` protocol, ``NullOCR`` and the
6
+ lazy :func:`get_engine` factory (raises ``ModelUnavailableError`` when the
7
+ optional engine/models are not provisioned; never downloads at inference time).
8
+ * :mod:`ytk.ocr.rapidocr_engine` — the RapidOCR implementation.
9
+ * :mod:`ytk.ocr.layout` — pixel-budget long-image tiling, original-coordinate
10
+ bbox mapping, overlap de-duplication and reading-order sorting.
11
+ * :mod:`ytk.ocr.pipeline` — ``run_ocr_for_content`` (image notes) and
12
+ ``run_ocr_for_frames`` (T32.2 budget-bound video frames).
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ from ytk.ocr.engine import (
18
+ DEFAULT_MODEL_DIR,
19
+ DEFAULT_MODEL_FILENAMES,
20
+ ENV_OCR_MODEL_DIR,
21
+ ModelPaths,
22
+ NullOCR,
23
+ OCREngine,
24
+ OCRLine,
25
+ engine_available,
26
+ get_engine,
27
+ resolve_model_paths,
28
+ )
29
+ from ytk.ocr.layout import (
30
+ LongImageResult,
31
+ Tile,
32
+ TileBudget,
33
+ dedupe_lines,
34
+ normalize_text,
35
+ ocr_long_image,
36
+ plan_tiles,
37
+ reading_order,
38
+ text_similarity,
39
+ )
40
+ from ytk.ocr.pipeline import FrameOCRBudget, run_ocr_for_content, run_ocr_for_frames
41
+
42
+ __all__ = [
43
+ "DEFAULT_MODEL_DIR",
44
+ "DEFAULT_MODEL_FILENAMES",
45
+ "ENV_OCR_MODEL_DIR",
46
+ "FrameOCRBudget",
47
+ "LongImageResult",
48
+ "ModelPaths",
49
+ "NullOCR",
50
+ "OCREngine",
51
+ "OCRLine",
52
+ "Tile",
53
+ "TileBudget",
54
+ "dedupe_lines",
55
+ "engine_available",
56
+ "get_engine",
57
+ "normalize_text",
58
+ "ocr_long_image",
59
+ "plan_tiles",
60
+ "reading_order",
61
+ "resolve_model_paths",
62
+ "run_ocr_for_content",
63
+ "run_ocr_for_frames",
64
+ "text_similarity",
65
+ ]