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/models/content.py ADDED
@@ -0,0 +1,451 @@
1
+ """T11.1 — v2 content + evidence model (video **and** image-note coordinates).
2
+
3
+ Frozen contract (plan §4.3):
4
+
5
+ * four locator kinds — ``time_range``, ``paragraph``, ``image_region``,
6
+ ``video_frame`` — discriminated by ``kind``; image indices are 1-based for
7
+ humans, bboxes are original-image pixel ``xyxy`` with the source dimensions
8
+ and a coordinate-system version recorded;
9
+ * ``block_id`` is stable **within one source revision** and derived from
10
+ (source, locator, raw text) — processing settings (cleaner, model, prompt)
11
+ can change without re-keying evidence;
12
+ * raw text and cleaned text are both stored; cleaning never overwrites raw;
13
+ * ``image_note`` content may not carry invented timestamps (no ``[00:00]``);
14
+ * ``ExtractionBundleV2`` validates that every evidence reference resolves to a
15
+ block of the *same* source revision, and that every atom/topic reference
16
+ resolves. Nothing is "repair-by-guess".
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ import hashlib
22
+ from pathlib import PurePosixPath, PureWindowsPath
23
+ from typing import Annotated, Any, Literal
24
+
25
+ from pydantic import BaseModel, ConfigDict, Field, ValidationError, field_validator, model_validator
26
+
27
+ from ytk.models.skill import SkillSpec
28
+ from ytk.models.source import ContentRef
29
+
30
+ BBox = tuple[float, float, float, float]
31
+ CoordinateVersion = Literal["px-xyxy-v1"]
32
+ BlockKind = Literal["transcript", "caption", "ocr", "paragraph", "title", "description"]
33
+ AtomType = Literal[
34
+ "terminal_command",
35
+ "configuration_snippet",
36
+ "best_practice",
37
+ "core_concept",
38
+ "workflow_step",
39
+ "code_block",
40
+ "command_sequence",
41
+ "insight",
42
+ "reference",
43
+ # 2026-11-04: 粗粒度分段(显式低置信)。只在用户显式开启 coarse 模式时产生,
44
+ # 用于"内容完整但抽不出结构化知识"的源,避免把 NO_KNOWLEDGE 变成空台账。
45
+ "segment_summary",
46
+ ]
47
+ Processor = Literal[
48
+ "subtitle_manual",
49
+ "subtitle_auto",
50
+ "subtitle_json3",
51
+ "subtitle_srt",
52
+ "subtitle_vtt",
53
+ "asr",
54
+ "ocr",
55
+ "author_text",
56
+ "video_frame_ocr",
57
+ "user_provided_subtitle",
58
+ "visual_review",
59
+ "v1_migration",
60
+ ]
61
+
62
+
63
+ class TimeRange(BaseModel):
64
+ model_config = ConfigDict(extra="forbid", frozen=True)
65
+
66
+ start: float = Field(ge=0, allow_inf_nan=False)
67
+ end: float = Field(ge=0, allow_inf_nan=False)
68
+
69
+ @model_validator(mode="after")
70
+ def _validate_order(self) -> TimeRange:
71
+ if self.end < self.start:
72
+ raise ValueError("end must be greater than or equal to start")
73
+ return self
74
+
75
+
76
+ class TimeRangeLocator(BaseModel):
77
+ model_config = ConfigDict(extra="forbid", frozen=True)
78
+
79
+ kind: Literal["time_range"] = "time_range"
80
+ time: TimeRange
81
+
82
+
83
+ class ParagraphLocator(BaseModel):
84
+ """Body-paragraph position (image notes, author text)."""
85
+
86
+ model_config = ConfigDict(extra="forbid", frozen=True)
87
+
88
+ kind: Literal["paragraph"] = "paragraph"
89
+ paragraph: int = Field(ge=0)
90
+
91
+
92
+ class ImageRegionLocator(BaseModel):
93
+ """A region of one image, in original-image pixel coordinates."""
94
+
95
+ model_config = ConfigDict(extra="forbid", frozen=True)
96
+
97
+ kind: Literal["image_region"] = "image_region"
98
+ image_index: int = Field(ge=1, description="1-based image order shown to users")
99
+ image_width: int = Field(gt=0)
100
+ image_height: int = Field(gt=0)
101
+ bbox: BBox | None = None
102
+ coordinate_version: CoordinateVersion = "px-xyxy-v1"
103
+ tile_offset: tuple[int, int] = (0, 0)
104
+
105
+ @model_validator(mode="after")
106
+ def _validate_bbox(self) -> ImageRegionLocator:
107
+ if self.bbox is None:
108
+ return self
109
+ x0, y0, x1, y1 = self.bbox
110
+ if not (0 <= x0 < x1 <= self.image_width and 0 <= y0 < y1 <= self.image_height):
111
+ raise ValueError(
112
+ "bbox must be a positive-area xyxy rectangle inside the original image "
113
+ f"({self.image_width}x{self.image_height})"
114
+ )
115
+ return self
116
+
117
+
118
+ class VideoFrameLocator(BaseModel):
119
+ """A sampled video frame (画面 OCR is independent evidence)."""
120
+
121
+ model_config = ConfigDict(extra="forbid", frozen=True)
122
+
123
+ kind: Literal["video_frame"] = "video_frame"
124
+ time: float = Field(ge=0, allow_inf_nan=False)
125
+ image_width: int | None = Field(default=None, gt=0)
126
+ image_height: int | None = Field(default=None, gt=0)
127
+ bbox: BBox | None = None
128
+ coordinate_version: CoordinateVersion = "px-xyxy-v1"
129
+
130
+ @model_validator(mode="after")
131
+ def _validate_bbox(self) -> VideoFrameLocator:
132
+ if self.bbox is None:
133
+ return self
134
+ if self.image_width is None or self.image_height is None:
135
+ raise ValueError("video_frame bbox requires image_width and image_height")
136
+ x0, y0, x1, y1 = self.bbox
137
+ if not (0 <= x0 < x1 <= self.image_width and 0 <= y0 < y1 <= self.image_height):
138
+ raise ValueError("bbox must be a positive-area xyxy rectangle inside the frame")
139
+ return self
140
+
141
+
142
+ Locator = Annotated[
143
+ TimeRangeLocator | ParagraphLocator | ImageRegionLocator | VideoFrameLocator,
144
+ Field(discriminator="kind"),
145
+ ]
146
+
147
+
148
+ def locator_key(locator: TimeRangeLocator | ParagraphLocator | ImageRegionLocator | VideoFrameLocator) -> str:
149
+ """Canonical, order-independent key for block-identity hashing."""
150
+ return locator.model_dump_json(exclude_none=True)
151
+
152
+
153
+ def make_block_id(
154
+ source_id: str,
155
+ locator: TimeRangeLocator | ParagraphLocator | ImageRegionLocator | VideoFrameLocator,
156
+ raw_text: str,
157
+ ) -> str:
158
+ """Stable block id: source + locator + raw text, never the cleaned form."""
159
+ payload = f"{source_id}\x1f{locator_key(locator)}\x1f{raw_text}"
160
+ return "blk-" + hashlib.sha256(payload.encode("utf-8")).hexdigest()[:20]
161
+
162
+
163
+ class ContentBlock(BaseModel):
164
+ model_config = ConfigDict(extra="forbid", frozen=True)
165
+
166
+ block_id: str = Field(min_length=1)
167
+ source_id: str = Field(min_length=1)
168
+ kind: BlockKind
169
+ raw_text: str = Field(min_length=1)
170
+ cleaned_text: str | None = None
171
+ language: str | None = None
172
+ order: int = Field(ge=0)
173
+ locator: Locator
174
+
175
+
176
+ class MediaAsset(BaseModel):
177
+ """A local media file that belongs to a source (portable relative path)."""
178
+
179
+ model_config = ConfigDict(extra="forbid", frozen=True)
180
+
181
+ asset_id: str = Field(min_length=1)
182
+ source_id: str = Field(min_length=1)
183
+ relative_path: str = Field(min_length=1)
184
+ media_type: Literal["video", "audio", "image", "frame"]
185
+ order: int = Field(ge=0)
186
+ byte_size: int = Field(ge=0)
187
+ sha256: str = Field(pattern=r"^[0-9a-f]{64}$")
188
+
189
+ @field_validator("relative_path")
190
+ @classmethod
191
+ def _validate_relative_path(cls, value: str) -> str:
192
+ path = PurePosixPath(value)
193
+ if (
194
+ path.is_absolute() or PureWindowsPath(value).drive or "\\" in value
195
+ or ".." in path.parts or not path.parts or "\x00" in value
196
+ ):
197
+ raise ValueError("relative_path must stay inside the portable artifact package")
198
+ return value
199
+
200
+
201
+ class ContentCompleteness(BaseModel):
202
+ """Whether the acquired content is provably complete.
203
+
204
+ ``unknown`` NEVER converts to ``complete`` downstream; it maps to a
205
+ recoverable BLOCKED with a reason code.
206
+ """
207
+
208
+ model_config = ConfigDict(extra="forbid", frozen=True)
209
+
210
+ status: Literal["complete", "partial", "unknown"]
211
+ checked: list[str] = Field(default_factory=list)
212
+ missing: list[str] = Field(default_factory=list)
213
+ reason_code: str | None = None
214
+
215
+ @model_validator(mode="after")
216
+ def _validate_status(self) -> ContentCompleteness:
217
+ if self.status == "complete" and self.missing:
218
+ raise ValueError("complete content cannot report missing items")
219
+ return self
220
+
221
+
222
+ class ProviderTrace(BaseModel):
223
+ model_config = ConfigDict(extra="forbid", frozen=True)
224
+
225
+ provider: str = Field(min_length=1)
226
+ provider_version: str | None = None
227
+ stage: Literal["resolve", "acquire", "subtitle", "media", "asr", "ocr"]
228
+ status: Literal["ok", "failed", "skipped", "unknown"]
229
+ notes: str | None = None
230
+
231
+
232
+ class ContentBundle(BaseModel):
233
+ model_config = ConfigDict(extra="forbid", frozen=True)
234
+
235
+ schema_version: Literal[2] = 2
236
+ source: ContentRef
237
+ source_revision: str = Field(min_length=1)
238
+ assets: list[MediaAsset] = Field(default_factory=list)
239
+ blocks: list[ContentBlock] = Field(default_factory=list)
240
+ completeness: ContentCompleteness
241
+ provider_trace: list[ProviderTrace] = Field(default_factory=list)
242
+ metadata: dict[str, object] = Field(default_factory=dict)
243
+
244
+ @model_validator(mode="after")
245
+ def _validate_bundle(self) -> ContentBundle:
246
+ source_id = self.source.source_id
247
+ seen: set[str] = set()
248
+ for block in self.blocks:
249
+ if block.block_id in seen:
250
+ raise ValueError(f"duplicate block id: {block.block_id}")
251
+ seen.add(block.block_id)
252
+ if block.source_id != source_id:
253
+ raise ValueError(
254
+ f"block {block.block_id} belongs to source {block.source_id!r}, "
255
+ f"bundle is {source_id!r}"
256
+ )
257
+ for asset in self.assets:
258
+ if asset.source_id != source_id:
259
+ raise ValueError(f"asset {asset.asset_id} belongs to another source")
260
+ if self.source.content_type == "image_note":
261
+ for block in self.blocks:
262
+ if block.locator.kind in {"time_range", "video_frame"}:
263
+ raise ValueError(
264
+ "image_note content must not invent timestamps "
265
+ f"(block {block.block_id} has a {block.locator.kind} locator)"
266
+ )
267
+ return self
268
+
269
+ @property
270
+ def content_key(self) -> str:
271
+ return f"{self.source.source_id}@{self.source_revision}"
272
+
273
+
274
+ class TopicGroup(BaseModel):
275
+ """Topic membership expressed as block ids (time range is optional/derived)."""
276
+
277
+ model_config = ConfigDict(extra="forbid", frozen=True)
278
+
279
+ topic_id: str = Field(min_length=1)
280
+ title: str = Field(min_length=1)
281
+ block_ids: list[str] = Field(min_length=1)
282
+ time_range: TimeRange | None = None
283
+ metadata: dict[str, object] = Field(default_factory=dict)
284
+
285
+
286
+ class KnowledgeAtomV2(BaseModel):
287
+ model_config = ConfigDict(extra="forbid", frozen=True)
288
+
289
+ atom_id: str = Field(min_length=1)
290
+ atom_type: AtomType
291
+ content: str = Field(min_length=1)
292
+ explanation: str = ""
293
+ source_ids: list[str] = Field(min_length=1)
294
+ evidence_ids: list[str] = Field(min_length=1)
295
+ confidence: float = Field(ge=0, le=1)
296
+ topic_path: list[str] = Field(default_factory=list)
297
+ metadata: dict[str, object] = Field(default_factory=dict)
298
+
299
+
300
+ class EvidenceRefV2(BaseModel):
301
+ model_config = ConfigDict(extra="forbid", frozen=True)
302
+
303
+ evidence_id: str = Field(min_length=1)
304
+ source_id: str = Field(min_length=1)
305
+ source_revision: str = Field(min_length=1)
306
+ block_id: str = Field(min_length=1)
307
+ quote: str = Field(min_length=1)
308
+ locator: Locator
309
+ processor: Processor
310
+
311
+ @property
312
+ def source_revision_key(self) -> str:
313
+ return f"{self.source_id}@{self.source_revision}"
314
+
315
+
316
+ class ExtractionBundleV2(BaseModel):
317
+ """The v2 knowledge product: sources + contents + evidence registry + atoms."""
318
+
319
+ model_config = ConfigDict(extra="forbid", frozen=True)
320
+
321
+ schema_version: Literal[2] = 2
322
+ sources: list[ContentRef] = Field(default_factory=list)
323
+ contents: list[ContentBundle] = Field(default_factory=list)
324
+ evidence_index: dict[str, EvidenceRefV2] = Field(default_factory=dict)
325
+ topics: list[TopicGroup] = Field(default_factory=list)
326
+ knowledge: list[KnowledgeAtomV2] = Field(default_factory=list)
327
+ metadata: dict[str, object] = Field(default_factory=dict)
328
+ skill_spec: SkillSpec | None = None
329
+
330
+ @model_validator(mode="after")
331
+ def _validate_references(self) -> ExtractionBundleV2:
332
+ self.validate_references()
333
+ return self
334
+
335
+ # -- reference integrity -------------------------------------------------
336
+ def content_keys(self) -> list[str]:
337
+ return [content.content_key for content in self.contents]
338
+
339
+ def get_content(self, source_id: str, source_revision: str) -> ContentBundle:
340
+ for content in self.contents:
341
+ if content.source.source_id == source_id and content.source_revision == source_revision:
342
+ return content
343
+ raise KeyError(f"content not found for {source_id}@{source_revision}")
344
+
345
+ def validate_references(self) -> None:
346
+ source_ids = [source.source_id for source in self.sources]
347
+ if len(source_ids) != len(set(source_ids)):
348
+ raise ValidationError.from_exception_data(
349
+ "ExtractionBundleV2",
350
+ [{"type": "value_error", "loc": ("sources",), "input": source_ids,
351
+ "ctx": {"error": ValueError("duplicate source id in sources")}}],
352
+ )
353
+
354
+ keys = self.content_keys()
355
+ if len(keys) != len(set(keys)):
356
+ raise ValueError("duplicate content revision in contents")
357
+
358
+ source_map = {source.source_id: source for source in self.sources}
359
+ blocks_by_key: dict[str, dict[str, ContentBlock]] = {}
360
+ for content in self.contents:
361
+ source = source_map.get(content.source.source_id)
362
+ if source is None:
363
+ raise ValueError(f"content {content.content_key} references an unregistered source")
364
+ if source.content_type != content.source.content_type:
365
+ raise ValueError(f"content {content.content_key} has a conflicting source content type")
366
+ blocks_by_key[content.content_key] = {
367
+ block.block_id: block for block in content.blocks
368
+ }
369
+ for key, evidence in self.evidence_index.items():
370
+ if key != evidence.evidence_id:
371
+ raise ValueError(
372
+ f"evidence_index key {key!r} does not match evidence_id {evidence.evidence_id!r}"
373
+ )
374
+ revision_key = evidence.source_revision_key
375
+ if revision_key not in blocks_by_key:
376
+ raise ValueError(
377
+ f"evidence {evidence.evidence_id} references revision {revision_key} "
378
+ "that is not present in contents"
379
+ )
380
+ if evidence.block_id not in blocks_by_key[revision_key]:
381
+ raise ValueError(
382
+ f"evidence {evidence.evidence_id} references block {evidence.block_id} "
383
+ f"that does not exist in revision {revision_key}"
384
+ )
385
+ block = blocks_by_key[revision_key][evidence.block_id]
386
+ if locator_key(block.locator) != locator_key(evidence.locator):
387
+ raise ValueError(
388
+ f"evidence {evidence.evidence_id} locator does not match block {block.block_id}"
389
+ )
390
+ block_text = block.raw_text
391
+ cleaned = block.cleaned_text
392
+ if evidence.quote not in block_text and (not cleaned or evidence.quote not in cleaned):
393
+ raise ValueError(
394
+ f"evidence {evidence.evidence_id} quote is not present in block "
395
+ f"{evidence.block_id} text (fabricated quote)"
396
+ )
397
+
398
+ known_blocks: set[str] = set()
399
+ for blocks in blocks_by_key.values():
400
+ known_blocks.update(blocks)
401
+
402
+ for atom in self.knowledge:
403
+ for source_id in atom.source_ids:
404
+ if source_id not in source_ids:
405
+ raise ValueError(
406
+ f"atom {atom.atom_id} references source {source_id!r} that is not in sources"
407
+ )
408
+ for evidence_id in atom.evidence_ids:
409
+ if evidence_id not in self.evidence_index:
410
+ raise ValueError(
411
+ f"atom {atom.atom_id} references evidence {evidence_id!r} that is not "
412
+ "registered in evidence_index"
413
+ )
414
+ evidence_sources = {
415
+ self.evidence_index[evidence_id].source_id for evidence_id in atom.evidence_ids
416
+ }
417
+ if evidence_sources != set(atom.source_ids):
418
+ raise ValueError(
419
+ f"atom {atom.atom_id} source_ids do not match the sources of its evidence"
420
+ )
421
+
422
+ for topic in self.topics:
423
+ for block_id in topic.block_ids:
424
+ if block_id not in known_blocks:
425
+ raise ValueError(
426
+ f"topic {topic.topic_id} references block {block_id!r} that does not exist"
427
+ )
428
+
429
+ def evidence_for(self, atom: KnowledgeAtomV2) -> list[EvidenceRefV2]:
430
+ return [self.evidence_index[evidence_id] for evidence_id in atom.evidence_ids]
431
+
432
+ def block_map(self) -> dict[str, ContentBlock]:
433
+ return {
434
+ block.block_id: block
435
+ for content in self.contents
436
+ for block in content.blocks
437
+ }
438
+
439
+
440
+ def dump_bundle_json(bundle: ExtractionBundleV2) -> dict[str, Any]:
441
+ """Serialize a bundle, keeping the computed ``source_id`` on every ref.
442
+
443
+ ``source_id`` is a computed property and is dropped by ``model_dump``; the
444
+ contract calls it the primary key, so external consumers must see it.
445
+ """
446
+ payload = bundle.model_dump(mode="json")
447
+ for ref, out in zip(bundle.sources, payload.get("sources", []), strict=True):
448
+ out["source_id"] = ref.source_id
449
+ for content, cout in zip(bundle.contents, payload.get("contents", []), strict=True):
450
+ cout.setdefault("source", {})["source_id"] = content.source.source_id
451
+ return payload
@@ -0,0 +1,43 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Literal
4
+
5
+ from pydantic import BaseModel, ConfigDict, Field
6
+
7
+ from ytk.models.common import EvidenceRef, TranscriptSource
8
+ from ytk.models.skill import SkillSpec
9
+ from ytk.models.topic import TopicSegment
10
+
11
+
12
+ class KnowledgeAtom(BaseModel):
13
+ model_config = ConfigDict(extra="forbid")
14
+
15
+ id: str
16
+ atom_type: Literal[
17
+ "terminal_command",
18
+ "configuration_snippet",
19
+ "best_practice",
20
+ "core_concept",
21
+ "workflow_step",
22
+ "code_block",
23
+ "command_sequence",
24
+ "segment_summary",
25
+ ]
26
+ content: str
27
+ explanation: str
28
+ evidence: list[EvidenceRef]
29
+ timestamps: list[float]
30
+ confidence: float = Field(ge=0, le=1)
31
+ topic_path: list[str]
32
+ source_video: str
33
+
34
+
35
+ class ExtractionBundle(BaseModel):
36
+ model_config = ConfigDict(extra="forbid")
37
+
38
+ video_id: str
39
+ metadata: dict[str, object]
40
+ transcript_source: TranscriptSource
41
+ topic_segments: list[TopicSegment]
42
+ knowledge_atoms: list[KnowledgeAtom]
43
+ skill_spec: SkillSpec