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.
Files changed (125) hide show
  1. package/README.md +4 -4
  2. package/bin/bilisum.js +157 -76
  3. package/package.json +7 -2
  4. package/runtime/VERSION +1 -0
  5. package/runtime/apps/service/pyproject.toml +29 -0
  6. package/runtime/apps/service/src/video_sum_service/__init__.py +1 -0
  7. package/runtime/apps/service/src/video_sum_service/__main__.py +5 -0
  8. package/runtime/apps/service/src/video_sum_service/app.py +480 -0
  9. package/runtime/apps/service/src/video_sum_service/context.py +24 -0
  10. package/runtime/apps/service/src/video_sum_service/integrations.py +264 -0
  11. package/runtime/apps/service/src/video_sum_service/knowledge/__init__.py +5 -0
  12. package/runtime/apps/service/src/video_sum_service/knowledge/index_service.py +408 -0
  13. package/runtime/apps/service/src/video_sum_service/knowledge/local_llm.py +319 -0
  14. package/runtime/apps/service/src/video_sum_service/knowledge/rag_service.py +492 -0
  15. package/runtime/apps/service/src/video_sum_service/knowledge/tag_service.py +242 -0
  16. package/runtime/apps/service/src/video_sum_service/main.py +31 -0
  17. package/runtime/apps/service/src/video_sum_service/repository.py +942 -0
  18. package/runtime/apps/service/src/video_sum_service/routers/__init__.py +1 -0
  19. package/runtime/apps/service/src/video_sum_service/routers/knowledge.py +272 -0
  20. package/runtime/apps/service/src/video_sum_service/routers/system.py +280 -0
  21. package/runtime/apps/service/src/video_sum_service/routers/tasks.py +287 -0
  22. package/runtime/apps/service/src/video_sum_service/routers/videos.py +766 -0
  23. package/runtime/apps/service/src/video_sum_service/runtime_support.py +1007 -0
  24. package/runtime/apps/service/src/video_sum_service/schemas.py +418 -0
  25. package/runtime/apps/service/src/video_sum_service/settings_manager.py +130 -0
  26. package/runtime/apps/service/src/video_sum_service/task_artifacts.py +102 -0
  27. package/runtime/apps/service/src/video_sum_service/task_exports.py +108 -0
  28. package/runtime/apps/service/src/video_sum_service/transcribe_worker.py +9 -0
  29. package/runtime/apps/service/src/video_sum_service/video_assets.py +623 -0
  30. package/runtime/apps/service/src/video_sum_service/worker.py +381 -0
  31. package/runtime/apps/web/static/apple-touch-icon.png +0 -0
  32. package/runtime/apps/web/static/assets/KaTeX_AMS-Regular-BQhdFMY1.woff2 +0 -0
  33. package/runtime/apps/web/static/assets/KaTeX_AMS-Regular-DMm9YOAa.woff +0 -0
  34. package/runtime/apps/web/static/assets/KaTeX_AMS-Regular-DRggAlZN.ttf +0 -0
  35. package/runtime/apps/web/static/assets/KaTeX_Caligraphic-Bold-ATXxdsX0.ttf +0 -0
  36. package/runtime/apps/web/static/assets/KaTeX_Caligraphic-Bold-BEiXGLvX.woff +0 -0
  37. package/runtime/apps/web/static/assets/KaTeX_Caligraphic-Bold-Dq_IR9rO.woff2 +0 -0
  38. package/runtime/apps/web/static/assets/KaTeX_Caligraphic-Regular-CTRA-rTL.woff +0 -0
  39. package/runtime/apps/web/static/assets/KaTeX_Caligraphic-Regular-Di6jR-x-.woff2 +0 -0
  40. package/runtime/apps/web/static/assets/KaTeX_Caligraphic-Regular-wX97UBjC.ttf +0 -0
  41. package/runtime/apps/web/static/assets/KaTeX_Fraktur-Bold-BdnERNNW.ttf +0 -0
  42. package/runtime/apps/web/static/assets/KaTeX_Fraktur-Bold-BsDP51OF.woff +0 -0
  43. package/runtime/apps/web/static/assets/KaTeX_Fraktur-Bold-CL6g_b3V.woff2 +0 -0
  44. package/runtime/apps/web/static/assets/KaTeX_Fraktur-Regular-CB_wures.ttf +0 -0
  45. package/runtime/apps/web/static/assets/KaTeX_Fraktur-Regular-CTYiF6lA.woff2 +0 -0
  46. package/runtime/apps/web/static/assets/KaTeX_Fraktur-Regular-Dxdc4cR9.woff +0 -0
  47. package/runtime/apps/web/static/assets/KaTeX_Main-Bold-Cx986IdX.woff2 +0 -0
  48. package/runtime/apps/web/static/assets/KaTeX_Main-Bold-Jm3AIy58.woff +0 -0
  49. package/runtime/apps/web/static/assets/KaTeX_Main-Bold-waoOVXN0.ttf +0 -0
  50. package/runtime/apps/web/static/assets/KaTeX_Main-BoldItalic-DxDJ3AOS.woff2 +0 -0
  51. package/runtime/apps/web/static/assets/KaTeX_Main-BoldItalic-DzxPMmG6.ttf +0 -0
  52. package/runtime/apps/web/static/assets/KaTeX_Main-BoldItalic-SpSLRI95.woff +0 -0
  53. package/runtime/apps/web/static/assets/KaTeX_Main-Italic-3WenGoN9.ttf +0 -0
  54. package/runtime/apps/web/static/assets/KaTeX_Main-Italic-BMLOBm91.woff +0 -0
  55. package/runtime/apps/web/static/assets/KaTeX_Main-Italic-NWA7e6Wa.woff2 +0 -0
  56. package/runtime/apps/web/static/assets/KaTeX_Main-Regular-B22Nviop.woff2 +0 -0
  57. package/runtime/apps/web/static/assets/KaTeX_Main-Regular-Dr94JaBh.woff +0 -0
  58. package/runtime/apps/web/static/assets/KaTeX_Main-Regular-ypZvNtVU.ttf +0 -0
  59. package/runtime/apps/web/static/assets/KaTeX_Math-BoldItalic-B3XSjfu4.ttf +0 -0
  60. package/runtime/apps/web/static/assets/KaTeX_Math-BoldItalic-CZnvNsCZ.woff2 +0 -0
  61. package/runtime/apps/web/static/assets/KaTeX_Math-BoldItalic-iY-2wyZ7.woff +0 -0
  62. package/runtime/apps/web/static/assets/KaTeX_Math-Italic-DA0__PXp.woff +0 -0
  63. package/runtime/apps/web/static/assets/KaTeX_Math-Italic-flOr_0UB.ttf +0 -0
  64. package/runtime/apps/web/static/assets/KaTeX_Math-Italic-t53AETM-.woff2 +0 -0
  65. package/runtime/apps/web/static/assets/KaTeX_SansSerif-Bold-CFMepnvq.ttf +0 -0
  66. package/runtime/apps/web/static/assets/KaTeX_SansSerif-Bold-D1sUS0GD.woff2 +0 -0
  67. package/runtime/apps/web/static/assets/KaTeX_SansSerif-Bold-DbIhKOiC.woff +0 -0
  68. package/runtime/apps/web/static/assets/KaTeX_SansSerif-Italic-C3H0VqGB.woff2 +0 -0
  69. package/runtime/apps/web/static/assets/KaTeX_SansSerif-Italic-DN2j7dab.woff +0 -0
  70. package/runtime/apps/web/static/assets/KaTeX_SansSerif-Italic-YYjJ1zSn.ttf +0 -0
  71. package/runtime/apps/web/static/assets/KaTeX_SansSerif-Regular-BNo7hRIc.ttf +0 -0
  72. package/runtime/apps/web/static/assets/KaTeX_SansSerif-Regular-CS6fqUqJ.woff +0 -0
  73. package/runtime/apps/web/static/assets/KaTeX_SansSerif-Regular-DDBCnlJ7.woff2 +0 -0
  74. package/runtime/apps/web/static/assets/KaTeX_Script-Regular-C5JkGWo-.ttf +0 -0
  75. package/runtime/apps/web/static/assets/KaTeX_Script-Regular-D3wIWfF6.woff2 +0 -0
  76. package/runtime/apps/web/static/assets/KaTeX_Script-Regular-D5yQViql.woff +0 -0
  77. package/runtime/apps/web/static/assets/KaTeX_Size1-Regular-C195tn64.woff +0 -0
  78. package/runtime/apps/web/static/assets/KaTeX_Size1-Regular-Dbsnue_I.ttf +0 -0
  79. package/runtime/apps/web/static/assets/KaTeX_Size1-Regular-mCD8mA8B.woff2 +0 -0
  80. package/runtime/apps/web/static/assets/KaTeX_Size2-Regular-B7gKUWhC.ttf +0 -0
  81. package/runtime/apps/web/static/assets/KaTeX_Size2-Regular-Dy4dx90m.woff2 +0 -0
  82. package/runtime/apps/web/static/assets/KaTeX_Size2-Regular-oD1tc_U0.woff +0 -0
  83. package/runtime/apps/web/static/assets/KaTeX_Size3-Regular-CTq5MqoE.woff +0 -0
  84. package/runtime/apps/web/static/assets/KaTeX_Size3-Regular-DgpXs0kz.ttf +0 -0
  85. package/runtime/apps/web/static/assets/KaTeX_Size4-Regular-BF-4gkZK.woff +0 -0
  86. package/runtime/apps/web/static/assets/KaTeX_Size4-Regular-DWFBv043.ttf +0 -0
  87. package/runtime/apps/web/static/assets/KaTeX_Size4-Regular-Dl5lxZxV.woff2 +0 -0
  88. package/runtime/apps/web/static/assets/KaTeX_Typewriter-Regular-C0xS9mPB.woff +0 -0
  89. package/runtime/apps/web/static/assets/KaTeX_Typewriter-Regular-CO6r4hn1.woff2 +0 -0
  90. package/runtime/apps/web/static/assets/KaTeX_Typewriter-Regular-D3Ib7_Hf.ttf +0 -0
  91. package/runtime/apps/web/static/assets/icons/icon-180.png +0 -0
  92. package/runtime/apps/web/static/assets/icons/icon-512.png +0 -0
  93. package/runtime/apps/web/static/assets/icons/icon.svg +17 -0
  94. package/runtime/apps/web/static/assets/index-Cj5PVjQg.js +388 -0
  95. package/runtime/apps/web/static/assets/index-DU2t4_7s.css +1 -0
  96. package/runtime/apps/web/static/favicon-32x32.png +0 -0
  97. package/runtime/apps/web/static/favicon.ico +0 -0
  98. package/runtime/apps/web/static/favicon.svg +17 -0
  99. package/runtime/apps/web/static/index.html +19 -0
  100. package/runtime/apps/web/static/js/api.js +123 -0
  101. package/runtime/apps/web/static/js/main.js +932 -0
  102. package/runtime/apps/web/static/js/state.js +28 -0
  103. package/runtime/apps/web/static/js/utils.js +81 -0
  104. package/runtime/apps/web/static/js/views/home.js +604 -0
  105. package/runtime/apps/web/static/js/views/settings.js +477 -0
  106. package/runtime/apps/web/static/styles.css +2206 -0
  107. package/runtime/packages/core/pyproject.toml +18 -0
  108. package/runtime/packages/core/src/video_sum_core/__init__.py +1 -0
  109. package/runtime/packages/core/src/video_sum_core/errors.py +22 -0
  110. package/runtime/packages/core/src/video_sum_core/markdown_exports.py +154 -0
  111. package/runtime/packages/core/src/video_sum_core/models/__init__.py +1 -0
  112. package/runtime/packages/core/src/video_sum_core/models/tasks.py +70 -0
  113. package/runtime/packages/core/src/video_sum_core/pipeline/__init__.py +1 -0
  114. package/runtime/packages/core/src/video_sum_core/pipeline/base.py +46 -0
  115. package/runtime/packages/core/src/video_sum_core/pipeline/real.py +3467 -0
  116. package/runtime/packages/core/src/video_sum_core/transcribe_subprocess.py +173 -0
  117. package/runtime/packages/core/src/video_sum_core/utils.py +127 -0
  118. package/runtime/packages/infra/pyproject.toml +16 -0
  119. package/runtime/packages/infra/src/video_sum_infra/__init__.py +1 -0
  120. package/runtime/packages/infra/src/video_sum_infra/app.py +39 -0
  121. package/runtime/packages/infra/src/video_sum_infra/config.py +393 -0
  122. package/runtime/packages/infra/src/video_sum_infra/db.py +34 -0
  123. package/runtime/packages/infra/src/video_sum_infra/logging.py +54 -0
  124. package/runtime/packages/infra/src/video_sum_infra/paths.py +6 -0
  125. package/runtime/packages/infra/src/video_sum_infra/runtime.py +485 -0
@@ -0,0 +1,418 @@
1
+ from datetime import datetime, timezone
2
+ from typing import Literal
3
+ from uuid import uuid4
4
+
5
+ from pydantic import BaseModel, Field
6
+
7
+ from video_sum_core.models.tasks import MindMapNode, TaskInput, TaskMindMap, TaskResult, TaskStatus
8
+ from video_sum_core.utils import extract_bilibili_page
9
+
10
+
11
+ class TaskCreateRequest(TaskInput):
12
+ video_id: str | None = None
13
+
14
+
15
+ class ResummaryRequest(BaseModel):
16
+ task_id: str | None = None
17
+ page_number: int | None = None
18
+
19
+
20
+ class VideoTaskCreateRequest(BaseModel):
21
+ page_number: int | None = None
22
+
23
+
24
+ class VideoTaskBatchRequest(BaseModel):
25
+ page_numbers: list[int] = Field(default_factory=list)
26
+ confirm: bool = False
27
+
28
+
29
+ class AggregateSummaryRequest(BaseModel):
30
+ page_numbers: list[int] | None = None
31
+
32
+
33
+ class TaskMarkdownExportRequest(BaseModel):
34
+ target: Literal["markdown", "obsidian"] = "obsidian"
35
+
36
+
37
+ class VideoTaskBatchPageResponse(BaseModel):
38
+ page_number: int
39
+ page_title: str | None = None
40
+ action: str
41
+ reason: str | None = None
42
+ existing_task_id: str | None = None
43
+ existing_status: TaskStatus | None = None
44
+ has_existing_result: bool = False
45
+ task: "TaskDetailResponse | None" = None
46
+
47
+
48
+ class VideoTaskBatchResponse(BaseModel):
49
+ operation: str
50
+ requested_page_numbers: list[int] = Field(default_factory=list)
51
+ requires_confirmation: bool = False
52
+ created_tasks: list["TaskDetailResponse"] = Field(default_factory=list)
53
+ skipped_pages: list[VideoTaskBatchPageResponse] = Field(default_factory=list)
54
+ conflict_pages: list[VideoTaskBatchPageResponse] = Field(default_factory=list)
55
+
56
+
57
+ class VideoProbeRequest(BaseModel):
58
+ url: str
59
+ force_refresh: bool = False
60
+
61
+
62
+ class VideoPageOptionResponse(BaseModel):
63
+ page: int
64
+ title: str
65
+ source_url: str
66
+ cover_url: str = ""
67
+ duration: float | None = None
68
+
69
+
70
+ class VideoAssetSummaryResponse(BaseModel):
71
+ video_id: str
72
+ canonical_id: str
73
+ platform: str
74
+ title: str
75
+ source_url: str
76
+ cover_url: str = ""
77
+ duration: float | None = None
78
+ latest_task_id: str | None = None
79
+ latest_status: TaskStatus | None = None
80
+ latest_stage: str | None = None
81
+ has_result: bool = False
82
+ is_favorite: bool = False
83
+ favorite_updated_at: datetime | None = None
84
+ pages: list[VideoPageOptionResponse] = Field(default_factory=list)
85
+ created_at: datetime
86
+ updated_at: datetime
87
+
88
+
89
+ class VideoAssetDetailResponse(VideoAssetSummaryResponse):
90
+ latest_result: TaskResult | None = None
91
+ latest_error_message: str | None = None
92
+
93
+
94
+ class VideoAssetRecord(BaseModel):
95
+ video_id: str = Field(default_factory=lambda: uuid4().hex)
96
+ canonical_id: str
97
+ platform: str = "bilibili"
98
+ title: str
99
+ source_url: str
100
+ cover_url: str = ""
101
+ duration: float | None = None
102
+ latest_task_id: str | None = None
103
+ latest_status: TaskStatus | None = None
104
+ latest_stage: str | None = None
105
+ latest_result: TaskResult | None = None
106
+ latest_error_message: str | None = None
107
+ is_favorite: bool = False
108
+ favorite_updated_at: datetime | None = None
109
+ pages: list[VideoPageOptionResponse] = Field(default_factory=list)
110
+ created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
111
+ updated_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
112
+
113
+ def to_summary(self) -> VideoAssetSummaryResponse:
114
+ return VideoAssetSummaryResponse(
115
+ video_id=self.video_id,
116
+ canonical_id=self.canonical_id,
117
+ platform=self.platform,
118
+ title=self.title,
119
+ source_url=self.source_url,
120
+ cover_url=self.cover_url,
121
+ duration=self.duration,
122
+ latest_task_id=self.latest_task_id,
123
+ latest_status=self.latest_status,
124
+ latest_stage=self.latest_stage,
125
+ has_result=self.latest_result is not None,
126
+ is_favorite=self.is_favorite,
127
+ favorite_updated_at=self.favorite_updated_at,
128
+ pages=self.pages,
129
+ created_at=self.created_at,
130
+ updated_at=self.updated_at,
131
+ )
132
+
133
+ def to_detail(self) -> VideoAssetDetailResponse:
134
+ return VideoAssetDetailResponse(
135
+ **self.to_summary().model_dump(),
136
+ latest_result=self.latest_result,
137
+ latest_error_message=self.latest_error_message,
138
+ )
139
+
140
+
141
+ class VideoProbeResponse(BaseModel):
142
+ video: VideoAssetSummaryResponse
143
+ cached: bool = False
144
+ requires_selection: bool = False
145
+ pages: list[VideoPageOptionResponse] = Field(default_factory=list)
146
+
147
+
148
+ class TaskSummaryResponse(BaseModel):
149
+ task_id: str
150
+ video_id: str | None = None
151
+ status: TaskStatus
152
+ input_type: str
153
+ source: str
154
+ title: str | None = None
155
+ page_number: int | None = None
156
+ page_title: str | None = None
157
+ created_at: datetime
158
+ updated_at: datetime
159
+ llm_total_tokens: int | None = None
160
+ task_duration_seconds: float | None = None
161
+
162
+
163
+ class TaskDetailResponse(TaskSummaryResponse):
164
+ result: TaskResult | None = None
165
+ error_code: str | None = None
166
+ error_message: str | None = None
167
+
168
+
169
+ class TaskMindMapResponse(BaseModel):
170
+ task_id: str
171
+ status: str = "idle"
172
+ error_message: str | None = None
173
+ updated_at: datetime | None = None
174
+ mindmap: TaskMindMap | None = None
175
+
176
+
177
+ class TaskMarkdownExportResponse(BaseModel):
178
+ task_id: str
179
+ target_format: Literal["markdown", "obsidian"]
180
+ path: str
181
+ directory: str
182
+ file_name: str
183
+ overwritten: bool = False
184
+ artifact_key: str
185
+
186
+
187
+ class TaskEventResponse(BaseModel):
188
+ event_id: str
189
+ task_id: str
190
+ stage: str
191
+ progress: int
192
+ message: str
193
+ created_at: datetime
194
+ payload: dict[str, object] = Field(default_factory=dict)
195
+
196
+
197
+ class TaskProgressResponse(BaseModel):
198
+ task_id: str
199
+ status: TaskStatus
200
+ progress: int = 0
201
+ latest_stage: str | None = None
202
+ latest_message: str | None = None
203
+ updated_at: datetime
204
+
205
+
206
+ class TaskRecord(BaseModel):
207
+ task_id: str = Field(default_factory=lambda: uuid4().hex)
208
+ video_id: str | None = None
209
+ status: TaskStatus = TaskStatus.QUEUED
210
+ task_input: TaskInput
211
+ page_number: int | None = None
212
+ page_title: str | None = None
213
+ created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
214
+ updated_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
215
+ result: TaskResult | None = None
216
+ error_code: str | None = None
217
+ error_message: str | None = None
218
+
219
+ @property
220
+ def task_duration_seconds(self) -> float | None:
221
+ if not self.created_at or not self.updated_at:
222
+ return None
223
+ return max(0.0, (self.updated_at - self.created_at).total_seconds())
224
+
225
+ def to_summary(self) -> TaskSummaryResponse:
226
+ page_number = self.page_number if self.page_number is not None else extract_bilibili_page(self.task_input.source)
227
+ return TaskSummaryResponse(
228
+ task_id=self.task_id,
229
+ video_id=self.video_id,
230
+ status=self.status,
231
+ input_type=self.task_input.input_type.value,
232
+ source=self.task_input.source,
233
+ title=self.task_input.title,
234
+ page_number=page_number,
235
+ page_title=self.page_title or self.task_input.title,
236
+ created_at=self.created_at,
237
+ updated_at=self.updated_at,
238
+ llm_total_tokens=self.result.llm_total_tokens if self.result else None,
239
+ task_duration_seconds=self.task_duration_seconds,
240
+ )
241
+
242
+ def to_detail(self) -> TaskDetailResponse:
243
+ return TaskDetailResponse(
244
+ **self.to_summary().model_dump(),
245
+ result=self.result,
246
+ error_code=self.error_code,
247
+ error_message=self.error_message,
248
+ )
249
+
250
+
251
+ class TaskEventRecord(BaseModel):
252
+ event_id: str = Field(default_factory=lambda: uuid4().hex)
253
+ task_id: str
254
+ stage: str
255
+ progress: int = 0
256
+ message: str
257
+ created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
258
+ payload: dict[str, object] = Field(default_factory=dict)
259
+
260
+ def to_response(self) -> TaskEventResponse:
261
+ return TaskEventResponse(
262
+ event_id=self.event_id,
263
+ task_id=self.task_id,
264
+ stage=self.stage,
265
+ progress=self.progress,
266
+ message=self.message,
267
+ created_at=self.created_at,
268
+ payload=self.payload,
269
+ )
270
+
271
+
272
+ class VideoTagRecord(BaseModel):
273
+ video_id: str
274
+ tag: str
275
+ source: str = "manual"
276
+ confidence: float = 1.0
277
+ created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
278
+
279
+
280
+ class KnowledgeIndexChunkRecord(BaseModel):
281
+ chunk_id: str
282
+ video_id: str
283
+ embedding_json: str
284
+ indexed_content: str
285
+ index_type: str = "video_summary"
286
+ segment_order: int | None = None
287
+ anchor_label: str | None = None
288
+ anchor_seconds: float | None = None
289
+ created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
290
+ updated_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
291
+
292
+
293
+ class TagItem(BaseModel):
294
+ tag: str
295
+ count: int
296
+ videos: list[str] = Field(default_factory=list)
297
+
298
+
299
+ class TagListResponse(BaseModel):
300
+ items: list[TagItem] = Field(default_factory=list)
301
+
302
+
303
+ class VideoTagListResponse(BaseModel):
304
+ video_id: str
305
+ items: list[VideoTagRecord] = Field(default_factory=list)
306
+
307
+
308
+ class KnowledgeTagCreateRequest(BaseModel):
309
+ video_id: str
310
+ tag: str
311
+
312
+
313
+ class KnowledgeAutoTagRequest(BaseModel):
314
+ video_ids: list[str] | None = None
315
+
316
+
317
+ class KnowledgeAutoTagVideoResult(BaseModel):
318
+ video_id: str
319
+ tags: list[str] = Field(default_factory=list)
320
+
321
+
322
+ class KnowledgeAutoTagResponse(BaseModel):
323
+ items: list[KnowledgeAutoTagVideoResult] = Field(default_factory=list)
324
+
325
+
326
+ class KnowledgeNetworkNode(BaseModel):
327
+ id: str
328
+ label: str
329
+ type: str
330
+ count: int | None = None
331
+ tags: list[str] = Field(default_factory=list)
332
+ degree: int | None = None
333
+ focus: bool = False
334
+ hidden_count: int = 0
335
+ video_count: int = 0
336
+
337
+
338
+ class KnowledgeNetworkLink(BaseModel):
339
+ source: str
340
+ target: str
341
+ weight: float = 1.0
342
+ kind: str = "cooccurrence"
343
+
344
+
345
+ class KnowledgeNetworkResponse(BaseModel):
346
+ nodes: list[KnowledgeNetworkNode] = Field(default_factory=list)
347
+ links: list[KnowledgeNetworkLink] = Field(default_factory=list)
348
+ mode: str = "overview"
349
+ hidden_tag_count: int = 0
350
+ selected_tags: list[str] = Field(default_factory=list)
351
+
352
+
353
+ class KnowledgeSearchFilters(BaseModel):
354
+ tags: list[str] = Field(default_factory=list)
355
+
356
+
357
+ class KnowledgeSearchRequest(BaseModel):
358
+ query: str
359
+ limit: int = 10
360
+ filters: KnowledgeSearchFilters | None = None
361
+
362
+
363
+ class KnowledgeSearchResult(BaseModel):
364
+ video_id: str
365
+ title: str
366
+ relevance_score: float
367
+ snippet: str
368
+ tags: list[str] = Field(default_factory=list)
369
+ cover_url: str = ""
370
+ timestamp: str | None = None
371
+ video_title: str | None = None
372
+ page_title: str | None = None
373
+ page_number: int | None = None
374
+
375
+
376
+ class KnowledgeSearchResponse(BaseModel):
377
+ query: str
378
+ results: list[KnowledgeSearchResult] = Field(default_factory=list)
379
+ total: int = 0
380
+
381
+
382
+ class KnowledgeChatHistoryItem(BaseModel):
383
+ role: str
384
+ content: str
385
+
386
+
387
+ class KnowledgeAskRequest(BaseModel):
388
+ query: str
389
+ context_limit: int = 5
390
+ history: list[KnowledgeChatHistoryItem] = Field(default_factory=list)
391
+
392
+
393
+ class KnowledgeSourceRef(BaseModel):
394
+ video_id: str
395
+ title: str
396
+ relevance_score: float
397
+ timestamp: str | None = None
398
+ video_title: str | None = None
399
+ page_title: str | None = None
400
+ page_number: int | None = None
401
+
402
+
403
+ class KnowledgeAskResponse(BaseModel):
404
+ query: str
405
+ answer: str
406
+ sources: list[KnowledgeSourceRef] = Field(default_factory=list)
407
+
408
+
409
+ class KnowledgeStatsResponse(BaseModel):
410
+ video_count: int
411
+ indexed_chunk_count: int
412
+ tag_count: int
413
+ untagged_video_count: int
414
+ knowledge_llm_available: bool
415
+
416
+
417
+ class KnowledgeRebuildResponse(BaseModel):
418
+ indexed_videos: int
@@ -0,0 +1,130 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ from pathlib import Path
5
+
6
+ from pydantic import BaseModel
7
+
8
+ from video_sum_infra.config import (
9
+ DEFAULT_SUMMARY_SYSTEM_PROMPT,
10
+ DEFAULT_SUMMARY_USER_PROMPT_TEMPLATE,
11
+ LEGACY_SUMMARY_SYSTEM_PROMPT,
12
+ LEGACY_SUMMARY_USER_PROMPT_TEMPLATE,
13
+ PREVIOUS_DEFAULT_SUMMARY_SYSTEM_PROMPT,
14
+ PREVIOUS_DEFAULT_SUMMARY_USER_PROMPT_TEMPLATE,
15
+ ServiceSettings,
16
+ recommend_mindmap_concurrency,
17
+ recommend_task_concurrency,
18
+ )
19
+
20
+
21
+ class SettingsUpdatePayload(BaseModel):
22
+ host: str | None = None
23
+ port: int | None = None
24
+ data_dir: str | None = None
25
+ cache_dir: str | None = None
26
+ tasks_dir: str | None = None
27
+ database_url: str | None = None
28
+ whisper_model: str | None = None
29
+ whisper_device: str | None = None
30
+ whisper_compute_type: str | None = None
31
+ transcription_provider: str | None = None
32
+ device_preference: str | None = None
33
+ compute_type: str | None = None
34
+ model_mode: str | None = None
35
+ fixed_model: str | None = None
36
+ siliconflow_asr_base_url: str | None = None
37
+ siliconflow_asr_model: str | None = None
38
+ siliconflow_asr_api_key: str | None = None
39
+ cuda_variant: str | None = None
40
+ runtime_channel: str | None = None
41
+ output_dir: str | None = None
42
+ preserve_temp_audio: bool | None = None
43
+ enable_cache: bool | None = None
44
+ language: str | None = None
45
+ summary_mode: str | None = None
46
+ llm_enabled: bool | None = None
47
+ auto_generate_mindmap: bool | None = None
48
+ llm_provider: str | None = None
49
+ llm_base_url: str | None = None
50
+ llm_model: str | None = None
51
+ llm_api_key: str | None = None
52
+ knowledge_llm_mode: str | None = None
53
+ knowledge_llm_enabled: bool | None = None
54
+ knowledge_llm_base_url: str | None = None
55
+ knowledge_llm_model: str | None = None
56
+ knowledge_llm_api_key: str | None = None
57
+ knowledge_index_auto_rebuild: str | None = None
58
+ knowledge_enabled: bool | None = None
59
+ summary_system_prompt: str | None = None
60
+ summary_user_prompt_template: str | None = None
61
+ summary_chunk_target_chars: int | None = None
62
+ summary_chunk_overlap_segments: int | None = None
63
+ task_concurrency: int | None = None
64
+ mindmap_concurrency: int | None = None
65
+ summary_chunk_concurrency: int | None = None
66
+ summary_chunk_retry_count: int | None = None
67
+ ytdlp_cookies_file: str | None = None
68
+ ytdlp_cookies_browser: str | None = None
69
+
70
+
71
+ class SettingsManager:
72
+ def __init__(self, base_settings: ServiceSettings) -> None:
73
+ self._base_settings = base_settings
74
+ self._settings = base_settings
75
+ self._settings_path = Path(base_settings.data_dir) / "settings.json"
76
+
77
+ @property
78
+ def current(self) -> ServiceSettings:
79
+ return self._settings
80
+
81
+ @property
82
+ def has_persisted_settings(self) -> bool:
83
+ return self._settings_path.exists()
84
+
85
+ def load(self) -> ServiceSettings:
86
+ if self._settings_path.exists():
87
+ stored = json.loads(self._settings_path.read_text(encoding="utf-8"))
88
+ if stored.get("summary_system_prompt") == LEGACY_SUMMARY_SYSTEM_PROMPT:
89
+ stored["summary_system_prompt"] = DEFAULT_SUMMARY_SYSTEM_PROMPT
90
+ if stored.get("summary_system_prompt") == PREVIOUS_DEFAULT_SUMMARY_SYSTEM_PROMPT:
91
+ stored["summary_system_prompt"] = DEFAULT_SUMMARY_SYSTEM_PROMPT
92
+ if stored.get("summary_user_prompt_template") == LEGACY_SUMMARY_USER_PROMPT_TEMPLATE:
93
+ stored["summary_user_prompt_template"] = DEFAULT_SUMMARY_USER_PROMPT_TEMPLATE
94
+ if stored.get("summary_user_prompt_template") == PREVIOUS_DEFAULT_SUMMARY_USER_PROMPT_TEMPLATE:
95
+ stored["summary_user_prompt_template"] = DEFAULT_SUMMARY_USER_PROMPT_TEMPLATE
96
+ migrated = False
97
+ candidate = ServiceSettings.model_validate({**self._base_settings.model_dump(), **stored})
98
+ if "task_concurrency" not in stored:
99
+ stored["task_concurrency"] = recommend_task_concurrency(candidate)
100
+ migrated = True
101
+ if "mindmap_concurrency" not in stored:
102
+ stored["mindmap_concurrency"] = recommend_mindmap_concurrency()
103
+ migrated = True
104
+ self._settings = ServiceSettings.model_validate({**self._base_settings.model_dump(), **stored})
105
+ if migrated:
106
+ self._settings_path.parent.mkdir(parents=True, exist_ok=True)
107
+ self._settings_path.write_text(
108
+ json.dumps(self._settings.model_dump(mode="json"), ensure_ascii=False, indent=2),
109
+ encoding="utf-8",
110
+ )
111
+ else:
112
+ self._settings = self._base_settings.model_copy(
113
+ update={
114
+ "task_concurrency": recommend_task_concurrency(self._base_settings),
115
+ "mindmap_concurrency": recommend_mindmap_concurrency(),
116
+ }
117
+ )
118
+ return self._settings
119
+
120
+ def save(self, payload: SettingsUpdatePayload) -> ServiceSettings:
121
+ current_dump = self._settings.model_dump(mode="json")
122
+ updates = payload.model_dump(exclude_none=True)
123
+ next_settings = ServiceSettings.model_validate({**current_dump, **updates})
124
+ self._settings_path.parent.mkdir(parents=True, exist_ok=True)
125
+ self._settings_path.write_text(
126
+ json.dumps(next_settings.model_dump(mode="json"), ensure_ascii=False, indent=2),
127
+ encoding="utf-8",
128
+ )
129
+ self._settings = next_settings
130
+ return self._settings
@@ -0,0 +1,102 @@
1
+ import json
2
+ import logging
3
+ import shutil
4
+ from pathlib import Path
5
+
6
+ from fastapi import HTTPException
7
+
8
+ from video_sum_core.models.tasks import TaskMindMap
9
+ from video_sum_infra.config import ServiceSettings
10
+
11
+ from video_sum_service.schemas import TaskRecord, VideoAssetRecord
12
+
13
+ logger = logging.getLogger("video_sum_service.app")
14
+
15
+
16
+ def task_artifact_directories(tasks: list[TaskRecord], tasks_dir: Path) -> list[Path]:
17
+ directories: set[Path] = set()
18
+ for task in tasks:
19
+ directories.add(tasks_dir / task.task_id)
20
+ if task.result is None:
21
+ continue
22
+ for artifact_path in task.result.artifacts.values():
23
+ try:
24
+ path = Path(str(artifact_path))
25
+ except (TypeError, ValueError):
26
+ continue
27
+ directories.add(path.parent)
28
+ return sorted(directories, key=lambda item: len(item.parts), reverse=True)
29
+
30
+
31
+ def video_cover_paths(video: VideoAssetRecord, cache_dir: Path) -> list[Path]:
32
+ cover_paths: set[Path] = set()
33
+ cover_url = str(video.cover_url or "")
34
+ if cover_url.startswith("/media/covers/"):
35
+ cover_paths.add(cache_dir / "covers" / Path(cover_url).name)
36
+ return sorted(cover_paths)
37
+
38
+
39
+ def video_source_paths(video: VideoAssetRecord, cache_dir: Path) -> list[Path]:
40
+ source_paths: set[Path] = set()
41
+ if str(video.platform or "").lower() != "local":
42
+ return []
43
+ try:
44
+ source_path = Path(str(video.source_url or "")).resolve()
45
+ uploads_dir = (cache_dir / "uploads").resolve()
46
+ if uploads_dir in source_path.parents:
47
+ source_paths.add(source_path)
48
+ except (OSError, RuntimeError, ValueError):
49
+ return []
50
+ return sorted(source_paths)
51
+
52
+
53
+ def cleanup_video_files(video: VideoAssetRecord, tasks: list[TaskRecord], current_settings: ServiceSettings) -> None:
54
+ for directory in task_artifact_directories(tasks, current_settings.tasks_dir):
55
+ try:
56
+ if directory.exists():
57
+ shutil.rmtree(directory, ignore_errors=False)
58
+ except OSError:
59
+ logger.warning("failed to remove task directory: %s", directory, exc_info=True)
60
+
61
+ for cover_path in video_cover_paths(video, current_settings.cache_dir):
62
+ try:
63
+ if cover_path.exists():
64
+ cover_path.unlink()
65
+ except OSError:
66
+ logger.warning("failed to remove cached cover: %s", cover_path, exc_info=True)
67
+
68
+ for source_path in video_source_paths(video, current_settings.cache_dir):
69
+ try:
70
+ if source_path.exists():
71
+ source_path.unlink()
72
+ except OSError:
73
+ logger.warning("failed to remove cached local media source: %s", source_path, exc_info=True)
74
+
75
+
76
+ def cleanup_task_files(task: TaskRecord, current_settings: ServiceSettings) -> None:
77
+ for directory in task_artifact_directories([task], current_settings.tasks_dir):
78
+ try:
79
+ if directory.exists():
80
+ shutil.rmtree(directory, ignore_errors=False)
81
+ except OSError:
82
+ logger.warning("failed to remove task directory: %s", directory, exc_info=True)
83
+
84
+
85
+ def load_task_segments(summary_path: str) -> list[dict[str, object]]:
86
+ try:
87
+ payload = json.loads(Path(summary_path).read_text(encoding="utf-8"))
88
+ except (OSError, json.JSONDecodeError) as exc:
89
+ raise HTTPException(status_code=500, detail="无法读取当前任务的分段结果。") from exc
90
+
91
+ segments = payload.get("segments")
92
+ if not isinstance(segments, list) or not segments:
93
+ raise HTTPException(status_code=400, detail="当前任务缺少可复用的分段数据。")
94
+ return segments
95
+
96
+
97
+ def load_task_mindmap(mindmap_path: str) -> TaskMindMap:
98
+ try:
99
+ payload = json.loads(Path(mindmap_path).read_text(encoding="utf-8"))
100
+ return TaskMindMap.model_validate(payload)
101
+ except (OSError, json.JSONDecodeError, ValueError) as exc:
102
+ raise HTTPException(status_code=500, detail="无法读取当前任务的思维导图结果。") from exc