bilisum 1.13.2 → 1.13.3
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.
- package/README.md +4 -4
- package/bin/bilisum.js +157 -76
- package/package.json +7 -2
- package/runtime/VERSION +1 -0
- package/runtime/apps/service/pyproject.toml +29 -0
- package/runtime/apps/service/src/video_sum_service/__init__.py +1 -0
- package/runtime/apps/service/src/video_sum_service/__main__.py +5 -0
- package/runtime/apps/service/src/video_sum_service/app.py +480 -0
- package/runtime/apps/service/src/video_sum_service/context.py +24 -0
- package/runtime/apps/service/src/video_sum_service/integrations.py +264 -0
- package/runtime/apps/service/src/video_sum_service/knowledge/__init__.py +5 -0
- package/runtime/apps/service/src/video_sum_service/knowledge/index_service.py +408 -0
- package/runtime/apps/service/src/video_sum_service/knowledge/local_llm.py +319 -0
- package/runtime/apps/service/src/video_sum_service/knowledge/rag_service.py +492 -0
- package/runtime/apps/service/src/video_sum_service/knowledge/tag_service.py +242 -0
- package/runtime/apps/service/src/video_sum_service/main.py +31 -0
- package/runtime/apps/service/src/video_sum_service/repository.py +942 -0
- package/runtime/apps/service/src/video_sum_service/routers/__init__.py +1 -0
- package/runtime/apps/service/src/video_sum_service/routers/knowledge.py +272 -0
- package/runtime/apps/service/src/video_sum_service/routers/system.py +280 -0
- package/runtime/apps/service/src/video_sum_service/routers/tasks.py +287 -0
- package/runtime/apps/service/src/video_sum_service/routers/videos.py +766 -0
- package/runtime/apps/service/src/video_sum_service/runtime_support.py +1007 -0
- package/runtime/apps/service/src/video_sum_service/schemas.py +418 -0
- package/runtime/apps/service/src/video_sum_service/settings_manager.py +130 -0
- package/runtime/apps/service/src/video_sum_service/task_artifacts.py +102 -0
- package/runtime/apps/service/src/video_sum_service/task_exports.py +108 -0
- package/runtime/apps/service/src/video_sum_service/transcribe_worker.py +9 -0
- package/runtime/apps/service/src/video_sum_service/video_assets.py +623 -0
- package/runtime/apps/service/src/video_sum_service/worker.py +381 -0
- package/runtime/apps/web/static/apple-touch-icon.png +0 -0
- package/runtime/apps/web/static/assets/KaTeX_AMS-Regular-BQhdFMY1.woff2 +0 -0
- package/runtime/apps/web/static/assets/KaTeX_AMS-Regular-DMm9YOAa.woff +0 -0
- package/runtime/apps/web/static/assets/KaTeX_AMS-Regular-DRggAlZN.ttf +0 -0
- package/runtime/apps/web/static/assets/KaTeX_Caligraphic-Bold-ATXxdsX0.ttf +0 -0
- package/runtime/apps/web/static/assets/KaTeX_Caligraphic-Bold-BEiXGLvX.woff +0 -0
- package/runtime/apps/web/static/assets/KaTeX_Caligraphic-Bold-Dq_IR9rO.woff2 +0 -0
- package/runtime/apps/web/static/assets/KaTeX_Caligraphic-Regular-CTRA-rTL.woff +0 -0
- package/runtime/apps/web/static/assets/KaTeX_Caligraphic-Regular-Di6jR-x-.woff2 +0 -0
- package/runtime/apps/web/static/assets/KaTeX_Caligraphic-Regular-wX97UBjC.ttf +0 -0
- package/runtime/apps/web/static/assets/KaTeX_Fraktur-Bold-BdnERNNW.ttf +0 -0
- package/runtime/apps/web/static/assets/KaTeX_Fraktur-Bold-BsDP51OF.woff +0 -0
- package/runtime/apps/web/static/assets/KaTeX_Fraktur-Bold-CL6g_b3V.woff2 +0 -0
- package/runtime/apps/web/static/assets/KaTeX_Fraktur-Regular-CB_wures.ttf +0 -0
- package/runtime/apps/web/static/assets/KaTeX_Fraktur-Regular-CTYiF6lA.woff2 +0 -0
- package/runtime/apps/web/static/assets/KaTeX_Fraktur-Regular-Dxdc4cR9.woff +0 -0
- package/runtime/apps/web/static/assets/KaTeX_Main-Bold-Cx986IdX.woff2 +0 -0
- package/runtime/apps/web/static/assets/KaTeX_Main-Bold-Jm3AIy58.woff +0 -0
- package/runtime/apps/web/static/assets/KaTeX_Main-Bold-waoOVXN0.ttf +0 -0
- package/runtime/apps/web/static/assets/KaTeX_Main-BoldItalic-DxDJ3AOS.woff2 +0 -0
- package/runtime/apps/web/static/assets/KaTeX_Main-BoldItalic-DzxPMmG6.ttf +0 -0
- package/runtime/apps/web/static/assets/KaTeX_Main-BoldItalic-SpSLRI95.woff +0 -0
- package/runtime/apps/web/static/assets/KaTeX_Main-Italic-3WenGoN9.ttf +0 -0
- package/runtime/apps/web/static/assets/KaTeX_Main-Italic-BMLOBm91.woff +0 -0
- package/runtime/apps/web/static/assets/KaTeX_Main-Italic-NWA7e6Wa.woff2 +0 -0
- package/runtime/apps/web/static/assets/KaTeX_Main-Regular-B22Nviop.woff2 +0 -0
- package/runtime/apps/web/static/assets/KaTeX_Main-Regular-Dr94JaBh.woff +0 -0
- package/runtime/apps/web/static/assets/KaTeX_Main-Regular-ypZvNtVU.ttf +0 -0
- package/runtime/apps/web/static/assets/KaTeX_Math-BoldItalic-B3XSjfu4.ttf +0 -0
- package/runtime/apps/web/static/assets/KaTeX_Math-BoldItalic-CZnvNsCZ.woff2 +0 -0
- package/runtime/apps/web/static/assets/KaTeX_Math-BoldItalic-iY-2wyZ7.woff +0 -0
- package/runtime/apps/web/static/assets/KaTeX_Math-Italic-DA0__PXp.woff +0 -0
- package/runtime/apps/web/static/assets/KaTeX_Math-Italic-flOr_0UB.ttf +0 -0
- package/runtime/apps/web/static/assets/KaTeX_Math-Italic-t53AETM-.woff2 +0 -0
- package/runtime/apps/web/static/assets/KaTeX_SansSerif-Bold-CFMepnvq.ttf +0 -0
- package/runtime/apps/web/static/assets/KaTeX_SansSerif-Bold-D1sUS0GD.woff2 +0 -0
- package/runtime/apps/web/static/assets/KaTeX_SansSerif-Bold-DbIhKOiC.woff +0 -0
- package/runtime/apps/web/static/assets/KaTeX_SansSerif-Italic-C3H0VqGB.woff2 +0 -0
- package/runtime/apps/web/static/assets/KaTeX_SansSerif-Italic-DN2j7dab.woff +0 -0
- package/runtime/apps/web/static/assets/KaTeX_SansSerif-Italic-YYjJ1zSn.ttf +0 -0
- package/runtime/apps/web/static/assets/KaTeX_SansSerif-Regular-BNo7hRIc.ttf +0 -0
- package/runtime/apps/web/static/assets/KaTeX_SansSerif-Regular-CS6fqUqJ.woff +0 -0
- package/runtime/apps/web/static/assets/KaTeX_SansSerif-Regular-DDBCnlJ7.woff2 +0 -0
- package/runtime/apps/web/static/assets/KaTeX_Script-Regular-C5JkGWo-.ttf +0 -0
- package/runtime/apps/web/static/assets/KaTeX_Script-Regular-D3wIWfF6.woff2 +0 -0
- package/runtime/apps/web/static/assets/KaTeX_Script-Regular-D5yQViql.woff +0 -0
- package/runtime/apps/web/static/assets/KaTeX_Size1-Regular-C195tn64.woff +0 -0
- package/runtime/apps/web/static/assets/KaTeX_Size1-Regular-Dbsnue_I.ttf +0 -0
- package/runtime/apps/web/static/assets/KaTeX_Size1-Regular-mCD8mA8B.woff2 +0 -0
- package/runtime/apps/web/static/assets/KaTeX_Size2-Regular-B7gKUWhC.ttf +0 -0
- package/runtime/apps/web/static/assets/KaTeX_Size2-Regular-Dy4dx90m.woff2 +0 -0
- package/runtime/apps/web/static/assets/KaTeX_Size2-Regular-oD1tc_U0.woff +0 -0
- package/runtime/apps/web/static/assets/KaTeX_Size3-Regular-CTq5MqoE.woff +0 -0
- package/runtime/apps/web/static/assets/KaTeX_Size3-Regular-DgpXs0kz.ttf +0 -0
- package/runtime/apps/web/static/assets/KaTeX_Size4-Regular-BF-4gkZK.woff +0 -0
- package/runtime/apps/web/static/assets/KaTeX_Size4-Regular-DWFBv043.ttf +0 -0
- package/runtime/apps/web/static/assets/KaTeX_Size4-Regular-Dl5lxZxV.woff2 +0 -0
- package/runtime/apps/web/static/assets/KaTeX_Typewriter-Regular-C0xS9mPB.woff +0 -0
- package/runtime/apps/web/static/assets/KaTeX_Typewriter-Regular-CO6r4hn1.woff2 +0 -0
- package/runtime/apps/web/static/assets/KaTeX_Typewriter-Regular-D3Ib7_Hf.ttf +0 -0
- package/runtime/apps/web/static/assets/icons/icon-180.png +0 -0
- package/runtime/apps/web/static/assets/icons/icon-512.png +0 -0
- package/runtime/apps/web/static/assets/icons/icon.svg +17 -0
- package/runtime/apps/web/static/assets/index-Cj5PVjQg.js +388 -0
- package/runtime/apps/web/static/assets/index-DU2t4_7s.css +1 -0
- package/runtime/apps/web/static/favicon-32x32.png +0 -0
- package/runtime/apps/web/static/favicon.ico +0 -0
- package/runtime/apps/web/static/favicon.svg +17 -0
- package/runtime/apps/web/static/index.html +19 -0
- package/runtime/apps/web/static/js/api.js +123 -0
- package/runtime/apps/web/static/js/main.js +932 -0
- package/runtime/apps/web/static/js/state.js +28 -0
- package/runtime/apps/web/static/js/utils.js +81 -0
- package/runtime/apps/web/static/js/views/home.js +604 -0
- package/runtime/apps/web/static/js/views/settings.js +477 -0
- package/runtime/apps/web/static/styles.css +2206 -0
- package/runtime/packages/core/pyproject.toml +18 -0
- package/runtime/packages/core/src/video_sum_core/__init__.py +1 -0
- package/runtime/packages/core/src/video_sum_core/errors.py +22 -0
- package/runtime/packages/core/src/video_sum_core/markdown_exports.py +154 -0
- package/runtime/packages/core/src/video_sum_core/models/__init__.py +1 -0
- package/runtime/packages/core/src/video_sum_core/models/tasks.py +70 -0
- package/runtime/packages/core/src/video_sum_core/pipeline/__init__.py +1 -0
- package/runtime/packages/core/src/video_sum_core/pipeline/base.py +46 -0
- package/runtime/packages/core/src/video_sum_core/pipeline/real.py +3467 -0
- package/runtime/packages/core/src/video_sum_core/transcribe_subprocess.py +173 -0
- package/runtime/packages/core/src/video_sum_core/utils.py +127 -0
- package/runtime/packages/infra/pyproject.toml +16 -0
- package/runtime/packages/infra/src/video_sum_infra/__init__.py +1 -0
- package/runtime/packages/infra/src/video_sum_infra/app.py +39 -0
- package/runtime/packages/infra/src/video_sum_infra/config.py +393 -0
- package/runtime/packages/infra/src/video_sum_infra/db.py +34 -0
- package/runtime/packages/infra/src/video_sum_infra/logging.py +54 -0
- package/runtime/packages/infra/src/video_sum_infra/paths.py +6 -0
- package/runtime/packages/infra/src/video_sum_infra/runtime.py +485 -0
|
@@ -0,0 +1,942 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import sqlite3
|
|
3
|
+
from datetime import datetime, timedelta, timezone
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from threading import Lock
|
|
6
|
+
|
|
7
|
+
from video_sum_core.models.tasks import TaskInput, TaskResult, TaskStatus
|
|
8
|
+
from video_sum_infra.db import sqlite_cursor
|
|
9
|
+
from video_sum_service.schemas import (
|
|
10
|
+
KnowledgeIndexChunkRecord,
|
|
11
|
+
TaskEventRecord,
|
|
12
|
+
TaskRecord,
|
|
13
|
+
VideoTagRecord,
|
|
14
|
+
VideoAssetRecord,
|
|
15
|
+
VideoPageOptionResponse,
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class SqliteTaskRepository:
|
|
20
|
+
def __init__(self, connection: sqlite3.Connection) -> None:
|
|
21
|
+
self._connection = connection
|
|
22
|
+
self._lock = Lock()
|
|
23
|
+
|
|
24
|
+
def initialize(self) -> None:
|
|
25
|
+
with self._lock, sqlite_cursor(self._connection) as cursor:
|
|
26
|
+
cursor.execute(
|
|
27
|
+
"""
|
|
28
|
+
CREATE TABLE IF NOT EXISTS video_assets (
|
|
29
|
+
video_id TEXT PRIMARY KEY,
|
|
30
|
+
canonical_id TEXT NOT NULL UNIQUE,
|
|
31
|
+
platform TEXT NOT NULL,
|
|
32
|
+
title TEXT NOT NULL,
|
|
33
|
+
source_url TEXT NOT NULL,
|
|
34
|
+
cover_url TEXT,
|
|
35
|
+
duration REAL,
|
|
36
|
+
page_catalog_json TEXT NOT NULL DEFAULT '[]',
|
|
37
|
+
latest_task_id TEXT,
|
|
38
|
+
latest_status TEXT,
|
|
39
|
+
latest_stage TEXT,
|
|
40
|
+
latest_error_message TEXT,
|
|
41
|
+
is_favorite INTEGER NOT NULL DEFAULT 0,
|
|
42
|
+
favorite_updated_at TEXT,
|
|
43
|
+
created_at TEXT NOT NULL,
|
|
44
|
+
updated_at TEXT NOT NULL
|
|
45
|
+
)
|
|
46
|
+
"""
|
|
47
|
+
)
|
|
48
|
+
self._ensure_column(cursor, "video_assets", "page_catalog_json", "TEXT NOT NULL DEFAULT '[]'")
|
|
49
|
+
self._ensure_column(cursor, "video_assets", "is_favorite", "INTEGER NOT NULL DEFAULT 0")
|
|
50
|
+
self._ensure_column(cursor, "video_assets", "favorite_updated_at", "TEXT")
|
|
51
|
+
cursor.execute(
|
|
52
|
+
"""
|
|
53
|
+
CREATE TABLE IF NOT EXISTS tasks (
|
|
54
|
+
task_id TEXT PRIMARY KEY,
|
|
55
|
+
video_id TEXT,
|
|
56
|
+
status TEXT NOT NULL,
|
|
57
|
+
task_input_json TEXT NOT NULL,
|
|
58
|
+
page_number INTEGER,
|
|
59
|
+
page_title TEXT,
|
|
60
|
+
result_json TEXT,
|
|
61
|
+
error_code TEXT,
|
|
62
|
+
error_message TEXT,
|
|
63
|
+
created_at TEXT NOT NULL,
|
|
64
|
+
updated_at TEXT NOT NULL
|
|
65
|
+
)
|
|
66
|
+
"""
|
|
67
|
+
)
|
|
68
|
+
self._ensure_column(cursor, "tasks", "video_id", "TEXT")
|
|
69
|
+
self._ensure_column(cursor, "tasks", "page_number", "INTEGER")
|
|
70
|
+
self._ensure_column(cursor, "tasks", "page_title", "TEXT")
|
|
71
|
+
cursor.execute(
|
|
72
|
+
"""
|
|
73
|
+
CREATE TABLE IF NOT EXISTS task_results (
|
|
74
|
+
task_id TEXT PRIMARY KEY,
|
|
75
|
+
result_json TEXT NOT NULL,
|
|
76
|
+
updated_at TEXT NOT NULL,
|
|
77
|
+
FOREIGN KEY(task_id) REFERENCES tasks(task_id)
|
|
78
|
+
)
|
|
79
|
+
"""
|
|
80
|
+
)
|
|
81
|
+
cursor.execute(
|
|
82
|
+
"""
|
|
83
|
+
CREATE TABLE IF NOT EXISTS task_events (
|
|
84
|
+
event_id TEXT PRIMARY KEY,
|
|
85
|
+
task_id TEXT NOT NULL,
|
|
86
|
+
stage TEXT NOT NULL,
|
|
87
|
+
progress INTEGER NOT NULL,
|
|
88
|
+
message TEXT NOT NULL,
|
|
89
|
+
payload_json TEXT NOT NULL,
|
|
90
|
+
created_at TEXT NOT NULL,
|
|
91
|
+
FOREIGN KEY(task_id) REFERENCES tasks(task_id)
|
|
92
|
+
)
|
|
93
|
+
"""
|
|
94
|
+
)
|
|
95
|
+
cursor.execute(
|
|
96
|
+
"""
|
|
97
|
+
CREATE TABLE IF NOT EXISTS video_tags (
|
|
98
|
+
video_id TEXT NOT NULL,
|
|
99
|
+
tag TEXT NOT NULL,
|
|
100
|
+
source TEXT NOT NULL DEFAULT 'manual',
|
|
101
|
+
confidence REAL NOT NULL DEFAULT 1.0,
|
|
102
|
+
created_at TEXT NOT NULL,
|
|
103
|
+
PRIMARY KEY (video_id, tag),
|
|
104
|
+
FOREIGN KEY(video_id) REFERENCES video_assets(video_id)
|
|
105
|
+
)
|
|
106
|
+
"""
|
|
107
|
+
)
|
|
108
|
+
cursor.execute(
|
|
109
|
+
"""
|
|
110
|
+
CREATE TABLE IF NOT EXISTS knowledge_index (
|
|
111
|
+
chunk_id TEXT PRIMARY KEY,
|
|
112
|
+
video_id TEXT NOT NULL,
|
|
113
|
+
embedding_json TEXT NOT NULL,
|
|
114
|
+
indexed_content TEXT NOT NULL,
|
|
115
|
+
index_type TEXT NOT NULL DEFAULT 'video_summary',
|
|
116
|
+
segment_order INTEGER,
|
|
117
|
+
anchor_label TEXT,
|
|
118
|
+
anchor_seconds REAL,
|
|
119
|
+
created_at TEXT NOT NULL,
|
|
120
|
+
updated_at TEXT NOT NULL,
|
|
121
|
+
FOREIGN KEY(video_id) REFERENCES video_assets(video_id)
|
|
122
|
+
)
|
|
123
|
+
"""
|
|
124
|
+
)
|
|
125
|
+
self._ensure_column(cursor, "knowledge_index", "chunk_id", "TEXT")
|
|
126
|
+
self._ensure_column(cursor, "knowledge_index", "video_id", "TEXT NOT NULL DEFAULT ''")
|
|
127
|
+
self._ensure_column(cursor, "knowledge_index", "embedding_json", "TEXT NOT NULL DEFAULT '[]'")
|
|
128
|
+
self._ensure_column(cursor, "knowledge_index", "indexed_content", "TEXT NOT NULL DEFAULT ''")
|
|
129
|
+
self._ensure_column(cursor, "knowledge_index", "index_type", "TEXT NOT NULL DEFAULT 'video_summary'")
|
|
130
|
+
self._ensure_column(cursor, "knowledge_index", "segment_order", "INTEGER")
|
|
131
|
+
self._ensure_column(cursor, "knowledge_index", "anchor_label", "TEXT")
|
|
132
|
+
self._ensure_column(cursor, "knowledge_index", "anchor_seconds", "REAL")
|
|
133
|
+
self._ensure_column(cursor, "knowledge_index", "created_at", "TEXT")
|
|
134
|
+
self._ensure_column(cursor, "knowledge_index", "updated_at", "TEXT")
|
|
135
|
+
|
|
136
|
+
def _ensure_column(self, cursor: sqlite3.Cursor, table: str, column: str, definition: str) -> None:
|
|
137
|
+
rows = cursor.execute(f"PRAGMA table_info({table})").fetchall()
|
|
138
|
+
names = {row["name"] if isinstance(row, sqlite3.Row) else row[1] for row in rows}
|
|
139
|
+
if column not in names:
|
|
140
|
+
cursor.execute(f"ALTER TABLE {table} ADD COLUMN {column} {definition}")
|
|
141
|
+
|
|
142
|
+
def _canonical_family_pattern(self, canonical_id: str) -> tuple[str, str]:
|
|
143
|
+
base = str(canonical_id or "").split("?", 1)[0]
|
|
144
|
+
return base, f"{base}?p=%"
|
|
145
|
+
|
|
146
|
+
def _consolidate_video_family(self, cursor: sqlite3.Cursor, canonical_id: str) -> tuple[str, str] | None:
|
|
147
|
+
family, pattern = self._canonical_family_pattern(canonical_id)
|
|
148
|
+
rows = cursor.execute(
|
|
149
|
+
"""
|
|
150
|
+
SELECT video_id, canonical_id, created_at, updated_at
|
|
151
|
+
FROM video_assets
|
|
152
|
+
WHERE canonical_id = ? OR canonical_id LIKE ?
|
|
153
|
+
ORDER BY
|
|
154
|
+
CASE WHEN canonical_id = ? THEN 0 ELSE 1 END,
|
|
155
|
+
updated_at DESC,
|
|
156
|
+
created_at ASC
|
|
157
|
+
""",
|
|
158
|
+
(family, pattern, family),
|
|
159
|
+
).fetchall()
|
|
160
|
+
if not rows:
|
|
161
|
+
return None
|
|
162
|
+
|
|
163
|
+
primary = rows[0]
|
|
164
|
+
primary_video_id = primary["video_id"]
|
|
165
|
+
created_at = primary["created_at"]
|
|
166
|
+
|
|
167
|
+
if primary["canonical_id"] != family:
|
|
168
|
+
cursor.execute(
|
|
169
|
+
"UPDATE video_assets SET canonical_id = ? WHERE video_id = ?",
|
|
170
|
+
(family, primary_video_id),
|
|
171
|
+
)
|
|
172
|
+
|
|
173
|
+
duplicate_ids = [row["video_id"] for row in rows[1:]]
|
|
174
|
+
for duplicate_id in duplicate_ids:
|
|
175
|
+
cursor.execute(
|
|
176
|
+
"UPDATE tasks SET video_id = ? WHERE video_id = ?",
|
|
177
|
+
(primary_video_id, duplicate_id),
|
|
178
|
+
)
|
|
179
|
+
cursor.execute("DELETE FROM video_assets WHERE video_id = ?", (duplicate_id,))
|
|
180
|
+
|
|
181
|
+
return primary_video_id, created_at
|
|
182
|
+
|
|
183
|
+
def upsert_video_asset(self, asset: VideoAssetRecord) -> VideoAssetRecord:
|
|
184
|
+
updated_at = datetime.now(timezone.utc).isoformat()
|
|
185
|
+
created_at = asset.created_at.isoformat()
|
|
186
|
+
with self._lock, sqlite_cursor(self._connection) as cursor:
|
|
187
|
+
consolidated = self._consolidate_video_family(cursor, asset.canonical_id)
|
|
188
|
+
existing = cursor.execute(
|
|
189
|
+
"SELECT video_id, created_at FROM video_assets WHERE canonical_id = ?",
|
|
190
|
+
(asset.canonical_id,),
|
|
191
|
+
).fetchone()
|
|
192
|
+
if existing is not None:
|
|
193
|
+
video_id = existing["video_id"]
|
|
194
|
+
created = existing["created_at"]
|
|
195
|
+
elif consolidated is not None:
|
|
196
|
+
video_id, created = consolidated
|
|
197
|
+
else:
|
|
198
|
+
video_id = asset.video_id
|
|
199
|
+
created = created_at
|
|
200
|
+
cursor.execute(
|
|
201
|
+
"""
|
|
202
|
+
INSERT INTO video_assets (
|
|
203
|
+
video_id, canonical_id, platform, title, source_url, cover_url, duration, page_catalog_json,
|
|
204
|
+
latest_task_id, latest_status, latest_stage, latest_error_message, is_favorite, favorite_updated_at,
|
|
205
|
+
created_at, updated_at
|
|
206
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
207
|
+
ON CONFLICT(canonical_id) DO UPDATE SET
|
|
208
|
+
title = excluded.title,
|
|
209
|
+
source_url = excluded.source_url,
|
|
210
|
+
cover_url = excluded.cover_url,
|
|
211
|
+
duration = excluded.duration,
|
|
212
|
+
page_catalog_json = excluded.page_catalog_json,
|
|
213
|
+
updated_at = excluded.updated_at
|
|
214
|
+
""",
|
|
215
|
+
(
|
|
216
|
+
video_id,
|
|
217
|
+
asset.canonical_id,
|
|
218
|
+
asset.platform,
|
|
219
|
+
asset.title,
|
|
220
|
+
asset.source_url,
|
|
221
|
+
asset.cover_url,
|
|
222
|
+
asset.duration,
|
|
223
|
+
json.dumps([page.model_dump(mode="json") for page in asset.pages], ensure_ascii=False),
|
|
224
|
+
asset.latest_task_id,
|
|
225
|
+
asset.latest_status.value if asset.latest_status else None,
|
|
226
|
+
asset.latest_stage,
|
|
227
|
+
asset.latest_error_message,
|
|
228
|
+
1 if asset.is_favorite else 0,
|
|
229
|
+
asset.favorite_updated_at.isoformat() if asset.favorite_updated_at else None,
|
|
230
|
+
created,
|
|
231
|
+
updated_at,
|
|
232
|
+
),
|
|
233
|
+
)
|
|
234
|
+
refreshed = self.get_video_asset(video_id)
|
|
235
|
+
assert refreshed is not None
|
|
236
|
+
return refreshed
|
|
237
|
+
|
|
238
|
+
def get_video_asset(self, video_id: str) -> VideoAssetRecord | None:
|
|
239
|
+
with self._lock, sqlite_cursor(self._connection) as cursor:
|
|
240
|
+
row = cursor.execute(
|
|
241
|
+
"""
|
|
242
|
+
SELECT
|
|
243
|
+
v.video_id, v.canonical_id, v.platform, v.title, v.source_url, v.cover_url, v.duration,
|
|
244
|
+
v.page_catalog_json,
|
|
245
|
+
v.latest_task_id, v.latest_status, v.latest_stage, v.latest_error_message,
|
|
246
|
+
v.is_favorite, v.favorite_updated_at,
|
|
247
|
+
v.created_at, v.updated_at, r.result_json AS latest_result_json
|
|
248
|
+
FROM video_assets v
|
|
249
|
+
LEFT JOIN task_results r ON r.task_id = v.latest_task_id
|
|
250
|
+
WHERE v.video_id = ?
|
|
251
|
+
""",
|
|
252
|
+
(video_id,),
|
|
253
|
+
).fetchone()
|
|
254
|
+
return self._row_to_video_asset(row) if row is not None else None
|
|
255
|
+
|
|
256
|
+
def get_video_asset_by_canonical_id(self, canonical_id: str) -> VideoAssetRecord | None:
|
|
257
|
+
with self._lock, sqlite_cursor(self._connection) as cursor:
|
|
258
|
+
row = cursor.execute(
|
|
259
|
+
"""
|
|
260
|
+
SELECT
|
|
261
|
+
v.video_id, v.canonical_id, v.platform, v.title, v.source_url, v.cover_url, v.duration,
|
|
262
|
+
v.page_catalog_json,
|
|
263
|
+
v.latest_task_id, v.latest_status, v.latest_stage, v.latest_error_message,
|
|
264
|
+
v.is_favorite, v.favorite_updated_at,
|
|
265
|
+
v.created_at, v.updated_at, r.result_json AS latest_result_json
|
|
266
|
+
FROM video_assets v
|
|
267
|
+
LEFT JOIN task_results r ON r.task_id = v.latest_task_id
|
|
268
|
+
WHERE v.canonical_id = ?
|
|
269
|
+
""",
|
|
270
|
+
(canonical_id,),
|
|
271
|
+
).fetchone()
|
|
272
|
+
return self._row_to_video_asset(row) if row is not None else None
|
|
273
|
+
|
|
274
|
+
def list_video_assets(self) -> list[VideoAssetRecord]:
|
|
275
|
+
with self._lock, sqlite_cursor(self._connection) as cursor:
|
|
276
|
+
rows = cursor.execute(
|
|
277
|
+
"""
|
|
278
|
+
SELECT
|
|
279
|
+
v.video_id, v.canonical_id, v.platform, v.title, v.source_url, v.cover_url, v.duration,
|
|
280
|
+
v.page_catalog_json,
|
|
281
|
+
v.latest_task_id, v.latest_status, v.latest_stage, v.latest_error_message,
|
|
282
|
+
v.is_favorite, v.favorite_updated_at,
|
|
283
|
+
v.created_at, v.updated_at, r.result_json AS latest_result_json
|
|
284
|
+
FROM video_assets v
|
|
285
|
+
LEFT JOIN task_results r ON r.task_id = v.latest_task_id
|
|
286
|
+
ORDER BY v.updated_at DESC
|
|
287
|
+
"""
|
|
288
|
+
).fetchall()
|
|
289
|
+
videos = [self._row_to_video_asset(row) for row in rows]
|
|
290
|
+
grouped: dict[str, VideoAssetRecord] = {}
|
|
291
|
+
for video in videos:
|
|
292
|
+
family, _ = self._canonical_family_pattern(video.canonical_id)
|
|
293
|
+
current = grouped.get(family)
|
|
294
|
+
if current is None:
|
|
295
|
+
grouped[family] = video
|
|
296
|
+
continue
|
|
297
|
+
current_updated = current.updated_at.timestamp()
|
|
298
|
+
video_updated = video.updated_at.timestamp()
|
|
299
|
+
if video_updated > current_updated or ("?p=" in current.canonical_id and "?p=" not in video.canonical_id):
|
|
300
|
+
grouped[family] = video
|
|
301
|
+
return list(grouped.values())
|
|
302
|
+
|
|
303
|
+
def create_task(
|
|
304
|
+
self,
|
|
305
|
+
task_input: TaskInput,
|
|
306
|
+
video_id: str | None = None,
|
|
307
|
+
*,
|
|
308
|
+
page_number: int | None = None,
|
|
309
|
+
page_title: str | None = None,
|
|
310
|
+
) -> TaskRecord:
|
|
311
|
+
record = TaskRecord(
|
|
312
|
+
task_input=task_input,
|
|
313
|
+
video_id=video_id,
|
|
314
|
+
page_number=page_number,
|
|
315
|
+
page_title=page_title,
|
|
316
|
+
)
|
|
317
|
+
payload = self._serialize_record(record)
|
|
318
|
+
with self._lock, sqlite_cursor(self._connection) as cursor:
|
|
319
|
+
cursor.execute(
|
|
320
|
+
"""
|
|
321
|
+
INSERT INTO tasks (
|
|
322
|
+
task_id, video_id, status, task_input_json, page_number, page_title, result_json, error_code,
|
|
323
|
+
error_message, created_at, updated_at
|
|
324
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
325
|
+
""",
|
|
326
|
+
(
|
|
327
|
+
payload["task_id"],
|
|
328
|
+
payload["video_id"],
|
|
329
|
+
payload["status"],
|
|
330
|
+
payload["task_input_json"],
|
|
331
|
+
payload["page_number"],
|
|
332
|
+
payload["page_title"],
|
|
333
|
+
payload["result_json"],
|
|
334
|
+
payload["error_code"],
|
|
335
|
+
payload["error_message"],
|
|
336
|
+
payload["created_at"],
|
|
337
|
+
payload["updated_at"],
|
|
338
|
+
),
|
|
339
|
+
)
|
|
340
|
+
if video_id:
|
|
341
|
+
cursor.execute(
|
|
342
|
+
"""
|
|
343
|
+
UPDATE video_assets
|
|
344
|
+
SET latest_task_id = ?, latest_status = ?, latest_stage = ?, latest_error_message = NULL, updated_at = ?
|
|
345
|
+
WHERE video_id = ?
|
|
346
|
+
""",
|
|
347
|
+
(record.task_id, record.status.value, "queued", payload["updated_at"], video_id),
|
|
348
|
+
)
|
|
349
|
+
return record
|
|
350
|
+
|
|
351
|
+
def list_tasks(self) -> list[TaskRecord]:
|
|
352
|
+
with self._lock, sqlite_cursor(self._connection) as cursor:
|
|
353
|
+
rows = cursor.execute(
|
|
354
|
+
"""
|
|
355
|
+
SELECT
|
|
356
|
+
t.task_id, t.video_id, t.status, t.task_input_json, t.page_number, t.page_title,
|
|
357
|
+
r.result_json, t.error_code,
|
|
358
|
+
t.error_message, t.created_at, t.updated_at
|
|
359
|
+
FROM tasks t
|
|
360
|
+
LEFT JOIN task_results r ON r.task_id = t.task_id
|
|
361
|
+
ORDER BY t.created_at DESC
|
|
362
|
+
"""
|
|
363
|
+
).fetchall()
|
|
364
|
+
return [self._row_to_record(row) for row in rows]
|
|
365
|
+
|
|
366
|
+
def list_tasks_for_video(self, video_id: str) -> list[TaskRecord]:
|
|
367
|
+
with self._lock, sqlite_cursor(self._connection) as cursor:
|
|
368
|
+
asset_row = cursor.execute(
|
|
369
|
+
"SELECT canonical_id FROM video_assets WHERE video_id = ?",
|
|
370
|
+
(video_id,),
|
|
371
|
+
).fetchone()
|
|
372
|
+
if asset_row is None:
|
|
373
|
+
return []
|
|
374
|
+
family, pattern = self._canonical_family_pattern(asset_row["canonical_id"])
|
|
375
|
+
rows = cursor.execute(
|
|
376
|
+
"""
|
|
377
|
+
SELECT
|
|
378
|
+
t.task_id, t.video_id, t.status, t.task_input_json, t.page_number, t.page_title,
|
|
379
|
+
r.result_json, t.error_code,
|
|
380
|
+
t.error_message, t.created_at, t.updated_at
|
|
381
|
+
FROM tasks t
|
|
382
|
+
JOIN video_assets v ON v.video_id = t.video_id
|
|
383
|
+
LEFT JOIN task_results r ON r.task_id = t.task_id
|
|
384
|
+
WHERE v.canonical_id = ? OR v.canonical_id LIKE ?
|
|
385
|
+
ORDER BY t.created_at DESC
|
|
386
|
+
""",
|
|
387
|
+
(family, pattern),
|
|
388
|
+
).fetchall()
|
|
389
|
+
return [self._row_to_record(row) for row in rows]
|
|
390
|
+
|
|
391
|
+
def get_task(self, task_id: str) -> TaskRecord | None:
|
|
392
|
+
with self._lock, sqlite_cursor(self._connection) as cursor:
|
|
393
|
+
row = cursor.execute(
|
|
394
|
+
"""
|
|
395
|
+
SELECT
|
|
396
|
+
t.task_id, t.video_id, t.status, t.task_input_json, t.page_number, t.page_title,
|
|
397
|
+
r.result_json, t.error_code,
|
|
398
|
+
t.error_message, t.created_at, t.updated_at
|
|
399
|
+
FROM tasks t
|
|
400
|
+
LEFT JOIN task_results r ON r.task_id = t.task_id
|
|
401
|
+
WHERE t.task_id = ?
|
|
402
|
+
""",
|
|
403
|
+
(task_id,),
|
|
404
|
+
).fetchone()
|
|
405
|
+
return self._row_to_record(row) if row is not None else None
|
|
406
|
+
|
|
407
|
+
def delete_task(self, task_id: str) -> bool:
|
|
408
|
+
with self._lock, sqlite_cursor(self._connection) as cursor:
|
|
409
|
+
row = cursor.execute("SELECT video_id FROM tasks WHERE task_id = ?", (task_id,)).fetchone()
|
|
410
|
+
if row is None:
|
|
411
|
+
return False
|
|
412
|
+
video_id = row["video_id"]
|
|
413
|
+
cursor.execute("DELETE FROM task_events WHERE task_id = ?", (task_id,))
|
|
414
|
+
cursor.execute("DELETE FROM task_results WHERE task_id = ?", (task_id,))
|
|
415
|
+
cursor.execute("DELETE FROM tasks WHERE task_id = ?", (task_id,))
|
|
416
|
+
if video_id:
|
|
417
|
+
latest = cursor.execute(
|
|
418
|
+
"""
|
|
419
|
+
SELECT task_id, status, error_message, updated_at
|
|
420
|
+
FROM tasks
|
|
421
|
+
WHERE video_id = ?
|
|
422
|
+
ORDER BY updated_at DESC
|
|
423
|
+
LIMIT 1
|
|
424
|
+
""",
|
|
425
|
+
(video_id,),
|
|
426
|
+
).fetchone()
|
|
427
|
+
if latest is None:
|
|
428
|
+
cursor.execute(
|
|
429
|
+
"""
|
|
430
|
+
UPDATE video_assets
|
|
431
|
+
SET latest_task_id = NULL, latest_status = NULL, latest_stage = NULL,
|
|
432
|
+
latest_error_message = NULL, updated_at = ?
|
|
433
|
+
WHERE video_id = ?
|
|
434
|
+
""",
|
|
435
|
+
(datetime.now(timezone.utc).isoformat(), video_id),
|
|
436
|
+
)
|
|
437
|
+
else:
|
|
438
|
+
cursor.execute(
|
|
439
|
+
"""
|
|
440
|
+
UPDATE video_assets
|
|
441
|
+
SET latest_task_id = ?, latest_status = ?, latest_error_message = ?, updated_at = ?
|
|
442
|
+
WHERE video_id = ?
|
|
443
|
+
""",
|
|
444
|
+
(
|
|
445
|
+
latest["task_id"],
|
|
446
|
+
latest["status"],
|
|
447
|
+
latest["error_message"],
|
|
448
|
+
latest["updated_at"],
|
|
449
|
+
video_id,
|
|
450
|
+
),
|
|
451
|
+
)
|
|
452
|
+
return True
|
|
453
|
+
|
|
454
|
+
def delete_video_asset(self, video_id: str) -> bool:
|
|
455
|
+
with self._lock, sqlite_cursor(self._connection) as cursor:
|
|
456
|
+
video_row = cursor.execute(
|
|
457
|
+
"SELECT canonical_id, cover_url FROM video_assets WHERE video_id = ?",
|
|
458
|
+
(video_id,),
|
|
459
|
+
).fetchone()
|
|
460
|
+
if video_row is None:
|
|
461
|
+
return False
|
|
462
|
+
|
|
463
|
+
family, pattern = self._canonical_family_pattern(video_row["canonical_id"])
|
|
464
|
+
family_rows = cursor.execute(
|
|
465
|
+
"SELECT video_id FROM video_assets WHERE canonical_id = ? OR canonical_id LIKE ?",
|
|
466
|
+
(family, pattern),
|
|
467
|
+
).fetchall()
|
|
468
|
+
video_ids = [row["video_id"] for row in family_rows]
|
|
469
|
+
|
|
470
|
+
placeholders = ",".join("?" for _ in video_ids)
|
|
471
|
+
task_rows = cursor.execute(
|
|
472
|
+
f"SELECT task_id FROM tasks WHERE video_id IN ({placeholders})",
|
|
473
|
+
tuple(video_ids),
|
|
474
|
+
).fetchall()
|
|
475
|
+
task_ids = [row["task_id"] for row in task_rows]
|
|
476
|
+
|
|
477
|
+
for task_id in task_ids:
|
|
478
|
+
cursor.execute("DELETE FROM task_events WHERE task_id = ?", (task_id,))
|
|
479
|
+
cursor.execute("DELETE FROM task_results WHERE task_id = ?", (task_id,))
|
|
480
|
+
cursor.execute(f"DELETE FROM video_tags WHERE video_id IN ({placeholders})", tuple(video_ids))
|
|
481
|
+
cursor.execute(f"DELETE FROM knowledge_index WHERE video_id IN ({placeholders})", tuple(video_ids))
|
|
482
|
+
cursor.execute(f"DELETE FROM tasks WHERE video_id IN ({placeholders})", tuple(video_ids))
|
|
483
|
+
cursor.execute(f"DELETE FROM video_assets WHERE video_id IN ({placeholders})", tuple(video_ids))
|
|
484
|
+
return True
|
|
485
|
+
|
|
486
|
+
def add_video_tag(self, video_id: str, tag: str, source: str = "manual", confidence: float = 1.0) -> bool:
|
|
487
|
+
normalized_tag = str(tag or "").strip()
|
|
488
|
+
if not normalized_tag:
|
|
489
|
+
return False
|
|
490
|
+
|
|
491
|
+
created_at = datetime.now(timezone.utc).isoformat()
|
|
492
|
+
with self._lock, sqlite_cursor(self._connection) as cursor:
|
|
493
|
+
video = cursor.execute("SELECT video_id FROM video_assets WHERE video_id = ?", (video_id,)).fetchone()
|
|
494
|
+
if video is None:
|
|
495
|
+
return False
|
|
496
|
+
cursor.execute(
|
|
497
|
+
"""
|
|
498
|
+
INSERT INTO video_tags (video_id, tag, source, confidence, created_at)
|
|
499
|
+
VALUES (?, ?, ?, ?, ?)
|
|
500
|
+
ON CONFLICT(video_id, tag) DO UPDATE SET
|
|
501
|
+
source = CASE
|
|
502
|
+
WHEN excluded.source = 'manual' THEN 'manual'
|
|
503
|
+
ELSE video_tags.source
|
|
504
|
+
END,
|
|
505
|
+
confidence = CASE
|
|
506
|
+
WHEN excluded.source = 'manual' THEN 1.0
|
|
507
|
+
ELSE excluded.confidence
|
|
508
|
+
END,
|
|
509
|
+
created_at = CASE
|
|
510
|
+
WHEN excluded.source = 'manual' THEN excluded.created_at
|
|
511
|
+
ELSE video_tags.created_at
|
|
512
|
+
END
|
|
513
|
+
""",
|
|
514
|
+
(video_id, normalized_tag, source, float(confidence), created_at),
|
|
515
|
+
)
|
|
516
|
+
return True
|
|
517
|
+
|
|
518
|
+
def remove_video_tag(self, video_id: str, tag: str) -> bool:
|
|
519
|
+
normalized_tag = str(tag or "").strip()
|
|
520
|
+
if not normalized_tag:
|
|
521
|
+
return False
|
|
522
|
+
with self._lock, sqlite_cursor(self._connection) as cursor:
|
|
523
|
+
cursor.execute("DELETE FROM video_tags WHERE video_id = ? AND tag = ?", (video_id, normalized_tag))
|
|
524
|
+
return cursor.rowcount > 0
|
|
525
|
+
|
|
526
|
+
def list_video_tags(self, video_id: str) -> list[VideoTagRecord]:
|
|
527
|
+
with self._lock, sqlite_cursor(self._connection) as cursor:
|
|
528
|
+
rows = cursor.execute(
|
|
529
|
+
"""
|
|
530
|
+
SELECT video_id, tag, source, confidence, created_at
|
|
531
|
+
FROM video_tags
|
|
532
|
+
WHERE video_id = ?
|
|
533
|
+
ORDER BY source = 'manual' DESC, confidence DESC, tag COLLATE NOCASE ASC
|
|
534
|
+
""",
|
|
535
|
+
(video_id,),
|
|
536
|
+
).fetchall()
|
|
537
|
+
return [
|
|
538
|
+
VideoTagRecord(
|
|
539
|
+
video_id=row["video_id"],
|
|
540
|
+
tag=row["tag"],
|
|
541
|
+
source=row["source"],
|
|
542
|
+
confidence=float(row["confidence"]),
|
|
543
|
+
created_at=datetime.fromisoformat(row["created_at"]),
|
|
544
|
+
)
|
|
545
|
+
for row in rows
|
|
546
|
+
]
|
|
547
|
+
|
|
548
|
+
def list_all_tags(self) -> list[dict[str, object]]:
|
|
549
|
+
with self._lock, sqlite_cursor(self._connection) as cursor:
|
|
550
|
+
rows = cursor.execute(
|
|
551
|
+
"""
|
|
552
|
+
SELECT tag, COUNT(*) AS count
|
|
553
|
+
FROM video_tags
|
|
554
|
+
GROUP BY tag
|
|
555
|
+
ORDER BY count DESC, tag COLLATE NOCASE ASC
|
|
556
|
+
"""
|
|
557
|
+
).fetchall()
|
|
558
|
+
videos_by_tag_rows = cursor.execute(
|
|
559
|
+
"""
|
|
560
|
+
SELECT tag, video_id
|
|
561
|
+
FROM video_tags
|
|
562
|
+
ORDER BY tag COLLATE NOCASE ASC, video_id ASC
|
|
563
|
+
"""
|
|
564
|
+
).fetchall()
|
|
565
|
+
videos_by_tag: dict[str, list[str]] = {}
|
|
566
|
+
for row in videos_by_tag_rows:
|
|
567
|
+
videos_by_tag.setdefault(row["tag"], []).append(row["video_id"])
|
|
568
|
+
return [
|
|
569
|
+
{"tag": row["tag"], "count": int(row["count"]), "videos": videos_by_tag.get(row["tag"], [])}
|
|
570
|
+
for row in rows
|
|
571
|
+
]
|
|
572
|
+
|
|
573
|
+
def list_all_video_tags(self) -> list[VideoTagRecord]:
|
|
574
|
+
with self._lock, sqlite_cursor(self._connection) as cursor:
|
|
575
|
+
rows = cursor.execute(
|
|
576
|
+
"""
|
|
577
|
+
SELECT video_id, tag, source, confidence, created_at
|
|
578
|
+
FROM video_tags
|
|
579
|
+
ORDER BY tag COLLATE NOCASE ASC, confidence DESC, video_id ASC
|
|
580
|
+
"""
|
|
581
|
+
).fetchall()
|
|
582
|
+
return [
|
|
583
|
+
VideoTagRecord(
|
|
584
|
+
video_id=row["video_id"],
|
|
585
|
+
tag=row["tag"],
|
|
586
|
+
source=row["source"],
|
|
587
|
+
confidence=float(row["confidence"]),
|
|
588
|
+
created_at=datetime.fromisoformat(row["created_at"]),
|
|
589
|
+
)
|
|
590
|
+
for row in rows
|
|
591
|
+
]
|
|
592
|
+
|
|
593
|
+
def list_untagged_video_ids(self) -> list[str]:
|
|
594
|
+
with self._lock, sqlite_cursor(self._connection) as cursor:
|
|
595
|
+
rows = cursor.execute(
|
|
596
|
+
"""
|
|
597
|
+
SELECT v.video_id
|
|
598
|
+
FROM video_assets v
|
|
599
|
+
LEFT JOIN video_tags t ON t.video_id = v.video_id
|
|
600
|
+
WHERE t.video_id IS NULL
|
|
601
|
+
ORDER BY v.updated_at DESC
|
|
602
|
+
"""
|
|
603
|
+
).fetchall()
|
|
604
|
+
return [row["video_id"] for row in rows]
|
|
605
|
+
|
|
606
|
+
def replace_knowledge_chunks(self, video_id: str, chunks: list[KnowledgeIndexChunkRecord]) -> int:
|
|
607
|
+
with self._lock, sqlite_cursor(self._connection) as cursor:
|
|
608
|
+
cursor.execute("DELETE FROM knowledge_index WHERE video_id = ?", (video_id,))
|
|
609
|
+
for chunk in chunks:
|
|
610
|
+
cursor.execute(
|
|
611
|
+
"""
|
|
612
|
+
INSERT INTO knowledge_index (
|
|
613
|
+
chunk_id, video_id, embedding_json, indexed_content, index_type,
|
|
614
|
+
segment_order, anchor_label, anchor_seconds, created_at, updated_at
|
|
615
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
616
|
+
""",
|
|
617
|
+
(
|
|
618
|
+
chunk.chunk_id,
|
|
619
|
+
chunk.video_id,
|
|
620
|
+
chunk.embedding_json,
|
|
621
|
+
chunk.indexed_content,
|
|
622
|
+
chunk.index_type,
|
|
623
|
+
chunk.segment_order,
|
|
624
|
+
chunk.anchor_label,
|
|
625
|
+
chunk.anchor_seconds,
|
|
626
|
+
chunk.created_at.isoformat(),
|
|
627
|
+
chunk.updated_at.isoformat(),
|
|
628
|
+
),
|
|
629
|
+
)
|
|
630
|
+
return len(chunks)
|
|
631
|
+
|
|
632
|
+
def delete_knowledge_chunks(self, video_id: str) -> bool:
|
|
633
|
+
with self._lock, sqlite_cursor(self._connection) as cursor:
|
|
634
|
+
cursor.execute("DELETE FROM knowledge_index WHERE video_id = ?", (video_id,))
|
|
635
|
+
return cursor.rowcount > 0
|
|
636
|
+
|
|
637
|
+
def clear_knowledge_chunks(self) -> None:
|
|
638
|
+
with self._lock, sqlite_cursor(self._connection) as cursor:
|
|
639
|
+
cursor.execute("DELETE FROM knowledge_index")
|
|
640
|
+
|
|
641
|
+
def list_knowledge_chunks(self, video_id: str | None = None) -> list[KnowledgeIndexChunkRecord]:
|
|
642
|
+
with self._lock, sqlite_cursor(self._connection) as cursor:
|
|
643
|
+
if video_id:
|
|
644
|
+
rows = cursor.execute(
|
|
645
|
+
"""
|
|
646
|
+
SELECT
|
|
647
|
+
chunk_id, video_id, embedding_json, indexed_content, index_type,
|
|
648
|
+
segment_order, anchor_label, anchor_seconds, created_at, updated_at
|
|
649
|
+
FROM knowledge_index
|
|
650
|
+
WHERE video_id = ?
|
|
651
|
+
ORDER BY segment_order ASC, updated_at DESC
|
|
652
|
+
""",
|
|
653
|
+
(video_id,),
|
|
654
|
+
).fetchall()
|
|
655
|
+
else:
|
|
656
|
+
rows = cursor.execute(
|
|
657
|
+
"""
|
|
658
|
+
SELECT
|
|
659
|
+
chunk_id, video_id, embedding_json, indexed_content, index_type,
|
|
660
|
+
segment_order, anchor_label, anchor_seconds, created_at, updated_at
|
|
661
|
+
FROM knowledge_index
|
|
662
|
+
ORDER BY updated_at DESC
|
|
663
|
+
"""
|
|
664
|
+
).fetchall()
|
|
665
|
+
return [
|
|
666
|
+
KnowledgeIndexChunkRecord(
|
|
667
|
+
chunk_id=row["chunk_id"],
|
|
668
|
+
video_id=row["video_id"],
|
|
669
|
+
embedding_json=row["embedding_json"],
|
|
670
|
+
indexed_content=row["indexed_content"],
|
|
671
|
+
index_type=row["index_type"],
|
|
672
|
+
segment_order=row["segment_order"],
|
|
673
|
+
anchor_label=row["anchor_label"],
|
|
674
|
+
anchor_seconds=row["anchor_seconds"],
|
|
675
|
+
created_at=datetime.fromisoformat(row["created_at"]),
|
|
676
|
+
updated_at=datetime.fromisoformat(row["updated_at"]),
|
|
677
|
+
)
|
|
678
|
+
for row in rows
|
|
679
|
+
]
|
|
680
|
+
|
|
681
|
+
def get_knowledge_chunk_count(self) -> int:
|
|
682
|
+
with self._lock, sqlite_cursor(self._connection) as cursor:
|
|
683
|
+
row = cursor.execute("SELECT COUNT(*) AS count FROM knowledge_index").fetchone()
|
|
684
|
+
return int(row["count"]) if row is not None else 0
|
|
685
|
+
|
|
686
|
+
def set_video_favorite(self, video_id: str, is_favorite: bool) -> VideoAssetRecord | None:
|
|
687
|
+
favorite_updated_at = datetime.now(timezone.utc).isoformat() if is_favorite else None
|
|
688
|
+
with self._lock, sqlite_cursor(self._connection) as cursor:
|
|
689
|
+
row = cursor.execute("SELECT video_id FROM video_assets WHERE video_id = ?", (video_id,)).fetchone()
|
|
690
|
+
if row is None:
|
|
691
|
+
return None
|
|
692
|
+
cursor.execute(
|
|
693
|
+
"""
|
|
694
|
+
UPDATE video_assets
|
|
695
|
+
SET is_favorite = ?, favorite_updated_at = ?
|
|
696
|
+
WHERE video_id = ?
|
|
697
|
+
""",
|
|
698
|
+
(1 if is_favorite else 0, favorite_updated_at, video_id),
|
|
699
|
+
)
|
|
700
|
+
return self.get_video_asset(video_id)
|
|
701
|
+
|
|
702
|
+
def update_status(self, task_id: str, status: TaskStatus) -> TaskRecord | None:
|
|
703
|
+
updated_at = datetime.now(timezone.utc).isoformat()
|
|
704
|
+
with self._lock, sqlite_cursor(self._connection) as cursor:
|
|
705
|
+
cursor.execute(
|
|
706
|
+
"UPDATE tasks SET status = ?, updated_at = ? WHERE task_id = ?",
|
|
707
|
+
(status.value, updated_at, task_id),
|
|
708
|
+
)
|
|
709
|
+
row = cursor.execute("SELECT video_id FROM tasks WHERE task_id = ?", (task_id,)).fetchone()
|
|
710
|
+
if row is not None and row["video_id"]:
|
|
711
|
+
cursor.execute(
|
|
712
|
+
"""
|
|
713
|
+
UPDATE video_assets
|
|
714
|
+
SET latest_task_id = ?, latest_status = ?, updated_at = ?
|
|
715
|
+
WHERE video_id = ?
|
|
716
|
+
""",
|
|
717
|
+
(task_id, status.value, updated_at, row["video_id"]),
|
|
718
|
+
)
|
|
719
|
+
return self.get_task(task_id)
|
|
720
|
+
|
|
721
|
+
def save_result(self, task_id: str, result: TaskResult) -> TaskRecord | None:
|
|
722
|
+
updated_at = datetime.now(timezone.utc).isoformat()
|
|
723
|
+
payload = json.dumps(result.model_dump(mode="json"), ensure_ascii=False)
|
|
724
|
+
with self._lock, sqlite_cursor(self._connection) as cursor:
|
|
725
|
+
cursor.execute(
|
|
726
|
+
"""
|
|
727
|
+
INSERT INTO task_results (task_id, result_json, updated_at)
|
|
728
|
+
VALUES (?, ?, ?)
|
|
729
|
+
ON CONFLICT(task_id) DO UPDATE SET result_json = excluded.result_json, updated_at = excluded.updated_at
|
|
730
|
+
""",
|
|
731
|
+
(task_id, payload, updated_at),
|
|
732
|
+
)
|
|
733
|
+
cursor.execute("UPDATE tasks SET updated_at = ? WHERE task_id = ?", (updated_at, task_id))
|
|
734
|
+
row = cursor.execute("SELECT video_id FROM tasks WHERE task_id = ?", (task_id,)).fetchone()
|
|
735
|
+
if row is not None and row["video_id"]:
|
|
736
|
+
cursor.execute(
|
|
737
|
+
"""
|
|
738
|
+
UPDATE video_assets
|
|
739
|
+
SET latest_task_id = ?, updated_at = ?
|
|
740
|
+
WHERE video_id = ?
|
|
741
|
+
""",
|
|
742
|
+
(task_id, updated_at, row["video_id"]),
|
|
743
|
+
)
|
|
744
|
+
return self.get_task(task_id)
|
|
745
|
+
|
|
746
|
+
def update_error(self, task_id: str, error_code: str, error_message: str) -> TaskRecord | None:
|
|
747
|
+
updated_at = datetime.now(timezone.utc).isoformat()
|
|
748
|
+
with self._lock, sqlite_cursor(self._connection) as cursor:
|
|
749
|
+
cursor.execute(
|
|
750
|
+
"""
|
|
751
|
+
UPDATE tasks SET error_code = ?, error_message = ?, updated_at = ? WHERE task_id = ?
|
|
752
|
+
""",
|
|
753
|
+
(error_code, error_message, updated_at, task_id),
|
|
754
|
+
)
|
|
755
|
+
row = cursor.execute("SELECT video_id FROM tasks WHERE task_id = ?", (task_id,)).fetchone()
|
|
756
|
+
if row is not None and row["video_id"]:
|
|
757
|
+
cursor.execute(
|
|
758
|
+
"""
|
|
759
|
+
UPDATE video_assets
|
|
760
|
+
SET latest_task_id = ?, latest_error_message = ?, updated_at = ?
|
|
761
|
+
WHERE video_id = ?
|
|
762
|
+
""",
|
|
763
|
+
(task_id, error_message, updated_at, row["video_id"]),
|
|
764
|
+
)
|
|
765
|
+
return self.get_task(task_id)
|
|
766
|
+
|
|
767
|
+
def append_event(
|
|
768
|
+
self,
|
|
769
|
+
task_id: str,
|
|
770
|
+
stage: str,
|
|
771
|
+
progress: int,
|
|
772
|
+
message: str,
|
|
773
|
+
payload: dict[str, object] | None = None,
|
|
774
|
+
) -> TaskEventRecord:
|
|
775
|
+
event = TaskEventRecord(task_id=task_id, stage=stage, progress=progress, message=message, payload=payload or {})
|
|
776
|
+
updated_at = datetime.now(timezone.utc).isoformat()
|
|
777
|
+
with self._lock, sqlite_cursor(self._connection) as cursor:
|
|
778
|
+
latest_row = cursor.execute(
|
|
779
|
+
"""
|
|
780
|
+
SELECT created_at
|
|
781
|
+
FROM task_events
|
|
782
|
+
WHERE task_id = ?
|
|
783
|
+
ORDER BY created_at DESC
|
|
784
|
+
LIMIT 1
|
|
785
|
+
""",
|
|
786
|
+
(task_id,),
|
|
787
|
+
).fetchone()
|
|
788
|
+
if latest_row is not None:
|
|
789
|
+
latest_created_at = datetime.fromisoformat(latest_row["created_at"])
|
|
790
|
+
if event.created_at <= latest_created_at:
|
|
791
|
+
event.created_at = latest_created_at + timedelta(microseconds=1)
|
|
792
|
+
cursor.execute(
|
|
793
|
+
"""
|
|
794
|
+
INSERT INTO task_events (event_id, task_id, stage, progress, message, payload_json, created_at)
|
|
795
|
+
VALUES (?, ?, ?, ?, ?, ?, ?)
|
|
796
|
+
""",
|
|
797
|
+
(
|
|
798
|
+
event.event_id,
|
|
799
|
+
event.task_id,
|
|
800
|
+
event.stage,
|
|
801
|
+
event.progress,
|
|
802
|
+
event.message,
|
|
803
|
+
json.dumps(event.payload, ensure_ascii=False),
|
|
804
|
+
event.created_at.isoformat(),
|
|
805
|
+
),
|
|
806
|
+
)
|
|
807
|
+
row = cursor.execute("SELECT video_id FROM tasks WHERE task_id = ?", (task_id,)).fetchone()
|
|
808
|
+
if row is not None and row["video_id"]:
|
|
809
|
+
cursor.execute(
|
|
810
|
+
"""
|
|
811
|
+
UPDATE video_assets
|
|
812
|
+
SET latest_stage = ?, updated_at = ?
|
|
813
|
+
WHERE video_id = ?
|
|
814
|
+
""",
|
|
815
|
+
(stage, updated_at, row["video_id"]),
|
|
816
|
+
)
|
|
817
|
+
return event
|
|
818
|
+
|
|
819
|
+
def list_events(self, task_id: str) -> list[TaskEventRecord]:
|
|
820
|
+
with self._lock, sqlite_cursor(self._connection) as cursor:
|
|
821
|
+
rows = cursor.execute(
|
|
822
|
+
"""
|
|
823
|
+
SELECT event_id, task_id, stage, progress, message, payload_json, created_at
|
|
824
|
+
FROM task_events
|
|
825
|
+
WHERE task_id = ?
|
|
826
|
+
ORDER BY created_at ASC
|
|
827
|
+
""",
|
|
828
|
+
(task_id,),
|
|
829
|
+
).fetchall()
|
|
830
|
+
return [self._row_to_event(row) for row in rows]
|
|
831
|
+
|
|
832
|
+
def list_events_after(self, task_id: str, after_created_at: str | None) -> list[TaskEventRecord]:
|
|
833
|
+
with self._lock, sqlite_cursor(self._connection) as cursor:
|
|
834
|
+
if after_created_at:
|
|
835
|
+
rows = cursor.execute(
|
|
836
|
+
"""
|
|
837
|
+
SELECT event_id, task_id, stage, progress, message, payload_json, created_at
|
|
838
|
+
FROM task_events
|
|
839
|
+
WHERE task_id = ? AND created_at > ?
|
|
840
|
+
ORDER BY created_at ASC
|
|
841
|
+
""",
|
|
842
|
+
(task_id, after_created_at),
|
|
843
|
+
).fetchall()
|
|
844
|
+
else:
|
|
845
|
+
rows = cursor.execute(
|
|
846
|
+
"""
|
|
847
|
+
SELECT event_id, task_id, stage, progress, message, payload_json, created_at
|
|
848
|
+
FROM task_events
|
|
849
|
+
WHERE task_id = ?
|
|
850
|
+
ORDER BY created_at ASC
|
|
851
|
+
""",
|
|
852
|
+
(task_id,),
|
|
853
|
+
).fetchall()
|
|
854
|
+
return [self._row_to_event(row) for row in rows]
|
|
855
|
+
|
|
856
|
+
def get_latest_event(self, task_id: str) -> TaskEventRecord | None:
|
|
857
|
+
with self._lock, sqlite_cursor(self._connection) as cursor:
|
|
858
|
+
row = cursor.execute(
|
|
859
|
+
"""
|
|
860
|
+
SELECT event_id, task_id, stage, progress, message, payload_json, created_at
|
|
861
|
+
FROM task_events WHERE task_id = ?
|
|
862
|
+
ORDER BY created_at DESC LIMIT 1
|
|
863
|
+
""",
|
|
864
|
+
(task_id,),
|
|
865
|
+
).fetchone()
|
|
866
|
+
return self._row_to_event(row) if row is not None else None
|
|
867
|
+
|
|
868
|
+
def _serialize_record(self, record: TaskRecord) -> dict[str, str | None]:
|
|
869
|
+
return {
|
|
870
|
+
"task_id": record.task_id,
|
|
871
|
+
"video_id": record.video_id,
|
|
872
|
+
"status": record.status.value,
|
|
873
|
+
"task_input_json": json.dumps(record.task_input.model_dump(mode="json"), ensure_ascii=False),
|
|
874
|
+
"page_number": str(record.page_number) if record.page_number is not None else None,
|
|
875
|
+
"page_title": record.page_title,
|
|
876
|
+
"result_json": (
|
|
877
|
+
json.dumps(record.result.model_dump(mode="json"), ensure_ascii=False)
|
|
878
|
+
if record.result is not None
|
|
879
|
+
else None
|
|
880
|
+
),
|
|
881
|
+
"error_code": record.error_code,
|
|
882
|
+
"error_message": record.error_message,
|
|
883
|
+
"created_at": record.created_at.isoformat(),
|
|
884
|
+
"updated_at": record.updated_at.isoformat(),
|
|
885
|
+
}
|
|
886
|
+
|
|
887
|
+
def _row_to_record(self, row: sqlite3.Row) -> TaskRecord:
|
|
888
|
+
task_input = TaskInput.model_validate(json.loads(row["task_input_json"]))
|
|
889
|
+
result = TaskResult.model_validate(json.loads(row["result_json"])) if row["result_json"] else None
|
|
890
|
+
return TaskRecord(
|
|
891
|
+
task_id=row["task_id"],
|
|
892
|
+
video_id=row["video_id"],
|
|
893
|
+
status=TaskStatus(row["status"]),
|
|
894
|
+
task_input=task_input,
|
|
895
|
+
page_number=row["page_number"],
|
|
896
|
+
page_title=row["page_title"],
|
|
897
|
+
created_at=datetime.fromisoformat(row["created_at"]),
|
|
898
|
+
updated_at=datetime.fromisoformat(row["updated_at"]),
|
|
899
|
+
result=result,
|
|
900
|
+
error_code=row["error_code"],
|
|
901
|
+
error_message=row["error_message"],
|
|
902
|
+
)
|
|
903
|
+
|
|
904
|
+
def _row_to_video_asset(self, row: sqlite3.Row) -> VideoAssetRecord:
|
|
905
|
+
latest_result = (
|
|
906
|
+
TaskResult.model_validate(json.loads(row["latest_result_json"]))
|
|
907
|
+
if row["latest_result_json"]
|
|
908
|
+
else None
|
|
909
|
+
)
|
|
910
|
+
return VideoAssetRecord(
|
|
911
|
+
video_id=row["video_id"],
|
|
912
|
+
canonical_id=row["canonical_id"],
|
|
913
|
+
platform=row["platform"],
|
|
914
|
+
title=row["title"],
|
|
915
|
+
source_url=row["source_url"],
|
|
916
|
+
cover_url=row["cover_url"] or "",
|
|
917
|
+
duration=row["duration"],
|
|
918
|
+
pages=[
|
|
919
|
+
VideoPageOptionResponse.model_validate(item)
|
|
920
|
+
for item in json.loads(row["page_catalog_json"] or "[]")
|
|
921
|
+
],
|
|
922
|
+
latest_task_id=row["latest_task_id"],
|
|
923
|
+
latest_status=TaskStatus(row["latest_status"]) if row["latest_status"] else None,
|
|
924
|
+
latest_stage=row["latest_stage"],
|
|
925
|
+
latest_result=latest_result,
|
|
926
|
+
latest_error_message=row["latest_error_message"],
|
|
927
|
+
is_favorite=bool(row["is_favorite"]),
|
|
928
|
+
favorite_updated_at=datetime.fromisoformat(row["favorite_updated_at"]) if row["favorite_updated_at"] else None,
|
|
929
|
+
created_at=datetime.fromisoformat(row["created_at"]),
|
|
930
|
+
updated_at=datetime.fromisoformat(row["updated_at"]),
|
|
931
|
+
)
|
|
932
|
+
|
|
933
|
+
def _row_to_event(self, row: sqlite3.Row) -> TaskEventRecord:
|
|
934
|
+
return TaskEventRecord(
|
|
935
|
+
event_id=row["event_id"],
|
|
936
|
+
task_id=row["task_id"],
|
|
937
|
+
stage=row["stage"],
|
|
938
|
+
progress=row["progress"],
|
|
939
|
+
message=row["message"],
|
|
940
|
+
created_at=datetime.fromisoformat(row["created_at"]),
|
|
941
|
+
payload=json.loads(row["payload_json"]),
|
|
942
|
+
)
|