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 @@
1
+ """FastAPI route groups for the service."""
@@ -0,0 +1,272 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ import json
5
+ from queue import Empty, Queue
6
+ from threading import BoundedSemaphore, Event, Thread
7
+
8
+ from fastapi import APIRouter, HTTPException, Query, Request
9
+ from fastapi.responses import StreamingResponse
10
+
11
+ from video_sum_service.context import settings_manager
12
+ from video_sum_service.runtime_support import detect_environment
13
+ from video_sum_service.knowledge import KnowledgeIndexService, RagService, TagService
14
+ from video_sum_service.knowledge.local_llm import knowledge_llm_available
15
+ from video_sum_service.repository import SqliteTaskRepository
16
+ from video_sum_service.schemas import (
17
+ KnowledgeAskRequest,
18
+ KnowledgeAskResponse,
19
+ KnowledgeAutoTagRequest,
20
+ KnowledgeAutoTagResponse,
21
+ KnowledgeNetworkResponse,
22
+ KnowledgeRebuildResponse,
23
+ KnowledgeSearchRequest,
24
+ KnowledgeSearchResponse,
25
+ KnowledgeStatsResponse,
26
+ KnowledgeTagCreateRequest,
27
+ TagListResponse,
28
+ VideoTagListResponse,
29
+ )
30
+
31
+ router = APIRouter(prefix="/api/v1/knowledge")
32
+ _QUEUE_TIMEOUT = object()
33
+ _ASK_STREAM_SEMAPHORE = BoundedSemaphore(2)
34
+
35
+
36
+ def _get_queue_item(event_queue: Queue[object], timeout: float) -> object:
37
+ try:
38
+ return event_queue.get(timeout=timeout)
39
+ except Empty:
40
+ return _QUEUE_TIMEOUT
41
+
42
+
43
+ def _knowledge_settings_signature(settings) -> tuple[object, ...]:
44
+ return (
45
+ bool(getattr(settings, "knowledge_enabled", False)),
46
+ str(getattr(settings, "runtime_channel", "base") or "base"),
47
+ str(getattr(settings, "knowledge_index_auto_rebuild", "disabled") or "disabled"),
48
+ bool(settings.llm_enabled),
49
+ str(settings.llm_base_url or ""),
50
+ str(settings.llm_model or ""),
51
+ str(settings.llm_api_key or ""),
52
+ str(getattr(settings, "knowledge_llm_mode", "same_as_main") or "same_as_main"),
53
+ bool(getattr(settings, "knowledge_llm_enabled", False)),
54
+ str(getattr(settings, "knowledge_llm_base_url", "") or ""),
55
+ str(getattr(settings, "knowledge_llm_model", "") or ""),
56
+ str(getattr(settings, "knowledge_llm_api_key", "") or ""),
57
+ )
58
+
59
+
60
+ def _get_services(request: Request) -> tuple[TagService, KnowledgeIndexService, RagService]:
61
+ task_store: SqliteTaskRepository = request.app.state.task_repository
62
+ settings = settings_manager.current
63
+ settings_signature = _knowledge_settings_signature(settings)
64
+ tag_service = getattr(request.app.state, "knowledge_tag_service", None)
65
+ index_service = getattr(request.app.state, "knowledge_index_service", None)
66
+ rag_service = getattr(request.app.state, "knowledge_rag_service", None)
67
+ cached_signature = getattr(request.app.state, "knowledge_settings_signature", None)
68
+
69
+ if tag_service is None or index_service is None or rag_service is None or cached_signature != settings_signature:
70
+ tag_service = TagService(task_store, settings)
71
+ index_service = KnowledgeIndexService(task_store, settings)
72
+ rag_service = RagService(task_store, index_service, tag_service, settings)
73
+ request.app.state.knowledge_tag_service = tag_service
74
+ request.app.state.knowledge_index_service = index_service
75
+ request.app.state.knowledge_rag_service = rag_service
76
+ request.app.state.knowledge_settings_signature = settings_signature
77
+
78
+ return tag_service, index_service, rag_service
79
+
80
+
81
+ def _knowledge_runtime_ready() -> bool:
82
+ environment = detect_environment(settings_manager.current.runtime_channel)
83
+ return bool(environment.get("knowledgeDependenciesReady"))
84
+
85
+
86
+ def _knowledge_enabled() -> bool:
87
+ return bool(getattr(settings_manager.current, "knowledge_enabled", False))
88
+
89
+
90
+ def _index_video_if_ready(index_service: KnowledgeIndexService, video_id: str) -> None:
91
+ if _knowledge_enabled() and _knowledge_runtime_ready():
92
+ index_service.index_video(video_id)
93
+
94
+
95
+ def _require_knowledge_enabled() -> None:
96
+ if not _knowledge_enabled():
97
+ raise HTTPException(
98
+ status_code=400,
99
+ detail="知识库当前未启用。请先在设置中的知识库板块开启知识库。",
100
+ )
101
+
102
+
103
+ def _require_knowledge_runtime() -> None:
104
+ _require_knowledge_enabled()
105
+ if not _knowledge_runtime_ready():
106
+ raise HTTPException(
107
+ status_code=424,
108
+ detail="知识库依赖未安装。请先到设置中的知识库或运行时板块安装知识库依赖。",
109
+ )
110
+
111
+
112
+ @router.get("/tags", response_model=TagListResponse | VideoTagListResponse)
113
+ def get_tags(request: Request, video_id: str | None = None) -> TagListResponse | VideoTagListResponse:
114
+ tag_service, _index_service, _rag_service = _get_services(request)
115
+ if video_id:
116
+ return VideoTagListResponse(video_id=video_id, items=tag_service.get_tags_for_video(video_id))
117
+ return TagListResponse(items=tag_service.get_all_tags())
118
+
119
+
120
+ @router.post("/tags", response_model=VideoTagListResponse)
121
+ def create_tag(body: KnowledgeTagCreateRequest, request: Request) -> VideoTagListResponse:
122
+ tag_service, index_service, _rag_service = _get_services(request)
123
+ created = tag_service.add_tag(body.video_id, body.tag)
124
+ if not created:
125
+ raise HTTPException(status_code=404, detail="Video not found.")
126
+ _index_video_if_ready(index_service, body.video_id)
127
+ return VideoTagListResponse(video_id=body.video_id, items=tag_service.get_tags_for_video(body.video_id))
128
+
129
+
130
+ @router.delete("/tags/{video_id}/{tag}", response_model=VideoTagListResponse)
131
+ def delete_tag(video_id: str, tag: str, request: Request) -> VideoTagListResponse:
132
+ tag_service, index_service, _rag_service = _get_services(request)
133
+ removed = tag_service.remove_tag(video_id, tag)
134
+ if not removed:
135
+ raise HTTPException(status_code=404, detail="Tag not found.")
136
+ _index_video_if_ready(index_service, video_id)
137
+ return VideoTagListResponse(video_id=video_id, items=tag_service.get_tags_for_video(video_id))
138
+
139
+
140
+ @router.post("/auto-tag", response_model=KnowledgeAutoTagResponse)
141
+ def auto_tag(body: KnowledgeAutoTagRequest, request: Request) -> KnowledgeAutoTagResponse:
142
+ tag_service, index_service, _rag_service = _get_services(request)
143
+ response = tag_service.batch_auto_tag(body.video_ids)
144
+ for item in response.items:
145
+ _index_video_if_ready(index_service, item.video_id)
146
+ return response
147
+
148
+
149
+ @router.get("/network", response_model=KnowledgeNetworkResponse)
150
+ def get_network(
151
+ request: Request,
152
+ selected_tag: list[str] | None = Query(default=None),
153
+ max_tags: int = 12,
154
+ max_videos: int = 8,
155
+ ) -> KnowledgeNetworkResponse:
156
+ tag_service, _index_service, _rag_service = _get_services(request)
157
+ selected_values = selected_tag if isinstance(selected_tag, list) else None
158
+ return tag_service.get_network_data(selected_values, max_tags=max_tags, max_videos=max_videos)
159
+
160
+
161
+ @router.post("/search", response_model=KnowledgeSearchResponse)
162
+ def search_knowledge(body: KnowledgeSearchRequest, request: Request) -> KnowledgeSearchResponse:
163
+ _require_knowledge_runtime()
164
+ _tag_service, index_service, _rag_service = _get_services(request)
165
+ filters = body.filters.tags if body.filters is not None else []
166
+ results = index_service.search(body.query, limit=body.limit, tag_filter=filters)
167
+ return KnowledgeSearchResponse(query=body.query, results=results, total=len(results))
168
+
169
+
170
+ @router.post("/ask", response_model=KnowledgeAskResponse)
171
+ def ask_knowledge(body: KnowledgeAskRequest, request: Request) -> KnowledgeAskResponse:
172
+ _require_knowledge_runtime()
173
+ _tag_service, _index_service, rag_service = _get_services(request)
174
+ return rag_service.ask(
175
+ body.query,
176
+ context_limit=body.context_limit,
177
+ history=getattr(body, "history", []),
178
+ )
179
+
180
+
181
+ @router.post("/ask/stream")
182
+ async def ask_knowledge_stream(body: KnowledgeAskRequest, request: Request) -> StreamingResponse:
183
+ _require_knowledge_runtime()
184
+ _tag_service, _index_service, rag_service = _get_services(request)
185
+
186
+ async def event_generator():
187
+ event_queue: Queue[object] = Queue()
188
+ cancel_event = Event()
189
+
190
+ def produce_events() -> None:
191
+ acquired = _ASK_STREAM_SEMAPHORE.acquire(blocking=False)
192
+ if not acquired:
193
+ event_queue.put(
194
+ HTTPException(
195
+ status_code=429,
196
+ detail="知识库问答任务较多,请等待当前回答结束后再试。",
197
+ )
198
+ )
199
+ event_queue.put(None)
200
+ return
201
+ try:
202
+ for event in rag_service.ask_stream(
203
+ body.query,
204
+ context_limit=body.context_limit,
205
+ history=getattr(body, "history", []),
206
+ should_cancel=cancel_event.is_set,
207
+ ):
208
+ if cancel_event.is_set():
209
+ return
210
+ event_queue.put(event)
211
+ except Exception as exc: # pragma: no cover - surfaced to the SSE client below
212
+ event_queue.put(exc)
213
+ finally:
214
+ _ASK_STREAM_SEMAPHORE.release()
215
+ event_queue.put(None)
216
+
217
+ worker = Thread(target=produce_events, daemon=True, name="knowledge-ask-stream")
218
+ worker.start()
219
+
220
+ try:
221
+ while True:
222
+ if await request.is_disconnected():
223
+ cancel_event.set()
224
+ return
225
+ item = await asyncio.to_thread(_get_queue_item, event_queue, 1.5)
226
+ if item is _QUEUE_TIMEOUT:
227
+ yield ": keep-alive\n\n"
228
+ continue
229
+ if item is None:
230
+ return
231
+ if isinstance(item, HTTPException):
232
+ raise item
233
+ if isinstance(item, Exception):
234
+ raise item
235
+ event_name, payload = item
236
+ yield f"event: {event_name}\ndata: {json.dumps(payload, ensure_ascii=False)}\n\n"
237
+ except HTTPException as exc:
238
+ yield (
239
+ "event: error\n"
240
+ f"data: {json.dumps({'message': str(exc.detail), 'status_code': exc.status_code}, ensure_ascii=False)}\n\n"
241
+ )
242
+ except Exception as exc: # pragma: no cover - defensive fallback
243
+ yield f"event: error\ndata: {json.dumps({'message': f'知识库问答失败:{exc}'}, ensure_ascii=False)}\n\n"
244
+ finally:
245
+ cancel_event.set()
246
+
247
+ return StreamingResponse(
248
+ event_generator(),
249
+ media_type="text/event-stream",
250
+ headers={"Cache-Control": "no-cache", "Connection": "keep-alive", "X-Accel-Buffering": "no"},
251
+ )
252
+
253
+
254
+ @router.get("/stats", response_model=KnowledgeStatsResponse)
255
+ def get_knowledge_stats(request: Request) -> KnowledgeStatsResponse:
256
+ tag_service, index_service, _rag_service = _get_services(request)
257
+ task_store: SqliteTaskRepository = request.app.state.task_repository
258
+ settings = settings_manager.current
259
+ return KnowledgeStatsResponse(
260
+ video_count=len(task_store.list_video_assets()),
261
+ indexed_chunk_count=task_store.get_knowledge_chunk_count(),
262
+ tag_count=len(tag_service.get_all_tags()),
263
+ untagged_video_count=len(task_store.list_untagged_video_ids()),
264
+ knowledge_llm_available=bool(settings.knowledge_enabled and knowledge_llm_available(settings)),
265
+ )
266
+
267
+
268
+ @router.post("/rebuild-index", response_model=KnowledgeRebuildResponse)
269
+ def rebuild_index(request: Request) -> KnowledgeRebuildResponse:
270
+ _require_knowledge_runtime()
271
+ _tag_service, index_service, _rag_service = _get_services(request)
272
+ return KnowledgeRebuildResponse(indexed_videos=index_service.rebuild_index(force=True))
@@ -0,0 +1,280 @@
1
+ import os
2
+ import re
3
+ import threading
4
+
5
+ import httpx
6
+ from fastapi import APIRouter, HTTPException, Request
7
+
8
+ from video_sum_core.models.tasks import TaskStatus
9
+ from video_sum_infra.runtime import (
10
+ activate_runtime_pythonpath,
11
+ bootstrap_managed_runtime,
12
+ log_dir,
13
+ prepend_runtime_path,
14
+ service_log_path,
15
+ )
16
+
17
+ from video_sum_service.context import app_info, logger, settings_manager
18
+ from video_sum_service.integrations import (
19
+ extract_http_error_detail,
20
+ probe_asr_connection,
21
+ probe_llm_connection,
22
+ read_log_tail,
23
+ )
24
+ from video_sum_service.runtime_support import (
25
+ build_worker,
26
+ clear_environment_probe_cache,
27
+ detect_environment,
28
+ inspect_runtime_channels,
29
+ install_cuda_support,
30
+ install_knowledge_dependencies,
31
+ install_local_asr,
32
+ replace_task_worker,
33
+ serialize_settings,
34
+ sync_all_runtime_channels,
35
+ sync_runtime_channel,
36
+ )
37
+ from video_sum_service.settings_manager import SettingsUpdatePayload
38
+
39
+ router = APIRouter(prefix="/api/v1")
40
+ LATEST_RELEASE_URL = "https://api.github.com/repos/lycohana/BiliSum/releases/latest"
41
+
42
+
43
+ def _clear_knowledge_service_cache(app_state) -> None:
44
+ for key in (
45
+ "knowledge_tag_service",
46
+ "knowledge_index_service",
47
+ "knowledge_rag_service",
48
+ "knowledge_settings_signature",
49
+ ):
50
+ if hasattr(app_state, key):
51
+ delattr(app_state, key)
52
+
53
+
54
+ def _normalize_version(value: str | None) -> str:
55
+ return str(value or "").strip().removeprefix("v").removeprefix("V")
56
+
57
+
58
+ def _version_key(value: str | None) -> tuple[int | str, ...]:
59
+ normalized = _normalize_version(value)
60
+ if not normalized:
61
+ return (0,)
62
+
63
+ parts: list[int | str] = []
64
+ for chunk in re.split(r"[.\-+_]", normalized):
65
+ if not chunk:
66
+ continue
67
+ parts.append(int(chunk) if chunk.isdigit() else chunk.lower())
68
+ return tuple(parts) or (0,)
69
+
70
+
71
+ @router.get("/system/info")
72
+ def system_info(runtime_channel: str | None = None, refresh: bool = False) -> dict[str, object]:
73
+ current_settings = settings_manager.current
74
+ active_channel = runtime_channel or current_settings.runtime_channel
75
+ if refresh:
76
+ clear_environment_probe_cache(active_channel)
77
+ environment = detect_environment(active_channel)
78
+ runtime_settings = current_settings.with_resolved_runtime(
79
+ cuda_available=bool(environment.get("cudaAvailable"))
80
+ )
81
+ return {
82
+ "application": {"name": app_info.name, "version": app_info.version},
83
+ "service": {
84
+ "host": current_settings.host,
85
+ "port": current_settings.port,
86
+ "data_dir": str(current_settings.data_dir),
87
+ "cache_dir": str(current_settings.cache_dir),
88
+ "tasks_dir": str(current_settings.tasks_dir),
89
+ "database_url": current_settings.database_url,
90
+ "log_dir": str(log_dir()),
91
+ "log_file": str(service_log_path()),
92
+ },
93
+ "runtime": {
94
+ "runtime_channel": active_channel,
95
+ "whisper_model": runtime_settings.whisper_model,
96
+ "whisper_device": runtime_settings.whisper_device,
97
+ "whisper_compute_type": runtime_settings.whisper_compute_type,
98
+ "llm_enabled": current_settings.llm_enabled,
99
+ "llm_model": current_settings.llm_model,
100
+ },
101
+ "taskModel": {"statuses": [status.value for status in TaskStatus]},
102
+ "environment": environment,
103
+ }
104
+
105
+
106
+ @router.get("/settings")
107
+ def get_settings() -> dict[str, object]:
108
+ return serialize_settings(settings_manager.current)
109
+
110
+
111
+ @router.get("/app/update")
112
+ def get_app_update() -> dict[str, object]:
113
+ current_version = app_info.version
114
+ headers = {
115
+ "Accept": "application/vnd.github+json",
116
+ "User-Agent": f"{app_info.name}/{current_version}",
117
+ }
118
+
119
+ try:
120
+ with httpx.Client(timeout=20, follow_redirects=True, headers=headers) as client:
121
+ response = client.get(LATEST_RELEASE_URL)
122
+ except httpx.HTTPError as exc:
123
+ raise HTTPException(status_code=502, detail=f"获取最新版本信息失败:{exc}") from exc
124
+
125
+ if response.status_code >= 400:
126
+ detail = extract_http_error_detail(response)
127
+ raise HTTPException(status_code=response.status_code, detail=f"获取最新版本信息失败:{detail}")
128
+
129
+ try:
130
+ payload = response.json()
131
+ except ValueError as exc:
132
+ raise HTTPException(status_code=502, detail="获取最新版本信息失败:响应格式无效。") from exc
133
+
134
+ latest_version = _normalize_version(payload.get("tag_name") or payload.get("name") or current_version)
135
+ if not latest_version:
136
+ raise HTTPException(status_code=502, detail="获取最新版本信息失败:缺少版本号。")
137
+
138
+ current_key = _version_key(current_version)
139
+ latest_key = _version_key(latest_version)
140
+ is_newer_available = latest_key > current_key
141
+
142
+ return {
143
+ "status": "available" if is_newer_available else "not-available",
144
+ "version": latest_version if is_newer_available else _normalize_version(current_version),
145
+ "releaseDate": payload.get("published_at") or payload.get("created_at") or "",
146
+ "releaseNotes": str(payload.get("body") or "").strip() or None,
147
+ "downloadProgress": 0,
148
+ "errorMessage": None,
149
+ }
150
+
151
+
152
+ @router.put("/settings")
153
+ def update_settings(payload: SettingsUpdatePayload, request: Request) -> dict[str, object]:
154
+ previous_settings = settings_manager.current
155
+ current_settings = settings_manager.save(payload)
156
+ bootstrap_managed_runtime(current_settings.runtime_channel)
157
+ prepend_runtime_path(current_settings.runtime_channel)
158
+ activate_runtime_pythonpath(current_settings.runtime_channel)
159
+ current_settings.data_dir.mkdir(parents=True, exist_ok=True)
160
+ current_settings.cache_dir.mkdir(parents=True, exist_ok=True)
161
+ current_settings.tasks_dir.mkdir(parents=True, exist_ok=True)
162
+ runtime_channel_changed = previous_settings.runtime_channel != current_settings.runtime_channel
163
+ if runtime_channel_changed:
164
+ clear_environment_probe_cache(previous_settings.runtime_channel)
165
+ clear_environment_probe_cache(current_settings.runtime_channel)
166
+ _clear_knowledge_service_cache(request.app.state)
167
+ environment = detect_environment(current_settings.runtime_channel)
168
+ replace_task_worker(
169
+ request.app.state,
170
+ build_worker(
171
+ request.app.state.task_repository,
172
+ current_settings,
173
+ environment_info=environment,
174
+ ),
175
+ )
176
+ return {
177
+ "saved": True,
178
+ "settings": serialize_settings(current_settings, environment_info=environment),
179
+ "message": "设置已保存。涉及服务监听地址的修改将在下次启动后生效。",
180
+ }
181
+
182
+
183
+ @router.post("/llm/test")
184
+ def post_llm_test(payload: SettingsUpdatePayload | None = None) -> dict[str, object]:
185
+ return probe_llm_connection(payload)
186
+
187
+
188
+ @router.post("/asr/test")
189
+ def post_asr_test(payload: SettingsUpdatePayload | None = None) -> dict[str, object]:
190
+ return probe_asr_connection(payload)
191
+
192
+
193
+ @router.get("/environment")
194
+ def get_environment(runtime_channel: str | None = None, refresh: bool = False) -> dict[str, object]:
195
+ active_channel = runtime_channel or settings_manager.current.runtime_channel
196
+ if refresh:
197
+ clear_environment_probe_cache(active_channel)
198
+ return detect_environment(active_channel)
199
+
200
+
201
+ @router.get("/runtime/status")
202
+ def get_runtime_status() -> dict[str, object]:
203
+ return inspect_runtime_channels()
204
+
205
+
206
+ @router.post("/runtime/sync")
207
+ def post_runtime_sync(request: Request, payload: dict[str, object] | None = None) -> dict[str, object]:
208
+ requested_channel = str((payload or {}).get("runtime_channel") or (payload or {}).get("runtimeChannel") or "").strip()
209
+ try:
210
+ result = sync_runtime_channel(requested_channel) if requested_channel else sync_all_runtime_channels()
211
+ _clear_knowledge_service_cache(request.app.state)
212
+
213
+ current_settings = settings_manager.current
214
+ environment = detect_environment(current_settings.runtime_channel)
215
+ replace_task_worker(
216
+ request.app.state,
217
+ build_worker(
218
+ request.app.state.task_repository,
219
+ current_settings,
220
+ environment_info=environment,
221
+ ),
222
+ )
223
+ return {
224
+ **result,
225
+ "environment": environment,
226
+ "runtimeStatus": inspect_runtime_channels(),
227
+ }
228
+ except HTTPException:
229
+ raise
230
+ except Exception as exc:
231
+ logger.exception("runtime sync failed requested_channel=%s error=%s", requested_channel or "all", exc)
232
+ raise HTTPException(status_code=500, detail=f"运行时同步失败:{exc}") from exc
233
+
234
+
235
+ @router.get("/system/logs")
236
+ def get_system_logs(lines: int = 200) -> dict[str, object]:
237
+ line_count = max(20, min(int(lines), 1000))
238
+ return {
239
+ "path": str(service_log_path()),
240
+ "lines": line_count,
241
+ "content": read_log_tail(line_count),
242
+ }
243
+
244
+
245
+ @router.post("/system/shutdown")
246
+ def shutdown_service(request: Request) -> dict[str, object]:
247
+ def shutdown() -> None:
248
+ task_worker = getattr(request.app.state, "task_worker", None)
249
+ if task_worker is not None:
250
+ task_worker.shutdown(wait=False)
251
+ os._exit(0)
252
+
253
+ threading.Timer(0.5, shutdown).start()
254
+ return {"shuttingDown": True, "message": "服务正在关闭。"}
255
+
256
+
257
+ @router.post("/cuda/install")
258
+ def post_cuda_install(payload: dict[str, object], request: Request) -> dict[str, object]:
259
+ requested_variant = payload.get("cuda_variant", payload.get("cudaVariant", "cu128"))
260
+ result, worker = install_cuda_support(str(requested_variant), request.app.state.task_repository)
261
+ _clear_knowledge_service_cache(request.app.state)
262
+ replace_task_worker(request.app.state, worker)
263
+ return result
264
+
265
+
266
+ @router.post("/asr/local/install")
267
+ def post_local_asr_install(request: Request, payload: dict[str, object] | None = None) -> dict[str, object]:
268
+ reinstall = bool((payload or {}).get("reinstall"))
269
+ result, worker = install_local_asr(reinstall=reinstall, repository=request.app.state.task_repository)
270
+ replace_task_worker(request.app.state, worker)
271
+ return result
272
+
273
+
274
+ @router.post("/knowledge/install")
275
+ def post_knowledge_install(request: Request, payload: dict[str, object] | None = None) -> dict[str, object]:
276
+ reinstall = bool((payload or {}).get("reinstall"))
277
+ result, worker = install_knowledge_dependencies(reinstall=reinstall, repository=request.app.state.task_repository)
278
+ _clear_knowledge_service_cache(request.app.state)
279
+ replace_task_worker(request.app.state, worker)
280
+ return result