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,287 @@
1
+ import asyncio
2
+ import json
3
+
4
+ from fastapi import APIRouter, HTTPException, Request, status
5
+ from fastapi.responses import StreamingResponse
6
+
7
+ from video_sum_core.models.tasks import InputType, TaskStatus
8
+ from video_sum_core.utils import normalize_video_url
9
+
10
+ from video_sum_service.repository import SqliteTaskRepository
11
+ from video_sum_service.schemas import (
12
+ TaskCreateRequest,
13
+ TaskDetailResponse,
14
+ TaskEventResponse,
15
+ TaskMarkdownExportRequest,
16
+ TaskMarkdownExportResponse,
17
+ TaskMindMapResponse,
18
+ TaskProgressResponse,
19
+ TaskSummaryResponse,
20
+ )
21
+ from video_sum_service.context import settings_manager
22
+ from video_sum_service.task_artifacts import cleanup_task_files, load_task_mindmap
23
+ from video_sum_service.task_exports import export_task_markdown as export_task_markdown_artifact
24
+ from video_sum_service.video_assets import probe_video_asset
25
+ from video_sum_service.worker import TaskWorker
26
+
27
+ router = APIRouter(prefix="/api/v1/tasks")
28
+
29
+
30
+ @router.post("", response_model=TaskDetailResponse, status_code=status.HTTP_201_CREATED)
31
+ def create_task(body: TaskCreateRequest, request: Request) -> TaskDetailResponse:
32
+ task_store: SqliteTaskRepository = request.app.state.task_repository
33
+ task_worker: TaskWorker = request.app.state.task_worker
34
+
35
+ video_id = body.video_id
36
+ if video_id is None and body.input_type is InputType.URL:
37
+ probed, _, _ = probe_video_asset(body.source)
38
+ asset = task_store.upsert_video_asset(probed)
39
+ video_id = asset.video_id
40
+
41
+ normalized = normalize_video_url(body.source) if body.input_type is InputType.URL else None
42
+ page_number = normalized.page_number if normalized and normalized.platform == "bilibili" else None
43
+
44
+ record = task_store.create_task(
45
+ body,
46
+ video_id=video_id,
47
+ page_number=page_number,
48
+ page_title=body.title,
49
+ )
50
+ task_worker.submit(record)
51
+ refreshed = task_store.get_task(record.task_id)
52
+ assert refreshed is not None
53
+ return refreshed.to_detail()
54
+
55
+
56
+ @router.get("", response_model=list[TaskSummaryResponse])
57
+ def list_tasks(request: Request) -> list[TaskSummaryResponse]:
58
+ task_store: SqliteTaskRepository = request.app.state.task_repository
59
+ return [record.to_summary() for record in task_store.list_tasks()]
60
+
61
+
62
+ @router.get("/{task_id}", response_model=TaskDetailResponse)
63
+ def get_task(task_id: str, request: Request) -> TaskDetailResponse:
64
+ task_store: SqliteTaskRepository = request.app.state.task_repository
65
+ record = task_store.get_task(task_id)
66
+ if record is None:
67
+ raise HTTPException(status_code=404, detail="Task not found.")
68
+ return record.to_detail()
69
+
70
+
71
+ @router.delete("/{task_id}")
72
+ def delete_task(task_id: str, request: Request) -> dict[str, object]:
73
+ task_store: SqliteTaskRepository = request.app.state.task_repository
74
+ record = task_store.get_task(task_id)
75
+ if record is None:
76
+ raise HTTPException(status_code=404, detail="Task not found.")
77
+ # 先清理文件,再删除数据库记录:如果文件清理失败,数据库记录仍保留以便重试
78
+ cleanup_task_files(record, settings_manager.current)
79
+ deleted = task_store.delete_task(task_id)
80
+ if not deleted:
81
+ raise HTTPException(status_code=404, detail="Task not found.")
82
+ return {"deleted": True, "task_id": task_id}
83
+
84
+
85
+ @router.get("/{task_id}/result", response_model=TaskDetailResponse)
86
+ def get_task_result(task_id: str, request: Request) -> TaskDetailResponse:
87
+ return get_task(task_id, request)
88
+
89
+
90
+ @router.get("/{task_id}/mindmap", response_model=TaskMindMapResponse)
91
+ def get_task_mindmap(task_id: str, request: Request) -> TaskMindMapResponse:
92
+ task_store: SqliteTaskRepository = request.app.state.task_repository
93
+ record = task_store.get_task(task_id)
94
+ if record is None:
95
+ raise HTTPException(status_code=404, detail="Task not found.")
96
+
97
+ result = record.result
98
+ if result is None:
99
+ return TaskMindMapResponse(task_id=task_id, status="idle")
100
+
101
+ mindmap_path = result.mindmap_artifact_path or result.artifacts.get("mindmap_path")
102
+ mindmap = None
103
+ status_value = result.mindmap_status or ("ready" if mindmap_path else "idle")
104
+ error_message = result.mindmap_error_message
105
+ if status_value == "generating":
106
+ return TaskMindMapResponse(
107
+ task_id=task_id,
108
+ status="generating",
109
+ error_message=error_message,
110
+ updated_at=result.mindmap_updated_at,
111
+ mindmap=None,
112
+ )
113
+ if status_value == "failed":
114
+ return TaskMindMapResponse(
115
+ task_id=task_id,
116
+ status="failed",
117
+ error_message=error_message,
118
+ updated_at=result.mindmap_updated_at,
119
+ mindmap=None,
120
+ )
121
+ if mindmap_path:
122
+ try:
123
+ mindmap = load_task_mindmap(mindmap_path)
124
+ except HTTPException:
125
+ status_value = "failed"
126
+ error_message = "思维导图文件缺失或已损坏,请重新生成。"
127
+ if mindmap is not None:
128
+ status_value = "ready"
129
+
130
+ return TaskMindMapResponse(
131
+ task_id=task_id,
132
+ status=status_value,
133
+ error_message=error_message,
134
+ updated_at=result.mindmap_updated_at,
135
+ mindmap=mindmap,
136
+ )
137
+
138
+
139
+ @router.post("/{task_id}/mindmap", response_model=TaskMindMapResponse)
140
+ def generate_task_mindmap(request: Request, task_id: str, force: bool = False) -> TaskMindMapResponse:
141
+ task_store: SqliteTaskRepository = request.app.state.task_repository
142
+ task_worker: TaskWorker = request.app.state.task_worker
143
+ record = task_store.get_task(task_id)
144
+ if record is None:
145
+ raise HTTPException(status_code=404, detail="Task not found.")
146
+ if record.status != TaskStatus.COMPLETED or record.result is None:
147
+ raise HTTPException(status_code=400, detail="仅已完成且有结果的任务可以生成思维导图。")
148
+ if not record.result.knowledge_note_markdown.strip():
149
+ raise HTTPException(status_code=400, detail="当前任务缺少知识笔记,暂时无法生成思维导图。")
150
+ if not record.result.artifacts.get("summary_path"):
151
+ raise HTTPException(status_code=400, detail="当前任务缺少摘要文件,暂时无法生成思维导图。")
152
+
153
+ existing_path = record.result.mindmap_artifact_path or record.result.artifacts.get("mindmap_path")
154
+ if record.result.mindmap_status == "generating" and not force:
155
+ return TaskMindMapResponse(
156
+ task_id=task_id,
157
+ status="generating",
158
+ error_message=None,
159
+ updated_at=record.result.mindmap_updated_at,
160
+ mindmap=None,
161
+ )
162
+ if existing_path and record.result.mindmap_status == "ready" and not force:
163
+ try:
164
+ return TaskMindMapResponse(
165
+ task_id=task_id,
166
+ status="ready",
167
+ error_message=None,
168
+ updated_at=record.result.mindmap_updated_at,
169
+ mindmap=load_task_mindmap(existing_path),
170
+ )
171
+ except HTTPException:
172
+ pass
173
+
174
+ generating_result = record.result.model_copy(
175
+ update={
176
+ "mindmap_status": "generating",
177
+ "mindmap_error_message": None,
178
+ }
179
+ )
180
+ task_store.save_result(task_id, generating_result)
181
+ task_worker.submit_mindmap(task_id, force=force)
182
+ refreshed = task_store.get_task(task_id)
183
+ refreshed_result = refreshed.result if refreshed is not None else generating_result
184
+ return TaskMindMapResponse(
185
+ task_id=task_id,
186
+ status=refreshed_result.mindmap_status,
187
+ error_message=refreshed_result.mindmap_error_message,
188
+ updated_at=refreshed_result.mindmap_updated_at,
189
+ mindmap=None,
190
+ )
191
+
192
+
193
+ @router.post("/{task_id}/exports/markdown", response_model=TaskMarkdownExportResponse)
194
+ def export_task_markdown(request: Request, task_id: str, body: TaskMarkdownExportRequest) -> TaskMarkdownExportResponse:
195
+ task_store: SqliteTaskRepository = request.app.state.task_repository
196
+ return export_task_markdown_artifact(
197
+ task_store,
198
+ settings_manager.current,
199
+ task_id,
200
+ target=body.target,
201
+ )
202
+
203
+
204
+ @router.get("/{task_id}/events", response_model=list[TaskEventResponse])
205
+ def get_task_events(task_id: str, request: Request) -> list[TaskEventResponse]:
206
+ task_store: SqliteTaskRepository = request.app.state.task_repository
207
+ record = task_store.get_task(task_id)
208
+ if record is None:
209
+ raise HTTPException(status_code=404, detail="Task not found.")
210
+ return [event.to_response() for event in task_store.list_events(task_id)]
211
+
212
+
213
+ @router.get("/{task_id}/events/stream")
214
+ async def stream_task_events(request: Request, task_id: str, after: str | None = None) -> StreamingResponse:
215
+ task_store: SqliteTaskRepository = request.app.state.task_repository
216
+ record = task_store.get_task(task_id)
217
+ if record is None:
218
+ raise HTTPException(status_code=404, detail="Task not found.")
219
+
220
+ async def event_generator():
221
+ last_seen = after
222
+ idle_ticks = 0
223
+ terminal_statuses = {TaskStatus.COMPLETED, TaskStatus.FAILED, TaskStatus.CANCELLED}
224
+
225
+ while True:
226
+ if await request.is_disconnected():
227
+ return
228
+
229
+ current_record = task_store.get_task(task_id)
230
+ if current_record is None:
231
+ yield "event: error\ndata: {\"message\":\"Task not found.\"}\n\n"
232
+ return
233
+
234
+ events = task_store.list_events_after(task_id, last_seen)
235
+ if events:
236
+ idle_ticks = 0
237
+ for event in events:
238
+ if await request.is_disconnected():
239
+ return
240
+ last_seen = event.created_at.isoformat()
241
+ payload = {
242
+ "event": event.to_response().model_dump(mode="json"),
243
+ "status": current_record.status.value,
244
+ "updated_at": current_record.updated_at.isoformat(),
245
+ "result": current_record.result.model_dump(mode="json") if current_record.result is not None else None,
246
+ }
247
+ yield f"event: progress\ndata: {json.dumps(payload, ensure_ascii=False)}\n\n"
248
+ else:
249
+ idle_ticks += 1
250
+
251
+ current_record = task_store.get_task(task_id)
252
+ if current_record is None or current_record.status in terminal_statuses:
253
+ if idle_ticks >= 2:
254
+ return
255
+
256
+ if idle_ticks >= 20:
257
+ if await request.is_disconnected():
258
+ return
259
+ yield "event: heartbeat\ndata: {}\n\n"
260
+ idle_ticks = 0
261
+
262
+ if await request.is_disconnected():
263
+ return
264
+ await asyncio.sleep(0.4)
265
+
266
+ return StreamingResponse(
267
+ event_generator(),
268
+ media_type="text/event-stream",
269
+ headers={"Cache-Control": "no-cache", "Connection": "keep-alive", "X-Accel-Buffering": "no"},
270
+ )
271
+
272
+
273
+ @router.get("/{task_id}/progress", response_model=TaskProgressResponse)
274
+ def get_task_progress(task_id: str, request: Request) -> TaskProgressResponse:
275
+ task_store: SqliteTaskRepository = request.app.state.task_repository
276
+ record = task_store.get_task(task_id)
277
+ if record is None:
278
+ raise HTTPException(status_code=404, detail="Task not found.")
279
+ latest_event = task_store.get_latest_event(task_id)
280
+ return TaskProgressResponse(
281
+ task_id=record.task_id,
282
+ status=record.status,
283
+ progress=int(latest_event.progress) if latest_event is not None else 0,
284
+ latest_stage=latest_event.stage if latest_event is not None else None,
285
+ latest_message=latest_event.message if latest_event is not None else None,
286
+ updated_at=record.updated_at,
287
+ )