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/migrations.py
ADDED
|
@@ -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
|
ytk/models/transcript.py
ADDED
|
@@ -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
|
+
]
|