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/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
|
ytk/models/knowledge.py
ADDED
|
@@ -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
|