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,3467 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import logging
|
|
5
|
+
import math
|
|
6
|
+
import os
|
|
7
|
+
import re
|
|
8
|
+
import shutil
|
|
9
|
+
import subprocess
|
|
10
|
+
import time
|
|
11
|
+
from concurrent.futures import ThreadPoolExecutor, as_completed
|
|
12
|
+
from dataclasses import dataclass
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
from typing import Callable, NoReturn
|
|
15
|
+
|
|
16
|
+
import httpx
|
|
17
|
+
from yt_dlp import YoutubeDL
|
|
18
|
+
from yt_dlp.utils import DownloadError
|
|
19
|
+
|
|
20
|
+
from video_sum_core.errors import (
|
|
21
|
+
LLMAuthenticationError,
|
|
22
|
+
LLMConfigurationError,
|
|
23
|
+
TranscriptionAuthenticationError,
|
|
24
|
+
TranscriptionConfigurationError,
|
|
25
|
+
UnsupportedInputError,
|
|
26
|
+
VideoSumError,
|
|
27
|
+
)
|
|
28
|
+
from video_sum_core.models.tasks import InputType, MindMapNode, TaskMindMap, TaskResult
|
|
29
|
+
from video_sum_core.pipeline.base import PipelineContext, PipelineEvent, PipelineEventReporter, PipelineRunner
|
|
30
|
+
from video_sum_core.utils import ensure_directory, normalize_video_url, sanitize_filename
|
|
31
|
+
from video_sum_infra.runtime import (
|
|
32
|
+
ffmpeg_location,
|
|
33
|
+
runtime_library_dirs,
|
|
34
|
+
runtime_python_executable,
|
|
35
|
+
sanitized_subprocess_dll_search,
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
logger = logging.getLogger("video_sum_core.pipeline.real")
|
|
39
|
+
|
|
40
|
+
_BILIBILI_HTTP_HEADERS = {
|
|
41
|
+
"User-Agent": (
|
|
42
|
+
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
|
|
43
|
+
"AppleWebKit/537.36 (KHTML, like Gecko) "
|
|
44
|
+
"Chrome/124.0.0.0 Safari/537.36"
|
|
45
|
+
),
|
|
46
|
+
"Accept-Language": "zh-CN,zh;q=0.9,en;q=0.8",
|
|
47
|
+
"Referer": "https://www.bilibili.com/",
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
def _windows_hidden_subprocess_kwargs() -> dict[str, object]:
|
|
51
|
+
if os.name != "nt":
|
|
52
|
+
return {}
|
|
53
|
+
|
|
54
|
+
kwargs: dict[str, object] = {}
|
|
55
|
+
creationflags = getattr(subprocess, "CREATE_NO_WINDOW", 0)
|
|
56
|
+
if creationflags:
|
|
57
|
+
kwargs["creationflags"] = creationflags
|
|
58
|
+
|
|
59
|
+
startupinfo_cls = getattr(subprocess, "STARTUPINFO", None)
|
|
60
|
+
use_show_window = getattr(subprocess, "STARTF_USESHOWWINDOW", 0)
|
|
61
|
+
sw_hide = getattr(subprocess, "SW_HIDE", 0)
|
|
62
|
+
if startupinfo_cls is not None:
|
|
63
|
+
startupinfo = startupinfo_cls()
|
|
64
|
+
startupinfo.dwFlags |= use_show_window
|
|
65
|
+
startupinfo.wShowWindow = sw_hide
|
|
66
|
+
kwargs["startupinfo"] = startupinfo
|
|
67
|
+
|
|
68
|
+
return kwargs
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _safe_int(value: object) -> int | None:
|
|
72
|
+
try:
|
|
73
|
+
return int(value) if value is not None else None
|
|
74
|
+
except (TypeError, ValueError):
|
|
75
|
+
return None
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _truncate_text(value: str, limit: int) -> str:
|
|
79
|
+
text = str(value or "")
|
|
80
|
+
if len(text) <= limit:
|
|
81
|
+
return text
|
|
82
|
+
return text[:limit]
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _extract_response_error_detail(response: httpx.Response) -> str:
|
|
86
|
+
try:
|
|
87
|
+
payload = response.json()
|
|
88
|
+
except ValueError:
|
|
89
|
+
return _truncate_text(response.text.strip(), 400)
|
|
90
|
+
|
|
91
|
+
if isinstance(payload, dict):
|
|
92
|
+
error = payload.get("error")
|
|
93
|
+
if isinstance(error, dict):
|
|
94
|
+
message = error.get("message")
|
|
95
|
+
code = error.get("code")
|
|
96
|
+
if message and code:
|
|
97
|
+
return f"{code}: {message}"
|
|
98
|
+
if message:
|
|
99
|
+
return str(message)
|
|
100
|
+
message = payload.get("message")
|
|
101
|
+
if message:
|
|
102
|
+
return str(message)
|
|
103
|
+
return _truncate_text(json.dumps(payload, ensure_ascii=False), 400)
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def _extract_json_object_text(value: str) -> str:
|
|
107
|
+
text = str(value or "").strip()
|
|
108
|
+
if text.startswith("```"):
|
|
109
|
+
lines = text.splitlines()
|
|
110
|
+
if lines and lines[0].startswith("```"):
|
|
111
|
+
lines = lines[1:]
|
|
112
|
+
if lines and lines[-1].strip() == "```":
|
|
113
|
+
lines = lines[:-1]
|
|
114
|
+
text = "\n".join(lines).strip()
|
|
115
|
+
|
|
116
|
+
start = text.find("{")
|
|
117
|
+
end = text.rfind("}")
|
|
118
|
+
if start != -1 and end != -1 and end > start:
|
|
119
|
+
return text[start : end + 1]
|
|
120
|
+
return text
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def _should_retry_llm_transport_error(error: Exception) -> bool:
|
|
124
|
+
return isinstance(
|
|
125
|
+
error,
|
|
126
|
+
(
|
|
127
|
+
httpx.ConnectError,
|
|
128
|
+
httpx.ConnectTimeout,
|
|
129
|
+
httpx.ReadTimeout,
|
|
130
|
+
httpx.WriteTimeout,
|
|
131
|
+
httpx.ReadError,
|
|
132
|
+
httpx.WriteError,
|
|
133
|
+
httpx.CloseError,
|
|
134
|
+
httpx.ProxyError,
|
|
135
|
+
httpx.RemoteProtocolError,
|
|
136
|
+
httpx.NetworkError,
|
|
137
|
+
httpx.ProtocolError,
|
|
138
|
+
httpx.TransportError,
|
|
139
|
+
),
|
|
140
|
+
)
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
@dataclass(slots=True)
|
|
144
|
+
class PipelineSettings:
|
|
145
|
+
tasks_dir: Path
|
|
146
|
+
runtime_channel: str = "base"
|
|
147
|
+
transcription_provider: str = "siliconflow"
|
|
148
|
+
whisper_model: str = "tiny"
|
|
149
|
+
whisper_device: str = "cpu"
|
|
150
|
+
whisper_compute_type: str = "int8"
|
|
151
|
+
local_asr_available: bool = False
|
|
152
|
+
siliconflow_asr_base_url: str = "https://api.siliconflow.cn/v1"
|
|
153
|
+
siliconflow_asr_model: str = "TeleAI/TeleSpeechASR"
|
|
154
|
+
siliconflow_asr_api_key: str = ""
|
|
155
|
+
llm_enabled: bool = False
|
|
156
|
+
llm_api_key: str = ""
|
|
157
|
+
llm_base_url: str = ""
|
|
158
|
+
llm_model: str = ""
|
|
159
|
+
summary_system_prompt: str = ""
|
|
160
|
+
summary_user_prompt_template: str = ""
|
|
161
|
+
mindmap_system_prompt: str = ""
|
|
162
|
+
mindmap_user_prompt_template: str = ""
|
|
163
|
+
summary_chunk_target_chars: int = 2200
|
|
164
|
+
summary_chunk_overlap_segments: int = 2
|
|
165
|
+
summary_chunk_concurrency: int = 2
|
|
166
|
+
summary_chunk_retry_count: int = 2
|
|
167
|
+
ytdlp_cookies_file: str = ""
|
|
168
|
+
ytdlp_cookies_browser: str = ""
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
class RealPipelineRunner(PipelineRunner):
|
|
172
|
+
def __init__(self, settings: PipelineSettings) -> None:
|
|
173
|
+
self._settings = settings
|
|
174
|
+
|
|
175
|
+
def preflight(
|
|
176
|
+
self,
|
|
177
|
+
context: PipelineContext,
|
|
178
|
+
on_event: PipelineEventReporter | None = None,
|
|
179
|
+
) -> None:
|
|
180
|
+
task_input = context.task_input
|
|
181
|
+
|
|
182
|
+
def emit(stage: str, progress: int, message: str, payload: dict[str, object] | None = None) -> None:
|
|
183
|
+
if on_event is None:
|
|
184
|
+
return
|
|
185
|
+
on_event(PipelineEvent(stage=stage, progress=progress, message=message, payload=payload or {}))
|
|
186
|
+
|
|
187
|
+
if not (self._settings.llm_enabled and str(self._settings.llm_api_key or "").strip()):
|
|
188
|
+
return
|
|
189
|
+
|
|
190
|
+
if task_input.input_type not in {
|
|
191
|
+
InputType.URL,
|
|
192
|
+
InputType.VIDEO_FILE,
|
|
193
|
+
InputType.AUDIO_FILE,
|
|
194
|
+
InputType.TRANSCRIPT_TEXT,
|
|
195
|
+
}:
|
|
196
|
+
return
|
|
197
|
+
|
|
198
|
+
emit("preflight", 2, "正在检查 LLM API 是否可用")
|
|
199
|
+
self._preflight_llm_availability()
|
|
200
|
+
emit("preflight", 4, "LLM API 检查通过")
|
|
201
|
+
|
|
202
|
+
def run(
|
|
203
|
+
self,
|
|
204
|
+
context: PipelineContext,
|
|
205
|
+
on_event: PipelineEventReporter | None = None,
|
|
206
|
+
) -> tuple[list[PipelineEvent], TaskResult]:
|
|
207
|
+
task_input = context.task_input
|
|
208
|
+
|
|
209
|
+
events: list[PipelineEvent] = []
|
|
210
|
+
|
|
211
|
+
def emit(stage: str, progress: int, message: str, payload: dict[str, object] | None = None) -> None:
|
|
212
|
+
event = PipelineEvent(stage=stage, progress=progress, message=message, payload=payload or {})
|
|
213
|
+
events.append(event)
|
|
214
|
+
logger.info(
|
|
215
|
+
"pipeline event task_id=%s stage=%s progress=%s message=%s payload=%s",
|
|
216
|
+
context.task_id,
|
|
217
|
+
stage,
|
|
218
|
+
progress,
|
|
219
|
+
message,
|
|
220
|
+
event.payload,
|
|
221
|
+
)
|
|
222
|
+
if on_event is not None:
|
|
223
|
+
on_event(event)
|
|
224
|
+
|
|
225
|
+
logger.info(
|
|
226
|
+
"pipeline run start task_id=%s input_type=%s source=%s",
|
|
227
|
+
context.task_id,
|
|
228
|
+
task_input.input_type.value,
|
|
229
|
+
task_input.source,
|
|
230
|
+
)
|
|
231
|
+
|
|
232
|
+
if task_input.input_type is InputType.URL:
|
|
233
|
+
result = self._run_from_url(context, emit)
|
|
234
|
+
elif task_input.input_type in {InputType.VIDEO_FILE, InputType.AUDIO_FILE}:
|
|
235
|
+
result = self._run_from_local_media(context, emit)
|
|
236
|
+
elif task_input.input_type is InputType.TRANSCRIPT_TEXT:
|
|
237
|
+
result = self._run_from_transcript_text(context, emit)
|
|
238
|
+
else:
|
|
239
|
+
raise UnsupportedInputError(
|
|
240
|
+
f"Current runner does not support input type '{task_input.input_type.value}'."
|
|
241
|
+
)
|
|
242
|
+
return events, result
|
|
243
|
+
|
|
244
|
+
def _run_from_url(
|
|
245
|
+
self,
|
|
246
|
+
context: PipelineContext,
|
|
247
|
+
emit: Callable[[str, int, str, dict[str, object] | None], None],
|
|
248
|
+
) -> TaskResult:
|
|
249
|
+
task_input = context.task_input
|
|
250
|
+
emit("preparing", 8, "正在规范化视频链接")
|
|
251
|
+
normalized = normalize_video_url(task_input.source)
|
|
252
|
+
normalized_url = normalized.normalized_url
|
|
253
|
+
canonical_id = normalized.canonical_id
|
|
254
|
+
if normalized.platform not in {"bilibili", "youtube"}:
|
|
255
|
+
raise UnsupportedInputError("Current runner only supports Bilibili and YouTube video URLs.")
|
|
256
|
+
|
|
257
|
+
task_dir = ensure_directory(self._settings.tasks_dir / context.task_id)
|
|
258
|
+
emit("probing", 12, "正在读取视频信息", {"url": normalized_url})
|
|
259
|
+
metadata = self._probe_video(normalized_url)
|
|
260
|
+
title = task_input.title or metadata.get("title") or canonical_id or "video"
|
|
261
|
+
safe_title = sanitize_filename(title)
|
|
262
|
+
emit(
|
|
263
|
+
"probing",
|
|
264
|
+
16,
|
|
265
|
+
"视频信息读取完成",
|
|
266
|
+
{"title": title, "duration": metadata.get("duration")},
|
|
267
|
+
)
|
|
268
|
+
audio_path = self._download_audio(normalized_url, task_dir, safe_title, emit)
|
|
269
|
+
transcript, segments = self._transcribe(audio_path, metadata.get("duration"), emit)
|
|
270
|
+
transcript_result = self._export_transcript_snapshot(task_dir, title, transcript, segments)
|
|
271
|
+
emit(
|
|
272
|
+
"transcribing",
|
|
273
|
+
86,
|
|
274
|
+
"转写完成,已保存可复用文本",
|
|
275
|
+
{
|
|
276
|
+
"result": transcript_result.model_dump(mode="json"),
|
|
277
|
+
"result_scope": "transcript",
|
|
278
|
+
},
|
|
279
|
+
)
|
|
280
|
+
summary = self._summarize(transcript, segments, title, emit)
|
|
281
|
+
emit("exporting", 97, "正在导出任务结果")
|
|
282
|
+
result = self._export_result(task_dir, title, transcript, segments, summary)
|
|
283
|
+
emit("exporting", 99, "结果文件已写入本地目录")
|
|
284
|
+
logger.info(
|
|
285
|
+
"pipeline url run finish task_id=%s segments=%d transcript_chars=%d output_dir=%s",
|
|
286
|
+
context.task_id,
|
|
287
|
+
len(segments),
|
|
288
|
+
len(transcript),
|
|
289
|
+
task_dir,
|
|
290
|
+
)
|
|
291
|
+
return result
|
|
292
|
+
|
|
293
|
+
def _run_from_transcript_text(
|
|
294
|
+
self,
|
|
295
|
+
context: PipelineContext,
|
|
296
|
+
emit: Callable[[str, int, str, dict[str, object] | None], None],
|
|
297
|
+
) -> TaskResult:
|
|
298
|
+
task_dir = ensure_directory(self._settings.tasks_dir / context.task_id)
|
|
299
|
+
title, transcript, segments, source_kind = self._parse_transcript_payload(
|
|
300
|
+
context.task_input.source,
|
|
301
|
+
context.task_input.title,
|
|
302
|
+
)
|
|
303
|
+
is_aggregate_series = source_kind == "aggregate_series"
|
|
304
|
+
emit(
|
|
305
|
+
"preparing",
|
|
306
|
+
12,
|
|
307
|
+
"正在整理全集摘要素材" if is_aggregate_series else "正在复用已有转写内容",
|
|
308
|
+
{"segment_count": len(segments), "source_kind": source_kind or "transcript"},
|
|
309
|
+
)
|
|
310
|
+
emit(
|
|
311
|
+
"transcribing",
|
|
312
|
+
32,
|
|
313
|
+
"已跳过重新转写,直接复用分 P 摘要素材" if is_aggregate_series else "已跳过重新转写,直接复用当前版本文本",
|
|
314
|
+
{"transcript_chars": len(transcript), "segment_count": len(segments)},
|
|
315
|
+
)
|
|
316
|
+
summary = self._summarize(transcript, segments, title, emit, source_kind=source_kind)
|
|
317
|
+
emit("exporting", 97, "正在导出新的摘要结果")
|
|
318
|
+
result = self._export_result(task_dir, title, transcript, segments, summary)
|
|
319
|
+
emit("exporting", 99, "新的摘要结果已写入本地目录")
|
|
320
|
+
logger.info(
|
|
321
|
+
"pipeline transcript rerun finish task_id=%s segments=%d transcript_chars=%d output_dir=%s",
|
|
322
|
+
context.task_id,
|
|
323
|
+
len(segments),
|
|
324
|
+
len(transcript),
|
|
325
|
+
task_dir,
|
|
326
|
+
)
|
|
327
|
+
return result
|
|
328
|
+
|
|
329
|
+
def _run_from_local_media(
|
|
330
|
+
self,
|
|
331
|
+
context: PipelineContext,
|
|
332
|
+
emit: Callable[[str, int, str, dict[str, object] | None], None],
|
|
333
|
+
) -> TaskResult:
|
|
334
|
+
task_input = context.task_input
|
|
335
|
+
source_path = Path(str(task_input.source or "")).expanduser()
|
|
336
|
+
if not source_path.exists() or not source_path.is_file():
|
|
337
|
+
raise VideoSumError(f"Local media file does not exist: {source_path}")
|
|
338
|
+
|
|
339
|
+
task_dir = ensure_directory(self._settings.tasks_dir / context.task_id)
|
|
340
|
+
title = task_input.title or source_path.stem or source_path.name or "local_media"
|
|
341
|
+
safe_title = sanitize_filename(title)
|
|
342
|
+
is_video_file = task_input.input_type is InputType.VIDEO_FILE
|
|
343
|
+
emit(
|
|
344
|
+
"preparing",
|
|
345
|
+
8,
|
|
346
|
+
"正在读取本地视频文件" if is_video_file else "正在读取本地音频文件",
|
|
347
|
+
{"path": str(source_path)},
|
|
348
|
+
)
|
|
349
|
+
audio_path = self._prepare_local_audio_source(
|
|
350
|
+
source_path=source_path,
|
|
351
|
+
task_dir=task_dir,
|
|
352
|
+
safe_title=safe_title,
|
|
353
|
+
input_type=task_input.input_type,
|
|
354
|
+
emit=emit,
|
|
355
|
+
)
|
|
356
|
+
transcript, segments = self._transcribe(audio_path, None, emit)
|
|
357
|
+
transcript_result = self._export_transcript_snapshot(task_dir, title, transcript, segments)
|
|
358
|
+
emit(
|
|
359
|
+
"transcribing",
|
|
360
|
+
86,
|
|
361
|
+
"转写完成,已保存可复用文本",
|
|
362
|
+
{
|
|
363
|
+
"result": transcript_result.model_dump(mode="json"),
|
|
364
|
+
"result_scope": "transcript",
|
|
365
|
+
},
|
|
366
|
+
)
|
|
367
|
+
summary = self._summarize(transcript, segments, title, emit)
|
|
368
|
+
emit("exporting", 97, "正在导出任务结果")
|
|
369
|
+
result = self._export_result(task_dir, title, transcript, segments, summary)
|
|
370
|
+
emit("exporting", 99, "结果文件已写入本地目录")
|
|
371
|
+
logger.info(
|
|
372
|
+
"pipeline local media run finish task_id=%s input_type=%s source=%s segments=%d transcript_chars=%d output_dir=%s",
|
|
373
|
+
context.task_id,
|
|
374
|
+
task_input.input_type.value,
|
|
375
|
+
source_path,
|
|
376
|
+
len(segments),
|
|
377
|
+
len(transcript),
|
|
378
|
+
task_dir,
|
|
379
|
+
)
|
|
380
|
+
return result
|
|
381
|
+
|
|
382
|
+
def _prepare_local_audio_source(
|
|
383
|
+
self,
|
|
384
|
+
source_path: Path,
|
|
385
|
+
task_dir: Path,
|
|
386
|
+
safe_title: str,
|
|
387
|
+
input_type: InputType,
|
|
388
|
+
emit: Callable[[str, int, str, dict[str, object] | None], None],
|
|
389
|
+
) -> Path:
|
|
390
|
+
target_path = task_dir / f"{safe_title}.mp3"
|
|
391
|
+
if input_type is InputType.AUDIO_FILE and source_path.suffix.lower() == ".mp3":
|
|
392
|
+
emit("downloading", 20, "正在复制本地音频文件")
|
|
393
|
+
shutil.copy2(source_path, target_path)
|
|
394
|
+
emit("downloading", 48, "音频文件已就绪")
|
|
395
|
+
return target_path
|
|
396
|
+
|
|
397
|
+
ffmpeg_exe = ffmpeg_location()
|
|
398
|
+
if ffmpeg_exe is None:
|
|
399
|
+
raise VideoSumError("FFmpeg is unavailable, cannot process local media files.")
|
|
400
|
+
|
|
401
|
+
emit(
|
|
402
|
+
"downloading",
|
|
403
|
+
20,
|
|
404
|
+
"正在提取本地视频音轨" if input_type is InputType.VIDEO_FILE else "正在转换本地音频格式",
|
|
405
|
+
{"path": str(source_path)},
|
|
406
|
+
)
|
|
407
|
+
emit("downloading", 34, "正在写入可转写音频文件")
|
|
408
|
+
try:
|
|
409
|
+
result = subprocess.run(
|
|
410
|
+
[
|
|
411
|
+
str(ffmpeg_exe),
|
|
412
|
+
"-y",
|
|
413
|
+
"-i",
|
|
414
|
+
str(source_path),
|
|
415
|
+
"-vn",
|
|
416
|
+
"-acodec",
|
|
417
|
+
"libmp3lame",
|
|
418
|
+
"-ab",
|
|
419
|
+
"192k",
|
|
420
|
+
str(target_path),
|
|
421
|
+
],
|
|
422
|
+
capture_output=True,
|
|
423
|
+
text=True,
|
|
424
|
+
encoding="utf-8",
|
|
425
|
+
errors="replace",
|
|
426
|
+
timeout=15 * 60,
|
|
427
|
+
check=False,
|
|
428
|
+
**_windows_hidden_subprocess_kwargs(),
|
|
429
|
+
)
|
|
430
|
+
except (OSError, subprocess.SubprocessError) as exc:
|
|
431
|
+
raise VideoSumError("Failed to extract audio from local media file.") from exc
|
|
432
|
+
if result.returncode != 0 or not target_path.exists():
|
|
433
|
+
logger.error(
|
|
434
|
+
"local media audio extraction failed source=%s returncode=%s stdout=%s stderr=%s",
|
|
435
|
+
source_path,
|
|
436
|
+
result.returncode,
|
|
437
|
+
result.stdout.strip(),
|
|
438
|
+
result.stderr.strip(),
|
|
439
|
+
)
|
|
440
|
+
raise VideoSumError("Failed to extract audio from local media file.")
|
|
441
|
+
|
|
442
|
+
emit("downloading", 48, "音频文件已就绪")
|
|
443
|
+
return target_path
|
|
444
|
+
|
|
445
|
+
def _parse_transcript_payload(
|
|
446
|
+
self,
|
|
447
|
+
source: str,
|
|
448
|
+
title_hint: str | None,
|
|
449
|
+
) -> tuple[str, str, list[dict[str, object]], str | None]:
|
|
450
|
+
try:
|
|
451
|
+
payload = json.loads(source)
|
|
452
|
+
except json.JSONDecodeError as exc:
|
|
453
|
+
raise VideoSumError("Transcript task payload is invalid JSON.") from exc
|
|
454
|
+
|
|
455
|
+
if not isinstance(payload, dict):
|
|
456
|
+
raise VideoSumError("Transcript task payload must be an object.")
|
|
457
|
+
|
|
458
|
+
transcript = str(payload.get("transcript") or "").strip()
|
|
459
|
+
if not transcript:
|
|
460
|
+
raise VideoSumError("Transcript task payload is missing transcript content.")
|
|
461
|
+
|
|
462
|
+
raw_segments = payload.get("segments") or []
|
|
463
|
+
segments = self._coerce_transcript_segments(raw_segments)
|
|
464
|
+
if not segments:
|
|
465
|
+
raise VideoSumError("Transcript task payload is missing valid segments.")
|
|
466
|
+
|
|
467
|
+
title = str(payload.get("title") or title_hint or "视频摘要").strip() or "视频摘要"
|
|
468
|
+
source_kind = str(payload.get("source_kind") or "").strip() or None
|
|
469
|
+
return title, transcript, segments, source_kind
|
|
470
|
+
|
|
471
|
+
def _coerce_transcript_segments(self, value: object) -> list[dict[str, object]]:
|
|
472
|
+
if not isinstance(value, list):
|
|
473
|
+
return []
|
|
474
|
+
segments: list[dict[str, object]] = []
|
|
475
|
+
for item in value:
|
|
476
|
+
if not isinstance(item, dict):
|
|
477
|
+
continue
|
|
478
|
+
text = str(item.get("text") or "").strip()
|
|
479
|
+
if not text:
|
|
480
|
+
continue
|
|
481
|
+
start = item.get("start")
|
|
482
|
+
end = item.get("end")
|
|
483
|
+
try:
|
|
484
|
+
start_value = float(start) if start is not None else 0.0
|
|
485
|
+
except (TypeError, ValueError):
|
|
486
|
+
start_value = 0.0
|
|
487
|
+
try:
|
|
488
|
+
end_value = float(end) if end is not None else start_value
|
|
489
|
+
except (TypeError, ValueError):
|
|
490
|
+
end_value = start_value
|
|
491
|
+
segments.append({"start": start_value, "end": end_value, "text": text})
|
|
492
|
+
return segments
|
|
493
|
+
|
|
494
|
+
def _base_ydl_options(self) -> dict[str, object]:
|
|
495
|
+
options: dict[str, object] = {
|
|
496
|
+
"quiet": True,
|
|
497
|
+
"no_warnings": True,
|
|
498
|
+
"http_headers": _BILIBILI_HTTP_HEADERS,
|
|
499
|
+
"retries": 3,
|
|
500
|
+
"extractor_retries": 3,
|
|
501
|
+
"fragment_retries": 3,
|
|
502
|
+
"sleep_interval_requests": 1,
|
|
503
|
+
"socket_timeout": 30,
|
|
504
|
+
}
|
|
505
|
+
cookie_file = str(
|
|
506
|
+
self._settings.ytdlp_cookies_file
|
|
507
|
+
or os.environ.get("VIDEO_SUM_YTDLP_COOKIES_FILE")
|
|
508
|
+
or os.environ.get("YTDLP_COOKIES_FILE")
|
|
509
|
+
or ""
|
|
510
|
+
).strip()
|
|
511
|
+
if cookie_file:
|
|
512
|
+
cookie_path = Path(cookie_file).expanduser()
|
|
513
|
+
if cookie_path.exists() and cookie_path.is_file():
|
|
514
|
+
options["cookiefile"] = str(cookie_path)
|
|
515
|
+
else:
|
|
516
|
+
logger.warning("yt-dlp cookie file does not exist: %s", cookie_path)
|
|
517
|
+
return options
|
|
518
|
+
|
|
519
|
+
def _raise_ydl_error(self, error: DownloadError) -> NoReturn:
|
|
520
|
+
message = str(error)
|
|
521
|
+
if "Could not copy Chrome cookie database" in message:
|
|
522
|
+
raise VideoSumError(
|
|
523
|
+
"无法读取 Chrome 登录态:yt-dlp 不能复制 Chrome Cookie 数据库。"
|
|
524
|
+
"请通过 BiliSum 的 B 站登录窗口重新捕获登录态,或按教程导出 cookies.txt 并填写 yt-dlp Cookies 文件。"
|
|
525
|
+
) from error
|
|
526
|
+
if "Failed to decrypt with DPAPI" in message:
|
|
527
|
+
raise VideoSumError(
|
|
528
|
+
"无法读取浏览器登录态:yt-dlp 解密 Windows 浏览器 Cookie 失败(DPAPI)。"
|
|
529
|
+
"请通过 BiliSum 的 B 站登录窗口重新捕获登录态,或按教程导出 cookies.txt 并填写 yt-dlp Cookies 文件。"
|
|
530
|
+
) from error
|
|
531
|
+
if "HTTP Error 412" in message and "BiliBili" in message:
|
|
532
|
+
raise VideoSumError(
|
|
533
|
+
"B 站返回 HTTP 412,当前请求可能被风控拦截。请稍后重试、切换网络/IP,"
|
|
534
|
+
"或通过 BiliSum 的 B 站登录窗口捕获登录态;也可以填写 yt-dlp Cookies 文件 "
|
|
535
|
+
"/ VIDEO_SUM_YTDLP_COOKIES_FILE。"
|
|
536
|
+
) from error
|
|
537
|
+
raise VideoSumError(f"Failed to read or download video with yt-dlp: {message}") from error
|
|
538
|
+
|
|
539
|
+
def _probe_video(self, url: str) -> dict:
|
|
540
|
+
try:
|
|
541
|
+
with YoutubeDL(self._base_ydl_options()) as ydl:
|
|
542
|
+
info = ydl.extract_info(url, download=False)
|
|
543
|
+
except DownloadError as exc:
|
|
544
|
+
self._raise_ydl_error(exc)
|
|
545
|
+
if not isinstance(info, dict):
|
|
546
|
+
raise VideoSumError("Failed to probe video metadata.")
|
|
547
|
+
return info
|
|
548
|
+
|
|
549
|
+
def _download_audio(
|
|
550
|
+
self,
|
|
551
|
+
url: str,
|
|
552
|
+
task_dir: Path,
|
|
553
|
+
safe_title: str,
|
|
554
|
+
emit: Callable[[str, int, str, dict[str, object] | None], None],
|
|
555
|
+
) -> Path:
|
|
556
|
+
emit("downloading", 20, "正在连接音频源")
|
|
557
|
+
output_template = str(task_dir / f"{safe_title}.%(ext)s")
|
|
558
|
+
download_progress = {
|
|
559
|
+
"value": 20,
|
|
560
|
+
"last_emit_time": 0.0,
|
|
561
|
+
"last_downloaded_bytes": 0,
|
|
562
|
+
"last_message": "",
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
def progress_hook(data: dict[str, object]) -> None:
|
|
566
|
+
status = str(data.get("status") or "")
|
|
567
|
+
if status == "downloading":
|
|
568
|
+
now = time.monotonic()
|
|
569
|
+
total = data.get("total_bytes") or data.get("total_bytes_estimate")
|
|
570
|
+
downloaded = int(data.get("downloaded_bytes") or 0)
|
|
571
|
+
speed = data.get("speed")
|
|
572
|
+
eta = data.get("eta")
|
|
573
|
+
fragment_index = data.get("fragment_index")
|
|
574
|
+
fragment_count = data.get("fragment_count")
|
|
575
|
+
|
|
576
|
+
if total and downloaded:
|
|
577
|
+
ratio = max(0.0, min(1.0, float(downloaded) / float(total)))
|
|
578
|
+
progress = min(42, 22 + int(ratio * 20))
|
|
579
|
+
message = self._build_download_message(
|
|
580
|
+
downloaded=downloaded,
|
|
581
|
+
total=int(total),
|
|
582
|
+
speed=speed,
|
|
583
|
+
eta=eta,
|
|
584
|
+
fragment_index=fragment_index,
|
|
585
|
+
fragment_count=fragment_count,
|
|
586
|
+
)
|
|
587
|
+
should_emit = (
|
|
588
|
+
progress > download_progress["value"]
|
|
589
|
+
or now - float(download_progress["last_emit_time"]) >= 0.8
|
|
590
|
+
or abs(downloaded - int(download_progress["last_downloaded_bytes"])) >= 2 * 1024 * 1024
|
|
591
|
+
)
|
|
592
|
+
if should_emit:
|
|
593
|
+
download_progress["value"] = progress
|
|
594
|
+
download_progress["last_emit_time"] = now
|
|
595
|
+
download_progress["last_downloaded_bytes"] = downloaded
|
|
596
|
+
download_progress["last_message"] = message
|
|
597
|
+
emit(
|
|
598
|
+
"downloading",
|
|
599
|
+
progress,
|
|
600
|
+
message,
|
|
601
|
+
{
|
|
602
|
+
"downloaded_bytes": downloaded,
|
|
603
|
+
"total_bytes": int(total),
|
|
604
|
+
"speed": int(speed) if speed else None,
|
|
605
|
+
"eta": eta,
|
|
606
|
+
},
|
|
607
|
+
)
|
|
608
|
+
elif downloaded:
|
|
609
|
+
progress = min(38, 22 + min(14, int(math.log10(max(downloaded, 1))) * 2))
|
|
610
|
+
message = self._build_download_message(
|
|
611
|
+
downloaded=downloaded,
|
|
612
|
+
total=None,
|
|
613
|
+
speed=speed,
|
|
614
|
+
eta=eta,
|
|
615
|
+
fragment_index=fragment_index,
|
|
616
|
+
fragment_count=fragment_count,
|
|
617
|
+
)
|
|
618
|
+
should_emit = (
|
|
619
|
+
now - float(download_progress["last_emit_time"]) >= 0.8
|
|
620
|
+
or abs(downloaded - int(download_progress["last_downloaded_bytes"])) >= 2 * 1024 * 1024
|
|
621
|
+
)
|
|
622
|
+
if should_emit:
|
|
623
|
+
download_progress["value"] = max(int(download_progress["value"]), progress)
|
|
624
|
+
download_progress["last_emit_time"] = now
|
|
625
|
+
download_progress["last_downloaded_bytes"] = downloaded
|
|
626
|
+
download_progress["last_message"] = message
|
|
627
|
+
emit(
|
|
628
|
+
"downloading",
|
|
629
|
+
int(download_progress["value"]),
|
|
630
|
+
message,
|
|
631
|
+
{
|
|
632
|
+
"downloaded_bytes": downloaded,
|
|
633
|
+
"speed": int(speed) if speed else None,
|
|
634
|
+
"eta": eta,
|
|
635
|
+
},
|
|
636
|
+
)
|
|
637
|
+
elif status == "finished":
|
|
638
|
+
emit("downloading", 44, "音频下载完成,正在提取音轨")
|
|
639
|
+
|
|
640
|
+
def postprocessor_hook(data: dict[str, object]) -> None:
|
|
641
|
+
status = str(data.get("status") or "")
|
|
642
|
+
postprocessor = str(data.get("postprocessor") or "")
|
|
643
|
+
if status == "started":
|
|
644
|
+
emit("downloading", 46, f"正在执行 {postprocessor or '后处理'}")
|
|
645
|
+
elif status == "processing":
|
|
646
|
+
emit("downloading", 47, f"正在提取 MP3 音频")
|
|
647
|
+
elif status == "finished":
|
|
648
|
+
emit("downloading", 48, "音频提取完成")
|
|
649
|
+
|
|
650
|
+
options = {
|
|
651
|
+
**self._base_ydl_options(),
|
|
652
|
+
"format": "bestaudio/best",
|
|
653
|
+
"outtmpl": output_template,
|
|
654
|
+
"noplaylist": True,
|
|
655
|
+
"progress_hooks": [progress_hook],
|
|
656
|
+
"postprocessor_hooks": [postprocessor_hook],
|
|
657
|
+
"postprocessors": [
|
|
658
|
+
{
|
|
659
|
+
"key": "FFmpegExtractAudio",
|
|
660
|
+
"preferredcodec": "mp3",
|
|
661
|
+
"preferredquality": "192",
|
|
662
|
+
}
|
|
663
|
+
],
|
|
664
|
+
}
|
|
665
|
+
ffmpeg_exe = ffmpeg_location()
|
|
666
|
+
if ffmpeg_exe is not None:
|
|
667
|
+
options["ffmpeg_location"] = str(ffmpeg_exe)
|
|
668
|
+
logger.info("using ffmpeg location: %s", ffmpeg_exe)
|
|
669
|
+
else:
|
|
670
|
+
logger.warning("ffmpeg not found, yt_dlp will use system PATH")
|
|
671
|
+
try:
|
|
672
|
+
with YoutubeDL(options) as ydl:
|
|
673
|
+
ydl.download([url])
|
|
674
|
+
except DownloadError as exc:
|
|
675
|
+
self._raise_ydl_error(exc)
|
|
676
|
+
candidates = sorted(task_dir.glob(f"{safe_title}.*"))
|
|
677
|
+
if not candidates:
|
|
678
|
+
raise VideoSumError("Audio download failed.")
|
|
679
|
+
emit("downloading", 48, "音频文件已就绪")
|
|
680
|
+
return candidates[0]
|
|
681
|
+
|
|
682
|
+
def _build_download_message(
|
|
683
|
+
self,
|
|
684
|
+
downloaded: int,
|
|
685
|
+
total: int | None,
|
|
686
|
+
speed: float | None,
|
|
687
|
+
eta: int | None,
|
|
688
|
+
fragment_index: object | None,
|
|
689
|
+
fragment_count: object | None,
|
|
690
|
+
) -> str:
|
|
691
|
+
parts = [f"已下载 {self._format_bytes(downloaded)}"]
|
|
692
|
+
if total:
|
|
693
|
+
parts.append(f"/ {self._format_bytes(total)}")
|
|
694
|
+
if speed:
|
|
695
|
+
parts.append(f"速度 {self._format_bytes(int(speed))}/s")
|
|
696
|
+
if eta is not None:
|
|
697
|
+
parts.append(f"剩余约 {int(eta)} 秒")
|
|
698
|
+
if fragment_index and fragment_count:
|
|
699
|
+
parts.append(f"分片 {fragment_index}/{fragment_count}")
|
|
700
|
+
return " ".join(parts)
|
|
701
|
+
|
|
702
|
+
def _format_bytes(self, value: int) -> str:
|
|
703
|
+
units = ["B", "KB", "MB", "GB"]
|
|
704
|
+
size = float(max(value, 0))
|
|
705
|
+
unit_index = 0
|
|
706
|
+
while size >= 1024 and unit_index < len(units) - 1:
|
|
707
|
+
size /= 1024
|
|
708
|
+
unit_index += 1
|
|
709
|
+
if unit_index == 0:
|
|
710
|
+
return f"{int(size)}{units[unit_index]}"
|
|
711
|
+
return f"{size:.1f}{units[unit_index]}"
|
|
712
|
+
|
|
713
|
+
def _transcribe(
|
|
714
|
+
self,
|
|
715
|
+
audio_path: Path,
|
|
716
|
+
duration: float | None,
|
|
717
|
+
emit: Callable[[str, int, str, dict[str, object] | None], None],
|
|
718
|
+
) -> tuple[str, list[dict[str, object]]]:
|
|
719
|
+
if self._settings.transcription_provider == "siliconflow":
|
|
720
|
+
return self._transcribe_with_siliconflow(audio_path, duration, emit)
|
|
721
|
+
return self._transcribe_with_local_whisper(audio_path, duration, emit)
|
|
722
|
+
|
|
723
|
+
def _transcribe_with_local_whisper(
|
|
724
|
+
self,
|
|
725
|
+
audio_path: Path,
|
|
726
|
+
duration: float | None,
|
|
727
|
+
emit: Callable[[str, int, str, dict[str, object] | None], None],
|
|
728
|
+
) -> tuple[str, list[dict[str, object]]]:
|
|
729
|
+
if not self._settings.local_asr_available:
|
|
730
|
+
raise TranscriptionConfigurationError(
|
|
731
|
+
"Local ASR is not installed in the current runtime. Install local ASR from Settings or switch to SiliconFlow."
|
|
732
|
+
)
|
|
733
|
+
attempts = [
|
|
734
|
+
{
|
|
735
|
+
"model": self._settings.whisper_model,
|
|
736
|
+
"device": self._settings.whisper_device,
|
|
737
|
+
"compute_type": self._settings.whisper_compute_type,
|
|
738
|
+
"message": f"正在加载转写模型 {self._settings.whisper_model}",
|
|
739
|
+
}
|
|
740
|
+
]
|
|
741
|
+
if self._settings.whisper_device == "cpu" and self._settings.whisper_compute_type != "float32":
|
|
742
|
+
attempts.append(
|
|
743
|
+
{
|
|
744
|
+
"model": self._settings.whisper_model,
|
|
745
|
+
"device": "cpu",
|
|
746
|
+
"compute_type": "float32",
|
|
747
|
+
"message": f"兼容模式重试,正在加载转写模型 {self._settings.whisper_model}",
|
|
748
|
+
}
|
|
749
|
+
)
|
|
750
|
+
|
|
751
|
+
last_error: VideoSumError | None = None
|
|
752
|
+
for index, attempt in enumerate(attempts):
|
|
753
|
+
emit(
|
|
754
|
+
"transcribing",
|
|
755
|
+
52,
|
|
756
|
+
str(attempt["message"]),
|
|
757
|
+
{
|
|
758
|
+
"model": attempt["model"],
|
|
759
|
+
"device": attempt["device"],
|
|
760
|
+
"compute_type": attempt["compute_type"],
|
|
761
|
+
"attempt": index + 1,
|
|
762
|
+
},
|
|
763
|
+
)
|
|
764
|
+
logger.info(
|
|
765
|
+
"launch transcription subprocess attempt=%s model=%s device=%s compute_type=%s audio=%s",
|
|
766
|
+
index + 1,
|
|
767
|
+
attempt["model"],
|
|
768
|
+
attempt["device"],
|
|
769
|
+
attempt["compute_type"],
|
|
770
|
+
audio_path,
|
|
771
|
+
)
|
|
772
|
+
emit("transcribing", 58, "开始转写音频内容")
|
|
773
|
+
try:
|
|
774
|
+
transcript, segments = self._run_transcription_subprocess(
|
|
775
|
+
audio_path=audio_path,
|
|
776
|
+
duration=duration,
|
|
777
|
+
emit=emit,
|
|
778
|
+
model_name=str(attempt["model"]),
|
|
779
|
+
device=str(attempt["device"]),
|
|
780
|
+
compute_type=str(attempt["compute_type"]),
|
|
781
|
+
)
|
|
782
|
+
logger.info(
|
|
783
|
+
"transcription finished audio=%s segments=%d transcript_chars=%d attempt=%s",
|
|
784
|
+
audio_path,
|
|
785
|
+
len(segments),
|
|
786
|
+
len(transcript),
|
|
787
|
+
index + 1,
|
|
788
|
+
)
|
|
789
|
+
return transcript, segments
|
|
790
|
+
except VideoSumError as exc:
|
|
791
|
+
last_error = exc
|
|
792
|
+
is_last_attempt = index == len(attempts) - 1
|
|
793
|
+
if is_last_attempt or not self._should_retry_transcription(exc):
|
|
794
|
+
raise
|
|
795
|
+
emit("transcribing", 56, "转写引擎异常,正在切换兼容模式重试")
|
|
796
|
+
|
|
797
|
+
raise last_error or VideoSumError("Transcription failed.")
|
|
798
|
+
|
|
799
|
+
def _transcribe_with_siliconflow(
|
|
800
|
+
self,
|
|
801
|
+
audio_path: Path,
|
|
802
|
+
duration: float | None,
|
|
803
|
+
emit: Callable[[str, int, str, dict[str, object] | None], None],
|
|
804
|
+
) -> tuple[str, list[dict[str, object]]]:
|
|
805
|
+
base_url = (self._settings.siliconflow_asr_base_url or "").rstrip("/")
|
|
806
|
+
model_name = (self._settings.siliconflow_asr_model or "").strip()
|
|
807
|
+
api_key = (self._settings.siliconflow_asr_api_key or "").strip()
|
|
808
|
+
if not base_url or not model_name:
|
|
809
|
+
raise TranscriptionConfigurationError("SiliconFlow ASR settings are incomplete.")
|
|
810
|
+
if not api_key:
|
|
811
|
+
raise TranscriptionConfigurationError("SiliconFlow ASR API key is not configured.")
|
|
812
|
+
|
|
813
|
+
request_url = f"{base_url}/audio/transcriptions"
|
|
814
|
+
emit(
|
|
815
|
+
"transcribing",
|
|
816
|
+
52,
|
|
817
|
+
f"正在连接语音识别服务 {model_name}",
|
|
818
|
+
{"provider": "siliconflow", "model": model_name},
|
|
819
|
+
)
|
|
820
|
+
emit(
|
|
821
|
+
"transcribing",
|
|
822
|
+
58,
|
|
823
|
+
"正在上传音频到硅基流动语音识别服务",
|
|
824
|
+
{"provider": "siliconflow", "audio_name": audio_path.name},
|
|
825
|
+
)
|
|
826
|
+
|
|
827
|
+
headers = {"Authorization": f"Bearer {api_key}"}
|
|
828
|
+
timeout = httpx.Timeout(connect=30.0, read=600.0, write=600.0, pool=30.0)
|
|
829
|
+
try:
|
|
830
|
+
with audio_path.open("rb") as audio_handle, httpx.Client(timeout=timeout) as client:
|
|
831
|
+
response = client.post(
|
|
832
|
+
request_url,
|
|
833
|
+
headers=headers,
|
|
834
|
+
data={"model": model_name},
|
|
835
|
+
files={"file": (audio_path.name, audio_handle, "audio/mpeg")},
|
|
836
|
+
)
|
|
837
|
+
except httpx.HTTPError as exc:
|
|
838
|
+
raise VideoSumError(f"SiliconFlow ASR request failed: {exc}") from exc
|
|
839
|
+
|
|
840
|
+
if response.status_code in {401, 403}:
|
|
841
|
+
detail = _extract_response_error_detail(response)
|
|
842
|
+
raise TranscriptionAuthenticationError(f"SiliconFlow ASR authentication failed: {detail}")
|
|
843
|
+
if response.status_code >= 400:
|
|
844
|
+
detail = _extract_response_error_detail(response)
|
|
845
|
+
raise VideoSumError(f"SiliconFlow ASR request failed: {detail}")
|
|
846
|
+
|
|
847
|
+
payload = response.json()
|
|
848
|
+
transcript = str(payload.get("text") or "").strip() if isinstance(payload, dict) else ""
|
|
849
|
+
if not transcript:
|
|
850
|
+
raise VideoSumError("SiliconFlow ASR returned empty transcript text.")
|
|
851
|
+
|
|
852
|
+
emit(
|
|
853
|
+
"transcribing",
|
|
854
|
+
78,
|
|
855
|
+
"云端语音识别完成,正在整理文本结构",
|
|
856
|
+
{"provider": "siliconflow", "transcript_chars": len(transcript)},
|
|
857
|
+
)
|
|
858
|
+
segments = self._build_fallback_segments_from_transcript(transcript, duration)
|
|
859
|
+
emit(
|
|
860
|
+
"transcribing",
|
|
861
|
+
84,
|
|
862
|
+
f"转写完成,共整理 {len(segments)} 段",
|
|
863
|
+
{"provider": "siliconflow", "segment_count": len(segments)},
|
|
864
|
+
)
|
|
865
|
+
return self._render_transcript_from_segments(segments), segments
|
|
866
|
+
|
|
867
|
+
def _build_fallback_segments_from_transcript(
|
|
868
|
+
self,
|
|
869
|
+
transcript: str,
|
|
870
|
+
duration: float | None,
|
|
871
|
+
) -> list[dict[str, object]]:
|
|
872
|
+
normalized = re.sub(r"\s+", " ", str(transcript or "")).strip()
|
|
873
|
+
if not normalized:
|
|
874
|
+
return []
|
|
875
|
+
|
|
876
|
+
chunks = self._split_transcript_into_timed_chunks(normalized)
|
|
877
|
+
|
|
878
|
+
total_chars = sum(len(chunk) for chunk in chunks) or len(normalized)
|
|
879
|
+
effective_duration = max(float(duration or 0.0), float(len(chunks) * 6))
|
|
880
|
+
current = 0.0
|
|
881
|
+
segments: list[dict[str, object]] = []
|
|
882
|
+
for index, chunk in enumerate(chunks):
|
|
883
|
+
weight = len(chunk) / total_chars if total_chars > 0 else 1 / len(chunks)
|
|
884
|
+
start = current
|
|
885
|
+
if index == len(chunks) - 1:
|
|
886
|
+
end = effective_duration
|
|
887
|
+
else:
|
|
888
|
+
end = min(effective_duration, current + max(2.0, effective_duration * weight))
|
|
889
|
+
segments.append({"start": round(start, 3), "end": round(end, 3), "text": chunk})
|
|
890
|
+
current = end
|
|
891
|
+
return segments
|
|
892
|
+
|
|
893
|
+
def _split_transcript_into_timed_chunks(self, transcript: str) -> list[str]:
|
|
894
|
+
text = str(transcript or "").strip()
|
|
895
|
+
if not text:
|
|
896
|
+
return []
|
|
897
|
+
|
|
898
|
+
sentence_parts = [
|
|
899
|
+
part.strip()
|
|
900
|
+
for part in re.split(r"(?<=[。!?!?;;::,,、])", text)
|
|
901
|
+
if part and part.strip()
|
|
902
|
+
]
|
|
903
|
+
if not sentence_parts:
|
|
904
|
+
sentence_parts = [text]
|
|
905
|
+
|
|
906
|
+
soft_target_chars = 56
|
|
907
|
+
hard_limit_chars = 88
|
|
908
|
+
chunks: list[str] = []
|
|
909
|
+
buffer = ""
|
|
910
|
+
|
|
911
|
+
def flush_buffer() -> None:
|
|
912
|
+
nonlocal buffer
|
|
913
|
+
if buffer.strip():
|
|
914
|
+
chunks.append(buffer.strip())
|
|
915
|
+
buffer = ""
|
|
916
|
+
|
|
917
|
+
for part in sentence_parts:
|
|
918
|
+
candidate = f"{buffer}{part}".strip()
|
|
919
|
+
if buffer and len(candidate) > soft_target_chars:
|
|
920
|
+
flush_buffer()
|
|
921
|
+
candidate = part.strip()
|
|
922
|
+
|
|
923
|
+
while len(candidate) > hard_limit_chars:
|
|
924
|
+
split_index = max(
|
|
925
|
+
candidate.rfind(marker, 0, hard_limit_chars)
|
|
926
|
+
for marker in (",", "、", ",", ";", ";", ":", ":", " ")
|
|
927
|
+
)
|
|
928
|
+
if split_index < 24:
|
|
929
|
+
split_index = hard_limit_chars
|
|
930
|
+
head = candidate[:split_index].strip(" ,、;;::")
|
|
931
|
+
if head:
|
|
932
|
+
chunks.append(head)
|
|
933
|
+
candidate = candidate[split_index:].strip()
|
|
934
|
+
|
|
935
|
+
buffer = candidate
|
|
936
|
+
|
|
937
|
+
flush_buffer()
|
|
938
|
+
return chunks or [text]
|
|
939
|
+
|
|
940
|
+
def _render_transcript_from_segments(self, segments: list[dict[str, object]]) -> str:
|
|
941
|
+
lines = [
|
|
942
|
+
f"[{self._format_seconds(float(segment.get('start') or 0))}] {str(segment.get('text') or '').strip()}"
|
|
943
|
+
for segment in segments
|
|
944
|
+
if str(segment.get("text") or "").strip()
|
|
945
|
+
]
|
|
946
|
+
transcript = "\n".join(lines).strip()
|
|
947
|
+
if transcript:
|
|
948
|
+
return transcript
|
|
949
|
+
return "\n".join(str(segment.get("text") or "").strip() for segment in segments if str(segment.get("text") or "").strip())
|
|
950
|
+
|
|
951
|
+
def _run_transcription_subprocess(
|
|
952
|
+
self,
|
|
953
|
+
audio_path: Path,
|
|
954
|
+
duration: float | None,
|
|
955
|
+
emit: Callable[[str, int, str, dict[str, object] | None], None],
|
|
956
|
+
model_name: str,
|
|
957
|
+
device: str,
|
|
958
|
+
compute_type: str,
|
|
959
|
+
) -> tuple[str, list[dict[str, object]]]:
|
|
960
|
+
progress_path = audio_path.with_name("transcription_worker_progress.jsonl")
|
|
961
|
+
output_path = audio_path.with_name("transcription_worker_result.json")
|
|
962
|
+
if progress_path.exists():
|
|
963
|
+
progress_path.unlink()
|
|
964
|
+
if output_path.exists():
|
|
965
|
+
output_path.unlink()
|
|
966
|
+
|
|
967
|
+
command = self._build_transcription_command(
|
|
968
|
+
audio_path=audio_path,
|
|
969
|
+
model_name=model_name,
|
|
970
|
+
device=device,
|
|
971
|
+
compute_type=compute_type,
|
|
972
|
+
progress_path=progress_path,
|
|
973
|
+
output_path=output_path,
|
|
974
|
+
)
|
|
975
|
+
if duration is not None:
|
|
976
|
+
command.extend(["--duration", str(duration)])
|
|
977
|
+
|
|
978
|
+
env = os.environ.copy()
|
|
979
|
+
env.setdefault("PYTHONIOENCODING", "utf-8")
|
|
980
|
+
env.setdefault("PYTHONUTF8", "1")
|
|
981
|
+
runtime_paths = [str(path) for path in runtime_library_dirs(self._settings.runtime_channel)]
|
|
982
|
+
ffmpeg_dir = ffmpeg_location()
|
|
983
|
+
if ffmpeg_dir is not None:
|
|
984
|
+
runtime_paths.append(str(ffmpeg_dir))
|
|
985
|
+
env["VIDEO_SUM_DLL_PATHS"] = os.pathsep.join(runtime_paths)
|
|
986
|
+
merged_path: list[str] = []
|
|
987
|
+
for entry in [*runtime_paths, *(env.get("PATH", "").split(os.pathsep))]:
|
|
988
|
+
item = entry.strip()
|
|
989
|
+
if item and item not in merged_path:
|
|
990
|
+
merged_path.append(item)
|
|
991
|
+
env["PATH"] = os.pathsep.join(merged_path)
|
|
992
|
+
with sanitized_subprocess_dll_search():
|
|
993
|
+
process = subprocess.Popen(
|
|
994
|
+
command,
|
|
995
|
+
stdout=subprocess.PIPE,
|
|
996
|
+
stderr=subprocess.PIPE,
|
|
997
|
+
text=True,
|
|
998
|
+
encoding="utf-8",
|
|
999
|
+
errors="replace",
|
|
1000
|
+
env=env,
|
|
1001
|
+
cwd=str(audio_path.parent),
|
|
1002
|
+
**_windows_hidden_subprocess_kwargs(),
|
|
1003
|
+
)
|
|
1004
|
+
|
|
1005
|
+
progress_offset = 0
|
|
1006
|
+
timeout_seconds = max(30 * 60, int((duration or 0) * 8) + 10 * 60)
|
|
1007
|
+
deadline = time.monotonic() + timeout_seconds
|
|
1008
|
+
while process.poll() is None:
|
|
1009
|
+
progress_offset = self._replay_transcription_progress(progress_path, progress_offset, emit)
|
|
1010
|
+
if time.monotonic() > deadline:
|
|
1011
|
+
process.kill()
|
|
1012
|
+
stdout, stderr = process.communicate()
|
|
1013
|
+
logger.error(
|
|
1014
|
+
"transcription subprocess timeout audio=%s model=%s device=%s compute_type=%s stdout=%s stderr=%s",
|
|
1015
|
+
audio_path,
|
|
1016
|
+
model_name,
|
|
1017
|
+
device,
|
|
1018
|
+
compute_type,
|
|
1019
|
+
stdout.strip(),
|
|
1020
|
+
stderr.strip(),
|
|
1021
|
+
)
|
|
1022
|
+
raise VideoSumError("Transcription subprocess timed out.")
|
|
1023
|
+
time.sleep(0.2)
|
|
1024
|
+
|
|
1025
|
+
progress_offset = self._replay_transcription_progress(progress_path, progress_offset, emit)
|
|
1026
|
+
stdout, stderr = process.communicate()
|
|
1027
|
+
if process.returncode != 0:
|
|
1028
|
+
# 检查是否是 CUDA 清理时的访问冲突,但输出文件已成功写入
|
|
1029
|
+
is_native_crash = self._is_native_crash_returncode(process.returncode)
|
|
1030
|
+
output_valid = output_path.exists()
|
|
1031
|
+
if output_valid:
|
|
1032
|
+
try:
|
|
1033
|
+
payload = json.loads(output_path.read_text(encoding="utf-8"))
|
|
1034
|
+
transcript = str(payload.get("transcript") or "")
|
|
1035
|
+
segments = list(payload.get("segments") or [])
|
|
1036
|
+
if transcript.strip() and segments:
|
|
1037
|
+
# 输出有效,接受结果,记录警告但不抛出异常
|
|
1038
|
+
logger.warning(
|
|
1039
|
+
"transcription subprocess crashed during cleanup but output is valid "
|
|
1040
|
+
"audio=%s model=%s device=%s compute_type=%s returncode=%s segments=%d transcript_chars=%d",
|
|
1041
|
+
audio_path,
|
|
1042
|
+
model_name,
|
|
1043
|
+
device,
|
|
1044
|
+
compute_type,
|
|
1045
|
+
process.returncode,
|
|
1046
|
+
len(segments),
|
|
1047
|
+
len(transcript),
|
|
1048
|
+
)
|
|
1049
|
+
return transcript, segments
|
|
1050
|
+
except (json.JSONDecodeError, OSError):
|
|
1051
|
+
pass # 输出文件损坏,继续抛出异常
|
|
1052
|
+
|
|
1053
|
+
logger.error(
|
|
1054
|
+
"transcription subprocess failed audio=%s model=%s device=%s compute_type=%s returncode=%s stdout=%s stderr=%s",
|
|
1055
|
+
audio_path,
|
|
1056
|
+
model_name,
|
|
1057
|
+
device,
|
|
1058
|
+
compute_type,
|
|
1059
|
+
process.returncode,
|
|
1060
|
+
stdout.strip(),
|
|
1061
|
+
stderr.strip(),
|
|
1062
|
+
)
|
|
1063
|
+
native_hint = ""
|
|
1064
|
+
if is_native_crash:
|
|
1065
|
+
native_hint = " The transcription runtime crashed at the native library level."
|
|
1066
|
+
raise VideoSumError(
|
|
1067
|
+
f"Transcription subprocess failed with exit code {process.returncode}."
|
|
1068
|
+
f"{native_hint} Runtime={device}/{compute_type} model={model_name}."
|
|
1069
|
+
)
|
|
1070
|
+
if stderr.strip():
|
|
1071
|
+
logger.info("transcription subprocess stderr audio=%s stderr=%s", audio_path, stderr.strip())
|
|
1072
|
+
if not output_path.exists():
|
|
1073
|
+
raise VideoSumError("Transcription subprocess completed without output.")
|
|
1074
|
+
|
|
1075
|
+
payload = json.loads(output_path.read_text(encoding="utf-8"))
|
|
1076
|
+
transcript = str(payload.get("transcript") or "")
|
|
1077
|
+
segments = list(payload.get("segments") or [])
|
|
1078
|
+
if not transcript.strip():
|
|
1079
|
+
raise VideoSumError("Transcription produced empty output.")
|
|
1080
|
+
return transcript, segments
|
|
1081
|
+
|
|
1082
|
+
def _build_transcription_command(
|
|
1083
|
+
self,
|
|
1084
|
+
audio_path: Path,
|
|
1085
|
+
model_name: str,
|
|
1086
|
+
device: str,
|
|
1087
|
+
compute_type: str,
|
|
1088
|
+
progress_path: Path,
|
|
1089
|
+
output_path: Path,
|
|
1090
|
+
) -> list[str]:
|
|
1091
|
+
runtime_python = runtime_python_executable(self._settings.runtime_channel)
|
|
1092
|
+
if runtime_python is None:
|
|
1093
|
+
raise VideoSumError(
|
|
1094
|
+
f"Managed runtime python is unavailable for channel '{self._settings.runtime_channel}'."
|
|
1095
|
+
)
|
|
1096
|
+
command = [str(runtime_python), "-m", "video_sum_core.transcribe_subprocess"]
|
|
1097
|
+
|
|
1098
|
+
command.extend(
|
|
1099
|
+
[
|
|
1100
|
+
"--audio-path",
|
|
1101
|
+
str(audio_path),
|
|
1102
|
+
"--model",
|
|
1103
|
+
model_name,
|
|
1104
|
+
"--device",
|
|
1105
|
+
device,
|
|
1106
|
+
"--compute-type",
|
|
1107
|
+
compute_type,
|
|
1108
|
+
"--progress-path",
|
|
1109
|
+
str(progress_path),
|
|
1110
|
+
"--output-path",
|
|
1111
|
+
str(output_path),
|
|
1112
|
+
]
|
|
1113
|
+
)
|
|
1114
|
+
return command
|
|
1115
|
+
|
|
1116
|
+
def _should_retry_transcription(self, error: VideoSumError) -> bool:
|
|
1117
|
+
message = str(error)
|
|
1118
|
+
return "native library level" in message or "exit code 3221226505" in message
|
|
1119
|
+
|
|
1120
|
+
def _is_native_crash_returncode(self, returncode: int) -> bool:
|
|
1121
|
+
return returncode in {3221226505, -1073740791}
|
|
1122
|
+
|
|
1123
|
+
def _replay_transcription_progress(
|
|
1124
|
+
self,
|
|
1125
|
+
progress_path: Path,
|
|
1126
|
+
offset: int,
|
|
1127
|
+
emit: Callable[[str, int, str, dict[str, object] | None], None],
|
|
1128
|
+
) -> int:
|
|
1129
|
+
if not progress_path.exists():
|
|
1130
|
+
return offset
|
|
1131
|
+
with progress_path.open("r", encoding="utf-8") as handle:
|
|
1132
|
+
handle.seek(offset)
|
|
1133
|
+
for line in handle:
|
|
1134
|
+
raw = line.strip()
|
|
1135
|
+
if not raw:
|
|
1136
|
+
continue
|
|
1137
|
+
event = json.loads(raw)
|
|
1138
|
+
emit(
|
|
1139
|
+
str(event.get("stage") or "transcribing"),
|
|
1140
|
+
int(event.get("progress") or 0),
|
|
1141
|
+
str(event.get("message") or ""),
|
|
1142
|
+
event.get("payload") or {},
|
|
1143
|
+
)
|
|
1144
|
+
return handle.tell()
|
|
1145
|
+
|
|
1146
|
+
def _summarize(
|
|
1147
|
+
self,
|
|
1148
|
+
transcript: str,
|
|
1149
|
+
segments: list[dict[str, object]],
|
|
1150
|
+
title: str,
|
|
1151
|
+
emit: Callable[[str, int, str, dict[str, object] | None], None],
|
|
1152
|
+
source_kind: str | None = None,
|
|
1153
|
+
) -> dict[str, object]:
|
|
1154
|
+
emit(
|
|
1155
|
+
"summarizing",
|
|
1156
|
+
88,
|
|
1157
|
+
self._build_summary_start_message(),
|
|
1158
|
+
{"llm_enabled": self._settings.llm_enabled and bool(self._settings.llm_api_key)},
|
|
1159
|
+
)
|
|
1160
|
+
used_llm_summary = False
|
|
1161
|
+
if self._settings.llm_enabled and self._settings.llm_api_key:
|
|
1162
|
+
emit("summarizing", 91, f"正在请求 LLM:{self._settings.llm_model or '未命名模型'}")
|
|
1163
|
+
logger.info(
|
|
1164
|
+
"llm summary request model=%s base_url=%s transcript_chars=%d segments=%d",
|
|
1165
|
+
self._settings.llm_model,
|
|
1166
|
+
self._settings.llm_base_url,
|
|
1167
|
+
len(transcript),
|
|
1168
|
+
len(segments),
|
|
1169
|
+
)
|
|
1170
|
+
try:
|
|
1171
|
+
summary = self._summarize_with_llm(
|
|
1172
|
+
transcript,
|
|
1173
|
+
segments,
|
|
1174
|
+
title,
|
|
1175
|
+
emit,
|
|
1176
|
+
source_kind=source_kind,
|
|
1177
|
+
)
|
|
1178
|
+
used_llm_summary = True
|
|
1179
|
+
except (LLMAuthenticationError, LLMConfigurationError) as exc:
|
|
1180
|
+
logger.warning("llm unavailable, fallback to rule summary reason=%s", exc)
|
|
1181
|
+
emit(
|
|
1182
|
+
"summarizing",
|
|
1183
|
+
91,
|
|
1184
|
+
f"LLM 不可用,已切换为本地规则摘要:{exc}",
|
|
1185
|
+
{"fallback": "rules", "reason": str(exc)},
|
|
1186
|
+
)
|
|
1187
|
+
summary = self._summarize_with_rules(transcript, segments, title)
|
|
1188
|
+
else:
|
|
1189
|
+
emit("summarizing", 91, "未启用 LLM,使用本地规则摘要")
|
|
1190
|
+
logger.info("rule summary start transcript_chars=%d segments=%d", len(transcript), len(segments))
|
|
1191
|
+
summary = self._summarize_with_rules(transcript, segments, title)
|
|
1192
|
+
summary = self._normalize_summary(summary, transcript, segments, title)
|
|
1193
|
+
emit(
|
|
1194
|
+
"summarizing",
|
|
1195
|
+
95,
|
|
1196
|
+
"知识卡片摘要生成完成",
|
|
1197
|
+
{
|
|
1198
|
+
"result": self._build_task_result(transcript, summary).model_dump(mode="json"),
|
|
1199
|
+
"result_scope": "knowledge_cards",
|
|
1200
|
+
},
|
|
1201
|
+
)
|
|
1202
|
+
if used_llm_summary:
|
|
1203
|
+
emit("summarizing", 96, "正在生成知识笔记")
|
|
1204
|
+
try:
|
|
1205
|
+
note_payload = self._generate_knowledge_note_with_llm(
|
|
1206
|
+
transcript=transcript,
|
|
1207
|
+
segments=segments,
|
|
1208
|
+
title=title,
|
|
1209
|
+
summary=summary,
|
|
1210
|
+
source_kind=source_kind,
|
|
1211
|
+
)
|
|
1212
|
+
knowledge_note_markdown = str(note_payload.get("knowledgeNoteMarkdown") or "").strip()
|
|
1213
|
+
if knowledge_note_markdown:
|
|
1214
|
+
summary["knowledgeNoteMarkdown"] = knowledge_note_markdown
|
|
1215
|
+
summary["llm_prompt_tokens"] = (_safe_int(summary.get("llm_prompt_tokens")) or 0) + (
|
|
1216
|
+
_safe_int(note_payload.get("llm_prompt_tokens")) or 0
|
|
1217
|
+
)
|
|
1218
|
+
summary["llm_completion_tokens"] = (_safe_int(summary.get("llm_completion_tokens")) or 0) + (
|
|
1219
|
+
_safe_int(note_payload.get("llm_completion_tokens")) or 0
|
|
1220
|
+
)
|
|
1221
|
+
summary["llm_total_tokens"] = (_safe_int(summary.get("llm_total_tokens")) or 0) + (
|
|
1222
|
+
_safe_int(note_payload.get("llm_total_tokens")) or 0
|
|
1223
|
+
)
|
|
1224
|
+
except VideoSumError as exc:
|
|
1225
|
+
logger.warning("knowledge note llm generation failed, fallback to local note builder error=%s", exc)
|
|
1226
|
+
emit(
|
|
1227
|
+
"summarizing",
|
|
1228
|
+
97,
|
|
1229
|
+
f"知识笔记生成失败,已回退为本地笔记:{exc}",
|
|
1230
|
+
{"fallback": "knowledge_note_rules", "reason": str(exc)},
|
|
1231
|
+
)
|
|
1232
|
+
else:
|
|
1233
|
+
emit(
|
|
1234
|
+
"summarizing",
|
|
1235
|
+
98,
|
|
1236
|
+
"知识笔记生成完成",
|
|
1237
|
+
{
|
|
1238
|
+
"result": self._build_task_result(transcript, summary).model_dump(mode="json"),
|
|
1239
|
+
"result_scope": "knowledge_note",
|
|
1240
|
+
},
|
|
1241
|
+
)
|
|
1242
|
+
emit("summarizing", 99, "结果整理完成")
|
|
1243
|
+
logger.info(
|
|
1244
|
+
"summary finished title=%s bullet_points=%d chapters=%d overview_chars=%d",
|
|
1245
|
+
title,
|
|
1246
|
+
len(summary.get("bulletPoints", [])),
|
|
1247
|
+
len(summary.get("chapters", [])),
|
|
1248
|
+
len(str(summary.get("overview") or "")),
|
|
1249
|
+
)
|
|
1250
|
+
return summary
|
|
1251
|
+
|
|
1252
|
+
def _build_summary_start_message(self) -> str:
|
|
1253
|
+
if self._settings.llm_enabled and self._settings.llm_api_key:
|
|
1254
|
+
return "开始生成 LLM 摘要"
|
|
1255
|
+
return "开始生成本地规则摘要"
|
|
1256
|
+
|
|
1257
|
+
def _summarize_with_llm(
|
|
1258
|
+
self,
|
|
1259
|
+
transcript: str,
|
|
1260
|
+
segments: list[dict[str, object]],
|
|
1261
|
+
title: str,
|
|
1262
|
+
emit: Callable[[str, int, str, dict[str, object] | None], None],
|
|
1263
|
+
source_kind: str | None = None,
|
|
1264
|
+
) -> dict[str, object]:
|
|
1265
|
+
base_url = (self._settings.llm_base_url or "").rstrip("/")
|
|
1266
|
+
if not base_url or not self._settings.llm_model:
|
|
1267
|
+
raise LLMConfigurationError("LLM 配置不完整,请检查 Base URL 和模型名。")
|
|
1268
|
+
if source_kind == "aggregate_series":
|
|
1269
|
+
return self._summarize_aggregate_series_with_llm(base_url, transcript, segments, title, emit)
|
|
1270
|
+
chunks = self._build_summary_chunks(segments)
|
|
1271
|
+
if not chunks:
|
|
1272
|
+
chunks = [
|
|
1273
|
+
{
|
|
1274
|
+
"index": 1,
|
|
1275
|
+
"transcript": transcript,
|
|
1276
|
+
"segments_json": json.dumps(segments, ensure_ascii=False),
|
|
1277
|
+
}
|
|
1278
|
+
]
|
|
1279
|
+
logger.info(
|
|
1280
|
+
"llm summary chunk plan model=%s chunks=%d target_chars=%d overlap_segments=%d",
|
|
1281
|
+
self._settings.llm_model,
|
|
1282
|
+
len(chunks),
|
|
1283
|
+
self._settings.summary_chunk_target_chars,
|
|
1284
|
+
self._settings.summary_chunk_overlap_segments,
|
|
1285
|
+
)
|
|
1286
|
+
|
|
1287
|
+
partial_summaries: list[dict[str, object]] = []
|
|
1288
|
+
chunk_count = len(chunks)
|
|
1289
|
+
concurrency = max(1, int(self._settings.summary_chunk_concurrency))
|
|
1290
|
+
retry_count = max(0, int(self._settings.summary_chunk_retry_count))
|
|
1291
|
+
emit(
|
|
1292
|
+
"summarizing",
|
|
1293
|
+
91,
|
|
1294
|
+
f"正在并发汇总 {chunk_count} 个内容块",
|
|
1295
|
+
{"chunk_count": chunk_count, "concurrency": concurrency},
|
|
1296
|
+
)
|
|
1297
|
+
completed = 0
|
|
1298
|
+
failures: list[str] = []
|
|
1299
|
+
with ThreadPoolExecutor(max_workers=concurrency) as executor:
|
|
1300
|
+
future_map = {
|
|
1301
|
+
executor.submit(self._request_llm_summary_chunk, base_url, title, chunk, chunk_count, retry_count): chunk
|
|
1302
|
+
for chunk in chunks
|
|
1303
|
+
}
|
|
1304
|
+
for future in as_completed(future_map):
|
|
1305
|
+
chunk = future_map[future]
|
|
1306
|
+
chunk_index = int(chunk["index"])
|
|
1307
|
+
try:
|
|
1308
|
+
partial = future.result()
|
|
1309
|
+
partial_summaries.append(partial)
|
|
1310
|
+
completed += 1
|
|
1311
|
+
progress = min(94, 91 + max(0, math.floor(completed * 3 / max(1, chunk_count))))
|
|
1312
|
+
emit(
|
|
1313
|
+
"summarizing",
|
|
1314
|
+
progress,
|
|
1315
|
+
f"已完成第 {chunk_index}/{chunk_count} 个内容块",
|
|
1316
|
+
{"chunk_index": chunk_index, "chunk_count": chunk_count, "completed_chunks": completed},
|
|
1317
|
+
)
|
|
1318
|
+
except VideoSumError as exc:
|
|
1319
|
+
failures.append(f"chunk={chunk_index}: {exc}")
|
|
1320
|
+
completed += 1
|
|
1321
|
+
logger.warning(
|
|
1322
|
+
"llm summary chunk skipped after retries model=%s chunk=%d/%d error=%s",
|
|
1323
|
+
self._settings.llm_model,
|
|
1324
|
+
chunk_index,
|
|
1325
|
+
chunk_count,
|
|
1326
|
+
exc,
|
|
1327
|
+
)
|
|
1328
|
+
emit(
|
|
1329
|
+
"summarizing",
|
|
1330
|
+
min(94, 91 + max(0, math.floor(completed * 3 / max(1, chunk_count)))),
|
|
1331
|
+
f"第 {chunk_index}/{chunk_count} 个内容块失败,已跳过继续",
|
|
1332
|
+
{"chunk_index": chunk_index, "chunk_count": chunk_count, "error": str(exc)},
|
|
1333
|
+
)
|
|
1334
|
+
|
|
1335
|
+
partial_summaries.sort(key=lambda item: int(item.get("chunk_index") or 0))
|
|
1336
|
+
if not partial_summaries:
|
|
1337
|
+
raise VideoSumError("All LLM summary chunks failed.")
|
|
1338
|
+
|
|
1339
|
+
total_prompt_tokens = sum((_safe_int(item.get("llm_prompt_tokens")) or 0) for item in partial_summaries)
|
|
1340
|
+
total_completion_tokens = sum((_safe_int(item.get("llm_completion_tokens")) or 0) for item in partial_summaries)
|
|
1341
|
+
total_tokens = sum((_safe_int(item.get("llm_total_tokens")) or 0) for item in partial_summaries)
|
|
1342
|
+
|
|
1343
|
+
emit(
|
|
1344
|
+
"summarizing",
|
|
1345
|
+
94,
|
|
1346
|
+
"正在合并分块摘要",
|
|
1347
|
+
{"chunk_count": chunk_count},
|
|
1348
|
+
)
|
|
1349
|
+
merged_chapters = self._merge_partial_chapters(partial_summaries, segments)
|
|
1350
|
+
aggregate_transcript, aggregate_segments = self._build_aggregate_summary_inputs(partial_summaries, merged_chapters)
|
|
1351
|
+
logger.info(
|
|
1352
|
+
"llm summary aggregate request model=%s chunk_count=%d aggregate_transcript_chars=%d aggregate_segments_chars=%d merged_chapters=%d",
|
|
1353
|
+
self._settings.llm_model,
|
|
1354
|
+
chunk_count,
|
|
1355
|
+
len(aggregate_transcript),
|
|
1356
|
+
len(aggregate_segments),
|
|
1357
|
+
len(merged_chapters),
|
|
1358
|
+
)
|
|
1359
|
+
merged = self._request_llm_json(
|
|
1360
|
+
base_url=base_url,
|
|
1361
|
+
payload=self._build_llm_summary_payload(
|
|
1362
|
+
title=title,
|
|
1363
|
+
transcript_excerpt=aggregate_transcript,
|
|
1364
|
+
segments_excerpt=aggregate_segments,
|
|
1365
|
+
),
|
|
1366
|
+
)
|
|
1367
|
+
merged = self._merge_structured_summary(
|
|
1368
|
+
merged=merged,
|
|
1369
|
+
partial_summaries=partial_summaries,
|
|
1370
|
+
merged_chapters=merged_chapters,
|
|
1371
|
+
transcript=transcript,
|
|
1372
|
+
segments=segments,
|
|
1373
|
+
title=title,
|
|
1374
|
+
)
|
|
1375
|
+
if failures:
|
|
1376
|
+
merged.setdefault("overview", "")
|
|
1377
|
+
merged["overview"] = f"{merged['overview']}\n\n注意:有 {len(failures)} 个分块摘要失败,最终结果基于成功分块汇总。".strip()
|
|
1378
|
+
merged["llm_prompt_tokens"] = total_prompt_tokens + (_safe_int(merged.get("llm_prompt_tokens")) or 0)
|
|
1379
|
+
merged["llm_completion_tokens"] = total_completion_tokens + (_safe_int(merged.get("llm_completion_tokens")) or 0)
|
|
1380
|
+
merged["llm_total_tokens"] = total_tokens + (_safe_int(merged.get("llm_total_tokens")) or 0)
|
|
1381
|
+
return merged
|
|
1382
|
+
|
|
1383
|
+
def _summarize_aggregate_series_with_llm(
|
|
1384
|
+
self,
|
|
1385
|
+
base_url: str,
|
|
1386
|
+
transcript: str,
|
|
1387
|
+
segments: list[dict[str, object]],
|
|
1388
|
+
title: str,
|
|
1389
|
+
emit: Callable[[str, int, str, dict[str, object] | None], None],
|
|
1390
|
+
) -> dict[str, object]:
|
|
1391
|
+
page_count = len({int(float(item.get("start") or 0)) for item in segments if item.get("start") is not None})
|
|
1392
|
+
transcript_excerpt = self._build_aggregate_series_excerpt(transcript)
|
|
1393
|
+
segments_excerpt = self._build_aggregate_series_segments_excerpt(segments)
|
|
1394
|
+
emit(
|
|
1395
|
+
"summarizing",
|
|
1396
|
+
93,
|
|
1397
|
+
"正在生成全集总结",
|
|
1398
|
+
{"page_count": page_count, "input_chars": len(transcript_excerpt)},
|
|
1399
|
+
)
|
|
1400
|
+
logger.info(
|
|
1401
|
+
"llm aggregate series request model=%s pages=%d transcript_chars=%d segments_chars=%d",
|
|
1402
|
+
self._settings.llm_model,
|
|
1403
|
+
page_count,
|
|
1404
|
+
len(transcript_excerpt),
|
|
1405
|
+
len(segments_excerpt),
|
|
1406
|
+
)
|
|
1407
|
+
return self._request_llm_json(
|
|
1408
|
+
base_url=base_url,
|
|
1409
|
+
payload=self._build_aggregate_series_summary_payload(
|
|
1410
|
+
title=title,
|
|
1411
|
+
transcript_excerpt=transcript_excerpt,
|
|
1412
|
+
segments_excerpt=segments_excerpt,
|
|
1413
|
+
),
|
|
1414
|
+
)
|
|
1415
|
+
|
|
1416
|
+
def _request_llm_summary_chunk(
|
|
1417
|
+
self,
|
|
1418
|
+
base_url: str,
|
|
1419
|
+
title: str,
|
|
1420
|
+
chunk: dict[str, object],
|
|
1421
|
+
chunk_count: int,
|
|
1422
|
+
retry_count: int,
|
|
1423
|
+
) -> dict[str, object]:
|
|
1424
|
+
chunk_index = int(chunk["index"])
|
|
1425
|
+
last_error: Exception | None = None
|
|
1426
|
+
for attempt in range(retry_count + 1):
|
|
1427
|
+
logger.info(
|
|
1428
|
+
"llm summary chunk request model=%s chunk=%d/%d attempt=%d transcript_chars=%d segments_json_chars=%d",
|
|
1429
|
+
self._settings.llm_model,
|
|
1430
|
+
chunk_index,
|
|
1431
|
+
chunk_count,
|
|
1432
|
+
attempt + 1,
|
|
1433
|
+
len(str(chunk["transcript"])),
|
|
1434
|
+
len(str(chunk["segments_json"])),
|
|
1435
|
+
)
|
|
1436
|
+
try:
|
|
1437
|
+
partial = self._request_llm_json(
|
|
1438
|
+
base_url=base_url,
|
|
1439
|
+
payload=self._build_llm_summary_payload(
|
|
1440
|
+
title=f"{title} - 分块 {chunk_index}",
|
|
1441
|
+
transcript_excerpt=str(chunk["transcript"]),
|
|
1442
|
+
segments_excerpt=str(chunk["segments_json"]),
|
|
1443
|
+
),
|
|
1444
|
+
)
|
|
1445
|
+
partial["chunk_index"] = chunk_index
|
|
1446
|
+
partial["chunk_start"] = float(chunk.get("chunk_start") or 0)
|
|
1447
|
+
partial["chunk_end"] = float(chunk.get("chunk_end") or partial["chunk_start"])
|
|
1448
|
+
partial["source_segment_count"] = int(chunk.get("source_segment_count") or 0)
|
|
1449
|
+
return partial
|
|
1450
|
+
except Exception as exc:
|
|
1451
|
+
last_error = exc
|
|
1452
|
+
logger.warning(
|
|
1453
|
+
"llm summary chunk request failed model=%s chunk=%d/%d attempt=%d error=%s",
|
|
1454
|
+
self._settings.llm_model,
|
|
1455
|
+
chunk_index,
|
|
1456
|
+
chunk_count,
|
|
1457
|
+
attempt + 1,
|
|
1458
|
+
exc,
|
|
1459
|
+
)
|
|
1460
|
+
raise VideoSumError(str(last_error) if last_error else f"Chunk {chunk_index} failed.")
|
|
1461
|
+
|
|
1462
|
+
def _request_llm_json(
|
|
1463
|
+
self,
|
|
1464
|
+
base_url: str,
|
|
1465
|
+
payload: dict[str, object],
|
|
1466
|
+
*,
|
|
1467
|
+
timeout: float = 180,
|
|
1468
|
+
retry_count: int | None = None,
|
|
1469
|
+
) -> dict[str, object]:
|
|
1470
|
+
headers = {
|
|
1471
|
+
"Authorization": f"Bearer {self._settings.llm_api_key}",
|
|
1472
|
+
"Content-Type": "application/json",
|
|
1473
|
+
}
|
|
1474
|
+
transport_retry_count = max(0, int(self._settings.summary_chunk_retry_count if retry_count is None else retry_count))
|
|
1475
|
+
last_error: Exception | None = None
|
|
1476
|
+
response: httpx.Response | None = None
|
|
1477
|
+
for attempt in range(transport_retry_count + 1):
|
|
1478
|
+
try:
|
|
1479
|
+
with httpx.Client(timeout=timeout, follow_redirects=True) as client:
|
|
1480
|
+
response = client.post(f"{base_url}/chat/completions", headers=headers, json=payload)
|
|
1481
|
+
break
|
|
1482
|
+
except Exception as exc:
|
|
1483
|
+
last_error = exc
|
|
1484
|
+
if attempt >= transport_retry_count or not _should_retry_llm_transport_error(exc):
|
|
1485
|
+
raise
|
|
1486
|
+
backoff_seconds = min(6.0, 1.5 * (attempt + 1))
|
|
1487
|
+
logger.warning(
|
|
1488
|
+
"llm json transport retry model=%s attempt=%d/%d error=%s backoff=%.1fs",
|
|
1489
|
+
self._settings.llm_model,
|
|
1490
|
+
attempt + 1,
|
|
1491
|
+
transport_retry_count + 1,
|
|
1492
|
+
exc,
|
|
1493
|
+
backoff_seconds,
|
|
1494
|
+
)
|
|
1495
|
+
time.sleep(backoff_seconds)
|
|
1496
|
+
if response is None:
|
|
1497
|
+
raise VideoSumError(str(last_error) if last_error else "LLM request failed before receiving response.")
|
|
1498
|
+
try:
|
|
1499
|
+
response.raise_for_status()
|
|
1500
|
+
except httpx.HTTPStatusError as exc:
|
|
1501
|
+
detail = _extract_response_error_detail(exc.response)
|
|
1502
|
+
logger.error(
|
|
1503
|
+
"llm json request failed status=%s model=%s detail=%s",
|
|
1504
|
+
exc.response.status_code,
|
|
1505
|
+
self._settings.llm_model,
|
|
1506
|
+
detail,
|
|
1507
|
+
)
|
|
1508
|
+
status_code = exc.response.status_code
|
|
1509
|
+
if status_code in (401, 403):
|
|
1510
|
+
raise LLMAuthenticationError(
|
|
1511
|
+
f"LLM API Key 无效、已过期,或当前模型/接口无权限访问(HTTP {status_code}: {detail})。"
|
|
1512
|
+
) from exc
|
|
1513
|
+
raise VideoSumError(f"LLM request failed with status {status_code}: {detail}") from exc
|
|
1514
|
+
logger.info("llm json response status=%s model=%s", response.status_code, self._settings.llm_model)
|
|
1515
|
+
response_json = response.json()
|
|
1516
|
+
content = response_json["choices"][0]["message"]["content"]
|
|
1517
|
+
parsed = self._parse_llm_json_content(content)
|
|
1518
|
+
usage = response_json.get("usage") or {}
|
|
1519
|
+
parsed.setdefault("title", "")
|
|
1520
|
+
parsed.setdefault("overview", "")
|
|
1521
|
+
parsed.setdefault("bulletPoints", [])
|
|
1522
|
+
parsed.setdefault("chapters", [])
|
|
1523
|
+
parsed["llm_prompt_tokens"] = _safe_int(usage.get("prompt_tokens"))
|
|
1524
|
+
parsed["llm_completion_tokens"] = _safe_int(usage.get("completion_tokens"))
|
|
1525
|
+
parsed["llm_total_tokens"] = _safe_int(usage.get("total_tokens"))
|
|
1526
|
+
return parsed
|
|
1527
|
+
|
|
1528
|
+
def _preflight_llm_availability(self) -> None:
|
|
1529
|
+
base_url = (self._settings.llm_base_url or "").rstrip("/")
|
|
1530
|
+
if not base_url or not self._settings.llm_model:
|
|
1531
|
+
raise LLMConfigurationError("LLM 配置不完整,请检查 Base URL 和模型名。")
|
|
1532
|
+
|
|
1533
|
+
payload = {
|
|
1534
|
+
"model": self._settings.llm_model,
|
|
1535
|
+
"messages": [
|
|
1536
|
+
{
|
|
1537
|
+
"role": "system",
|
|
1538
|
+
"content": "你是一个 JSON 测试助手。你只能返回合法 JSON,不能输出任何额外文字。",
|
|
1539
|
+
},
|
|
1540
|
+
{
|
|
1541
|
+
"role": "user",
|
|
1542
|
+
"content": '请只返回:{"ok":true}',
|
|
1543
|
+
},
|
|
1544
|
+
],
|
|
1545
|
+
"temperature": 0,
|
|
1546
|
+
"max_tokens": 32,
|
|
1547
|
+
}
|
|
1548
|
+
try:
|
|
1549
|
+
self._request_llm_json(base_url=base_url, payload=payload, timeout=20, retry_count=0)
|
|
1550
|
+
except httpx.TimeoutException as exc:
|
|
1551
|
+
raise VideoSumError("LLM API 检查超时,请稍后重试或检查 Base URL / 网络代理。") from exc
|
|
1552
|
+
except httpx.TransportError as exc:
|
|
1553
|
+
raise VideoSumError(f"LLM API 检查失败,请检查 Base URL / 网络代理:{exc}") from exc
|
|
1554
|
+
|
|
1555
|
+
def _parse_llm_json_content(self, content: str) -> dict[str, object]:
|
|
1556
|
+
# 移除可能的 think 标签(某些模型如 MiniMax-M2.5 可能返回)
|
|
1557
|
+
content = re.sub(r"<think>.*?</think>", "", content, flags=re.DOTALL)
|
|
1558
|
+
content = re.sub(r"<think>.*?</think>", "", content, flags=re.DOTALL)
|
|
1559
|
+
|
|
1560
|
+
candidates = [str(content or "").strip()]
|
|
1561
|
+
extracted = _extract_json_object_text(content)
|
|
1562
|
+
if extracted not in candidates:
|
|
1563
|
+
candidates.append(extracted)
|
|
1564
|
+
|
|
1565
|
+
for candidate in candidates:
|
|
1566
|
+
if not candidate:
|
|
1567
|
+
continue
|
|
1568
|
+
try:
|
|
1569
|
+
return json.loads(candidate)
|
|
1570
|
+
except json.JSONDecodeError:
|
|
1571
|
+
try:
|
|
1572
|
+
return json.loads(candidate, strict=False)
|
|
1573
|
+
except json.JSONDecodeError:
|
|
1574
|
+
continue
|
|
1575
|
+
|
|
1576
|
+
preview = _truncate_text(str(content or "").replace("\r", "\\r").replace("\n", "\\n"), 600)
|
|
1577
|
+
logger.error("llm json returned invalid content preview=%s", preview)
|
|
1578
|
+
raise VideoSumError("LLM returned invalid JSON content.")
|
|
1579
|
+
|
|
1580
|
+
def _build_llm_summary_payload(
|
|
1581
|
+
self,
|
|
1582
|
+
title: str,
|
|
1583
|
+
transcript_excerpt: str,
|
|
1584
|
+
segments_excerpt: str,
|
|
1585
|
+
) -> dict[str, object]:
|
|
1586
|
+
messages = self._build_summary_messages(title, transcript_excerpt, segments_excerpt)
|
|
1587
|
+
messages = self._ensure_json_keyword_in_messages(messages)
|
|
1588
|
+
# Qwen mixed-thinking models may reject json_object mode when thinking is enabled.
|
|
1589
|
+
return {
|
|
1590
|
+
"model": self._settings.llm_model,
|
|
1591
|
+
"messages": messages,
|
|
1592
|
+
"response_format": {"type": "json_object"},
|
|
1593
|
+
"enable_thinking": False,
|
|
1594
|
+
}
|
|
1595
|
+
|
|
1596
|
+
def _build_aggregate_series_summary_payload(
|
|
1597
|
+
self,
|
|
1598
|
+
title: str,
|
|
1599
|
+
transcript_excerpt: str,
|
|
1600
|
+
segments_excerpt: str,
|
|
1601
|
+
) -> dict[str, object]:
|
|
1602
|
+
messages = self._build_aggregate_series_summary_messages(title, transcript_excerpt, segments_excerpt)
|
|
1603
|
+
messages = self._ensure_json_keyword_in_messages(messages)
|
|
1604
|
+
return {
|
|
1605
|
+
"model": self._settings.llm_model,
|
|
1606
|
+
"messages": messages,
|
|
1607
|
+
"response_format": {"type": "json_object"},
|
|
1608
|
+
"enable_thinking": False,
|
|
1609
|
+
}
|
|
1610
|
+
|
|
1611
|
+
def _build_aggregate_series_summary_messages(
|
|
1612
|
+
self,
|
|
1613
|
+
title: str,
|
|
1614
|
+
transcript_excerpt: str,
|
|
1615
|
+
segments_excerpt: str,
|
|
1616
|
+
) -> list[dict[str, str]]:
|
|
1617
|
+
return [
|
|
1618
|
+
{
|
|
1619
|
+
"role": "system",
|
|
1620
|
+
"content": (
|
|
1621
|
+
"你是一名中文长内容总编辑,正在把一个多 P 视频的分 P 摘要整合为全集总结。"
|
|
1622
|
+
"输入不是完整转写,而是每个分 P 已生成的概览、要点、章节和笔记摘录。"
|
|
1623
|
+
"你的任务是做二次归纳:先逐 P 扫描,再跨 P 合并去重,最后输出能直接展示的结构化 JSON。"
|
|
1624
|
+
"所有结论必须来自输入,不得补充外部资料,不得编造,不得输出 JSON 以外的文字。"
|
|
1625
|
+
"You must return valid json only."
|
|
1626
|
+
),
|
|
1627
|
+
},
|
|
1628
|
+
{
|
|
1629
|
+
"role": "user",
|
|
1630
|
+
"content": self._render_user_prompt_template(
|
|
1631
|
+
"""请基于下面的多 P 摘要素材生成「全集总结」JSON。
|
|
1632
|
+
|
|
1633
|
+
工作步骤必须隐式完成,不要把步骤写进输出:
|
|
1634
|
+
1. 逐 P 扫描:每个 `## P数字 标题` 是一个分 P,优先读取其中 `[重点-概览]`、`[重点-要点]`、`[重点-章节]`、`[重点-笔记]`。
|
|
1635
|
+
2. 每个分 P 至少保留一个高价值信息点,避免只关注开头或结尾导致中间分 P 丢失。
|
|
1636
|
+
3. 跨 P 合并:把重复观点合并,把递进关系、因果关系、对比关系、结论与限制提炼出来。
|
|
1637
|
+
4. 输出面向全集,而不是逐 P 流水账;但所有章节都必须保留可跳转的 P 数定位。
|
|
1638
|
+
|
|
1639
|
+
强约束:
|
|
1640
|
+
1. 只返回合法 JSON,对象顶层只允许包含 title、overview、bulletPoints、chapters、chapterGroups 五个字段。
|
|
1641
|
+
2. title 使用简洁中文标题,体现全集主题,不要机械重复原题。
|
|
1642
|
+
3. overview 写 3 到 5 句,覆盖全集主线、关键展开、最终结论或价值。
|
|
1643
|
+
4. bulletPoints 写 5 到 8 条,每条 28 到 88 个字,优先写跨 P 的综合结论、关键方法、限制、争议或行动建议。
|
|
1644
|
+
5. chapters 表示全集的主题段落或内容推进,不要等同于每个分 P 一条;每项必须包含 title、start、summary:
|
|
1645
|
+
- start 必须是该主题最适合跳转的分 P 数字,例如 1、2、3;禁止使用时间戳、秒数、00:00、分钟。
|
|
1646
|
+
- title 要像真实小标题,不能写“章节 1”“P1 总结”这类占位标题。
|
|
1647
|
+
- summary 写 40 到 120 个字,说明这个主题覆盖哪些 P、核心信息和为什么重要。
|
|
1648
|
+
6. chapterGroups 是大主题分组,每项包含 title、start、summary、children;children 必须来自 chapters。
|
|
1649
|
+
- group.start 同样是分 P 数字。
|
|
1650
|
+
- 分组按语义组织,不要机械平均分配。
|
|
1651
|
+
7. 如果某个 P 的材料很短,只做保守归纳;不要补充没有出现的信息。
|
|
1652
|
+
8. 不要写”视频主要讲了””本合集介绍了”这类低信息密度开头,直接进入信息本体。
|
|
1653
|
+
9. 当分 P 数量较多(超过 20P)时,chapters 应该按主题合并多个相邻分 P,而不是每个分 P 一条。
|
|
1654
|
+
10. 当分 P 数量较多时,bulletPoints 应该聚焦跨分 P 的综合结论,而不是单个分 P 的内容。
|
|
1655
|
+
|
|
1656
|
+
输出格式示例:
|
|
1657
|
+
{{"title":"","overview":"","bulletPoints":["", "", "", "", ""],"chapters":[{{"title":"","start":1,"summary":""}}],"chapterGroups":[{{"title":"","start":1,"summary":"","children":[{{"title":"","start":1,"summary":""}}]}}]}}
|
|
1658
|
+
|
|
1659
|
+
全集标题:
|
|
1660
|
+
{title}
|
|
1661
|
+
|
|
1662
|
+
逐 P 摘要素材:
|
|
1663
|
+
{transcript_excerpt}
|
|
1664
|
+
|
|
1665
|
+
P 数索引:
|
|
1666
|
+
{segments_excerpt}""",
|
|
1667
|
+
title=title,
|
|
1668
|
+
transcript_excerpt=transcript_excerpt,
|
|
1669
|
+
segments_excerpt=segments_excerpt,
|
|
1670
|
+
),
|
|
1671
|
+
},
|
|
1672
|
+
]
|
|
1673
|
+
|
|
1674
|
+
def _build_summary_messages(
|
|
1675
|
+
self,
|
|
1676
|
+
title: str,
|
|
1677
|
+
transcript_excerpt: str,
|
|
1678
|
+
segments_excerpt: str,
|
|
1679
|
+
) -> list[dict[str, str]]:
|
|
1680
|
+
system_prompt = (
|
|
1681
|
+
self._settings.summary_system_prompt.strip()
|
|
1682
|
+
if self._settings.summary_system_prompt.strip()
|
|
1683
|
+
else (
|
|
1684
|
+
"你是一名严谨、克制、信息密度优先的中文视频内容编辑。"
|
|
1685
|
+
"你的任务不是泛泛总结,而是基于转写和分段信息,产出可以直接用于“知识卡片”页面的结构化内容。"
|
|
1686
|
+
"所有内容都必须忠实原文,不得编造,不得补充外部资料,不得输出 JSON 以外的任何文字。"
|
|
1687
|
+
"You must return valid json only."
|
|
1688
|
+
)
|
|
1689
|
+
)
|
|
1690
|
+
user_template = (
|
|
1691
|
+
self._settings.summary_user_prompt_template.strip()
|
|
1692
|
+
if self._settings.summary_user_prompt_template.strip()
|
|
1693
|
+
else """请阅读下面的视频资料,并输出一个 JSON 对象。
|
|
1694
|
+
注意:你必须返回合法的 json 对象,且只返回 json。
|
|
1695
|
+
|
|
1696
|
+
目标:
|
|
1697
|
+
生成一个适合详情页展示的结构化摘要,让用户在不看完整视频的情况下,也能快速理解:
|
|
1698
|
+
1. 这支视频核心在讲什么;
|
|
1699
|
+
2. 有哪些关键观点、论据、案例、争议和结论;
|
|
1700
|
+
3. 内容是如何逐步展开的。
|
|
1701
|
+
|
|
1702
|
+
强约束:
|
|
1703
|
+
1. 必须输出合法 JSON,对象顶层只允许包含 title、overview、bulletPoints、chapters、chapterGroups 五个字段。
|
|
1704
|
+
2. title 必须是简洁、准确的中文标题,避免口号式空话。
|
|
1705
|
+
3. overview 必须写成 3 到 5 句中文,整体形成一段完整概述:
|
|
1706
|
+
- 第 1 句交代主题或讨论对象;
|
|
1707
|
+
- 中间句交代关键论点、论据、背景、冲突或方法;
|
|
1708
|
+
- 最后 1 句交代结论、判断、影响或最终落点;
|
|
1709
|
+
- 总体要具体、完整,适合单独作为“核心概览”展示。
|
|
1710
|
+
4. bulletPoints 必须是 5 到 8 条中文要点,每条 28 到 88 个字:
|
|
1711
|
+
- 每条都要能单独成为一张知识卡片;
|
|
1712
|
+
- 优先提炼事实、观点、因果、对比、条件、风险、建议、争议;
|
|
1713
|
+
- 不要把同一件事拆成多条近义重复表达;
|
|
1714
|
+
- 不要写“作者认为”“视频提到”这类低信息密度前缀,直接写结论。
|
|
1715
|
+
5. chapters 必须按内容自然分布生成,每个章节必须包含 title、start、summary:
|
|
1716
|
+
- chapter.title 要像小标题,短而具体,能体现这一段的主题推进;
|
|
1717
|
+
- chapter.summary 必须比普通概述更详细,写成 2 到 3 个短句或 40 到 120 个字,说明这一段具体讲了什么、举了什么例子、得出了什么判断;
|
|
1718
|
+
- chapters 应体现内容推进关系,而不是机械平均切分;
|
|
1719
|
+
- 章节数量不要预设固定值,应根据内容转折、主题切换、论证层次和视频时长自适应决定;
|
|
1720
|
+
- 短视频可以较少章节,长视频或知识密度高的视频应适当增加章节,必要时可达到 9 到 12 个;
|
|
1721
|
+
- 只有在确实进入新主题、新问题、新案例或新结论时才切出新章节,不要为了凑数量硬拆。
|
|
1722
|
+
6. chapterGroups 用来表示“大章节 / 小章节”层级,按真实结构归纳大章节;每个大章节必须包含 title、start、summary、children:
|
|
1723
|
+
- children 必须是从 chapters 中归并出来的小章节数组,每项仍然包含 title、start、summary;
|
|
1724
|
+
- chapterGroups.summary 要概括该大章节的主题推进,20 到 60 个字;
|
|
1725
|
+
- chapterGroups.title 必须是有内容的主题名,禁止使用“大章节1”“第一部分”“Part 1”“Section 1”这类占位标题;
|
|
1726
|
+
- 当原文层级明显时,大章节数量也应随内容自适应,不要固定成 2 到 4 组;
|
|
1727
|
+
- 如果层级不明显,可以少量归并;如果层级明显,可以返回更多组,但不要机械平均分配。
|
|
1728
|
+
7. start 必须使用视频里真实出现的时间点,单位为秒,按升序排列。
|
|
1729
|
+
8. 如果原文信息有限,也必须尽量提炼出非空 bulletPoints 和 chapters,不能返回空数组。
|
|
1730
|
+
9. 不要写“视频主要讲了”“本视频介绍了”“作者首先”这类模板化空话,直接进入信息本体。
|
|
1731
|
+
10. 不要引用不存在的数据,不要补充外部背景,不要猜测说话者未明确表达的动机。
|
|
1732
|
+
|
|
1733
|
+
写作要求:
|
|
1734
|
+
- 保持中文自然、清楚、具体,避免官话和营销口吻。
|
|
1735
|
+
- 优先保留高价值信息:定义、判断、证据、例子、条件、限制、影响、结论。
|
|
1736
|
+
- 如果视频包含多个层次,overview 负责总览,bulletPoints 负责拆出关键结论,chapters 负责还原内容推进,chapterGroups 负责归纳章节层级。
|
|
1737
|
+
- 标题必须像真实目录项,而不是编号占位符;宁可少而准,也不要为了数量固定而硬拆。
|
|
1738
|
+
|
|
1739
|
+
输出格式示例:
|
|
1740
|
+
{{"title":"","overview":"","bulletPoints":["", "", "", "", ""],"chapters":[{{"title":"","start":0,"summary":""}}],"chapterGroups":[{{"title":"","start":0,"summary":"","children":[{{"title":"","start":0,"summary":""}}]}}]}}
|
|
1741
|
+
|
|
1742
|
+
视频标题:{title}
|
|
1743
|
+
|
|
1744
|
+
转写节选:
|
|
1745
|
+
{transcript}
|
|
1746
|
+
|
|
1747
|
+
分段数据节选:
|
|
1748
|
+
{segments_json}"""
|
|
1749
|
+
)
|
|
1750
|
+
return [
|
|
1751
|
+
{
|
|
1752
|
+
"role": "system",
|
|
1753
|
+
"content": system_prompt,
|
|
1754
|
+
},
|
|
1755
|
+
{
|
|
1756
|
+
"role": "user",
|
|
1757
|
+
"content": self._render_user_prompt_template(
|
|
1758
|
+
user_template,
|
|
1759
|
+
title=title,
|
|
1760
|
+
transcript_excerpt=transcript_excerpt,
|
|
1761
|
+
segments_excerpt=segments_excerpt,
|
|
1762
|
+
),
|
|
1763
|
+
},
|
|
1764
|
+
]
|
|
1765
|
+
|
|
1766
|
+
def _build_knowledge_note_messages(
|
|
1767
|
+
self,
|
|
1768
|
+
title: str,
|
|
1769
|
+
transcript_excerpt: str,
|
|
1770
|
+
segments_excerpt: str,
|
|
1771
|
+
summary_json: str,
|
|
1772
|
+
) -> list[dict[str, str]]:
|
|
1773
|
+
return [
|
|
1774
|
+
{
|
|
1775
|
+
"role": "system",
|
|
1776
|
+
"content": (
|
|
1777
|
+
"你是一名严谨、擅长整理学习型内容的中文知识编辑。"
|
|
1778
|
+
"你的任务是基于转写、分段和现有结构化摘要,单独产出一篇适合阅读的知识笔记。"
|
|
1779
|
+
"知识笔记必须比知识卡片更完整,能够承担学习、回顾和查阅任务。"
|
|
1780
|
+
"所有内容都必须忠实原文,不得编造,不得补充外部资料,不得输出 JSON 以外的任何文字。"
|
|
1781
|
+
"You must return valid json only."
|
|
1782
|
+
),
|
|
1783
|
+
},
|
|
1784
|
+
{
|
|
1785
|
+
"role": "user",
|
|
1786
|
+
"content": self._render_user_prompt_template(
|
|
1787
|
+
"""请阅读下面的视频资料,并输出一个 JSON 对象。
|
|
1788
|
+
注意:你必须返回合法的 json 对象,且只返回 json。
|
|
1789
|
+
|
|
1790
|
+
目标:
|
|
1791
|
+
基于原始转写和结构化摘要,生成一篇适合详情页“知识笔记”阅读视图的 Markdown 笔记。
|
|
1792
|
+
|
|
1793
|
+
强约束:
|
|
1794
|
+
1. 顶层只允许包含 knowledgeNoteMarkdown 一个字段。
|
|
1795
|
+
2. knowledgeNoteMarkdown 必须是一篇完整 Markdown 笔记,不要输出代码围栏包裹整篇内容。
|
|
1796
|
+
3. 笔记必须明显区别于知识卡片:
|
|
1797
|
+
- 不要只是把 bulletPoints 改写一遍;
|
|
1798
|
+
- 要有连续叙述、上下文解释、章节展开和重点串联;
|
|
1799
|
+
- 允许引用已有结构化摘要,但必须重新组织为适合阅读的笔记。
|
|
1800
|
+
4. 遇到知识类内容时,优先组织为:核心结论、关键概念、推理/方法、章节展开、易错点/限制。
|
|
1801
|
+
5. 遇到教程、评论、新闻等非知识类内容时,退化为通用深度笔记:主题概览、关键信息、内容推进、结论/影响。
|
|
1802
|
+
6. 只有在原文确实涉及公式、符号、函数、逻辑表达式时才使用 LaTeX:
|
|
1803
|
+
- 行内公式使用 `$...$`
|
|
1804
|
+
- 独立公式使用 `$$...$$`
|
|
1805
|
+
- 不要强行输出数学公式。
|
|
1806
|
+
7. 不要照抄转写全文,不要把原始 transcript 直接拼进笔记主体。
|
|
1807
|
+
8. 不要补充外部背景,不要编造例子,不要猜测说话者未表达的动机。
|
|
1808
|
+
|
|
1809
|
+
写作要求:
|
|
1810
|
+
- 标题层级清楚,便于长文阅读。
|
|
1811
|
+
- 保留定义、条件、因果、例子、结论、限制、争议等高价值信息。
|
|
1812
|
+
- 如果结构化摘要过于简略,应优先参考转写和分段把笔记写得更完整。
|
|
1813
|
+
|
|
1814
|
+
输出格式示例:
|
|
1815
|
+
{{"knowledgeNoteMarkdown":"# 标题\n\n## 核心结论\n\n..."}}
|
|
1816
|
+
|
|
1817
|
+
视频标题:
|
|
1818
|
+
{title}
|
|
1819
|
+
|
|
1820
|
+
已有结构化摘要:
|
|
1821
|
+
{summary_json}
|
|
1822
|
+
|
|
1823
|
+
转写节选:
|
|
1824
|
+
{transcript_excerpt}
|
|
1825
|
+
|
|
1826
|
+
分段数据节选:
|
|
1827
|
+
{segments_excerpt}""",
|
|
1828
|
+
title=title,
|
|
1829
|
+
transcript_excerpt=transcript_excerpt,
|
|
1830
|
+
segments_excerpt=segments_excerpt,
|
|
1831
|
+
summary_json=summary_json,
|
|
1832
|
+
),
|
|
1833
|
+
},
|
|
1834
|
+
]
|
|
1835
|
+
|
|
1836
|
+
def _build_aggregate_series_note_messages(
|
|
1837
|
+
self,
|
|
1838
|
+
title: str,
|
|
1839
|
+
transcript_excerpt: str,
|
|
1840
|
+
segments_excerpt: str,
|
|
1841
|
+
summary_json: str,
|
|
1842
|
+
) -> list[dict[str, str]]:
|
|
1843
|
+
return [
|
|
1844
|
+
{
|
|
1845
|
+
"role": "system",
|
|
1846
|
+
"content": (
|
|
1847
|
+
"你是一名中文知识编辑,正在把多 P 视频的结构化全集总结整理成阅读笔记。"
|
|
1848
|
+
"输入是分 P 摘要素材和已生成的全集结构化摘要,不是完整转写。"
|
|
1849
|
+
"你需要做主题化整理,保留 P 数定位,不能补充外部资料,不能输出 JSON 以外的文字。"
|
|
1850
|
+
"You must return valid json only."
|
|
1851
|
+
),
|
|
1852
|
+
},
|
|
1853
|
+
{
|
|
1854
|
+
"role": "user",
|
|
1855
|
+
"content": self._render_user_prompt_template(
|
|
1856
|
+
"""请输出一个 JSON 对象,顶层只包含 knowledgeNoteMarkdown。
|
|
1857
|
+
|
|
1858
|
+
写作目标:
|
|
1859
|
+
- 生成一篇「全集总结」阅读笔记,面向复盘整套多 P 视频。
|
|
1860
|
+
- 不要把分 P 摘要逐条搬运成流水账;要按主题、方法、结论、限制进行重组。
|
|
1861
|
+
- 关键段落中保留 P 数定位,例如“P2-P4 说明...”“这一点从 P5 开始展开”。
|
|
1862
|
+
|
|
1863
|
+
强约束:
|
|
1864
|
+
1. knowledgeNoteMarkdown 必须是完整 Markdown,不要代码围栏。
|
|
1865
|
+
2. 不要写时间戳,不要写 00:00;所有定位都使用 P 数。
|
|
1866
|
+
3. 不要照抄逐 P 素材;要提炼主线、重点和跨 P 关系。
|
|
1867
|
+
4. 不要编造输入中没有的信息。
|
|
1868
|
+
5. 建议结构:核心结论、全集主线、分主题重点、关键转折/对比、回看路径。
|
|
1869
|
+
|
|
1870
|
+
全集标题:
|
|
1871
|
+
{title}
|
|
1872
|
+
|
|
1873
|
+
已有全集结构化摘要:
|
|
1874
|
+
{summary_json}
|
|
1875
|
+
|
|
1876
|
+
逐 P 摘要素材:
|
|
1877
|
+
{transcript_excerpt}
|
|
1878
|
+
|
|
1879
|
+
P 数索引:
|
|
1880
|
+
{segments_excerpt}""",
|
|
1881
|
+
title=title,
|
|
1882
|
+
transcript_excerpt=transcript_excerpt,
|
|
1883
|
+
segments_excerpt=segments_excerpt,
|
|
1884
|
+
summary_json=summary_json,
|
|
1885
|
+
),
|
|
1886
|
+
},
|
|
1887
|
+
]
|
|
1888
|
+
|
|
1889
|
+
def _ensure_json_keyword_in_messages(self, messages: list[dict[str, str]]) -> list[dict[str, str]]:
|
|
1890
|
+
if any("json" in str(message.get("content") or "").lower() for message in messages):
|
|
1891
|
+
return messages
|
|
1892
|
+
|
|
1893
|
+
patched = [dict(message) for message in messages]
|
|
1894
|
+
if patched:
|
|
1895
|
+
patched[0]["content"] = f"{patched[0].get('content', '').rstrip()}\nReturn valid json only."
|
|
1896
|
+
else:
|
|
1897
|
+
patched = [{"role": "system", "content": "Return valid json only."}]
|
|
1898
|
+
return patched
|
|
1899
|
+
|
|
1900
|
+
def _render_user_prompt_template(
|
|
1901
|
+
self,
|
|
1902
|
+
template: str,
|
|
1903
|
+
title: str,
|
|
1904
|
+
transcript_excerpt: str,
|
|
1905
|
+
segments_excerpt: str,
|
|
1906
|
+
summary_json: str = "",
|
|
1907
|
+
knowledge_note_markdown: str = "",
|
|
1908
|
+
) -> str:
|
|
1909
|
+
rendered = template
|
|
1910
|
+
replacements = {
|
|
1911
|
+
"title": title,
|
|
1912
|
+
"transcript": transcript_excerpt,
|
|
1913
|
+
"transcript_excerpt": transcript_excerpt,
|
|
1914
|
+
"segments_json": segments_excerpt,
|
|
1915
|
+
"segments_excerpt": segments_excerpt,
|
|
1916
|
+
"summary_json": summary_json,
|
|
1917
|
+
"knowledge_note_markdown": knowledge_note_markdown,
|
|
1918
|
+
}
|
|
1919
|
+
for key, value in replacements.items():
|
|
1920
|
+
rendered = rendered.replace(f"{{{key}}}", value)
|
|
1921
|
+
return rendered.replace("{{", "{").replace("}}", "}")
|
|
1922
|
+
|
|
1923
|
+
def _build_llm_knowledge_note_payload(
|
|
1924
|
+
self,
|
|
1925
|
+
title: str,
|
|
1926
|
+
transcript_excerpt: str,
|
|
1927
|
+
segments_excerpt: str,
|
|
1928
|
+
summary_json: str,
|
|
1929
|
+
source_kind: str | None = None,
|
|
1930
|
+
) -> dict[str, object]:
|
|
1931
|
+
if source_kind == "aggregate_series":
|
|
1932
|
+
messages = self._build_aggregate_series_note_messages(
|
|
1933
|
+
title,
|
|
1934
|
+
transcript_excerpt,
|
|
1935
|
+
segments_excerpt,
|
|
1936
|
+
summary_json,
|
|
1937
|
+
)
|
|
1938
|
+
else:
|
|
1939
|
+
messages = self._build_knowledge_note_messages(title, transcript_excerpt, segments_excerpt, summary_json)
|
|
1940
|
+
messages = self._ensure_json_keyword_in_messages(messages)
|
|
1941
|
+
return {
|
|
1942
|
+
"model": self._settings.llm_model,
|
|
1943
|
+
"messages": messages,
|
|
1944
|
+
"response_format": {"type": "json_object"},
|
|
1945
|
+
"enable_thinking": False,
|
|
1946
|
+
}
|
|
1947
|
+
|
|
1948
|
+
def _generate_knowledge_note_with_llm(
|
|
1949
|
+
self,
|
|
1950
|
+
transcript: str,
|
|
1951
|
+
segments: list[dict[str, object]],
|
|
1952
|
+
title: str,
|
|
1953
|
+
summary: dict[str, object],
|
|
1954
|
+
source_kind: str | None = None,
|
|
1955
|
+
) -> dict[str, object]:
|
|
1956
|
+
base_url = (self._settings.llm_base_url or "").rstrip("/")
|
|
1957
|
+
if not base_url or not self._settings.llm_model:
|
|
1958
|
+
raise LLMConfigurationError("LLM 配置不完整,请检查 Base URL 和模型名。")
|
|
1959
|
+
if source_kind == "aggregate_series":
|
|
1960
|
+
transcript_excerpt = self._build_aggregate_series_excerpt(transcript)
|
|
1961
|
+
segments_excerpt = self._build_aggregate_series_segments_excerpt(segments)
|
|
1962
|
+
else:
|
|
1963
|
+
transcript_excerpt = self._build_transcript_excerpt(transcript)
|
|
1964
|
+
segments_excerpt = self._build_segments_excerpt(segments)
|
|
1965
|
+
summary_json = _truncate_text(
|
|
1966
|
+
json.dumps(
|
|
1967
|
+
{
|
|
1968
|
+
"title": summary.get("title"),
|
|
1969
|
+
"overview": summary.get("overview"),
|
|
1970
|
+
"bulletPoints": summary.get("bulletPoints"),
|
|
1971
|
+
"chapters": summary.get("chapters"),
|
|
1972
|
+
},
|
|
1973
|
+
ensure_ascii=False,
|
|
1974
|
+
),
|
|
1975
|
+
2400,
|
|
1976
|
+
)
|
|
1977
|
+
logger.info(
|
|
1978
|
+
"llm knowledge note request model=%s transcript_chars=%d segments=%d summary_chars=%d",
|
|
1979
|
+
self._settings.llm_model,
|
|
1980
|
+
len(transcript_excerpt),
|
|
1981
|
+
len(segments),
|
|
1982
|
+
len(summary_json),
|
|
1983
|
+
)
|
|
1984
|
+
payload = self._build_llm_knowledge_note_payload(
|
|
1985
|
+
title=title,
|
|
1986
|
+
transcript_excerpt=transcript_excerpt,
|
|
1987
|
+
segments_excerpt=segments_excerpt,
|
|
1988
|
+
summary_json=summary_json,
|
|
1989
|
+
source_kind=source_kind,
|
|
1990
|
+
)
|
|
1991
|
+
result = self._request_llm_json(base_url=base_url, payload=payload)
|
|
1992
|
+
result.setdefault("knowledgeNoteMarkdown", "")
|
|
1993
|
+
if not str(result.get("knowledgeNoteMarkdown") or "").strip():
|
|
1994
|
+
raise VideoSumError("LLM returned empty knowledge note markdown.")
|
|
1995
|
+
return result
|
|
1996
|
+
|
|
1997
|
+
def build_and_export_mindmap(
|
|
1998
|
+
self,
|
|
1999
|
+
task_id: str,
|
|
2000
|
+
title: str,
|
|
2001
|
+
result: TaskResult,
|
|
2002
|
+
) -> tuple[TaskMindMap, Path]:
|
|
2003
|
+
mindmap = self._generate_mindmap_with_llm(title=title, result=result)
|
|
2004
|
+
task_dir = ensure_directory(self._settings.tasks_dir / task_id)
|
|
2005
|
+
mindmap_path = task_dir / "mindmap.json"
|
|
2006
|
+
mindmap_path.write_text(
|
|
2007
|
+
json.dumps(mindmap.model_dump(mode="json"), ensure_ascii=False, indent=2),
|
|
2008
|
+
encoding="utf-8",
|
|
2009
|
+
)
|
|
2010
|
+
return mindmap, mindmap_path
|
|
2011
|
+
|
|
2012
|
+
def _build_mindmap_messages(
|
|
2013
|
+
self,
|
|
2014
|
+
title: str,
|
|
2015
|
+
summary_json: str,
|
|
2016
|
+
knowledge_note_markdown: str,
|
|
2017
|
+
) -> list[dict[str, str]]:
|
|
2018
|
+
system_prompt = (
|
|
2019
|
+
self._settings.mindmap_system_prompt.strip()
|
|
2020
|
+
if self._settings.mindmap_system_prompt.strip()
|
|
2021
|
+
else (
|
|
2022
|
+
"你是一名擅长把学习内容重新组织为知识导图的中文内容编辑。"
|
|
2023
|
+
"你的任务是基于已有结构化摘要和知识笔记,输出一个适合思维导图展示、信息密度充足、覆盖完整的 JSON 树。"
|
|
2024
|
+
"所有内容都必须忠实原文,不得编造,不得补充外部资料,不得输出 JSON 以外的任何文字。"
|
|
2025
|
+
"You must return valid json only."
|
|
2026
|
+
)
|
|
2027
|
+
)
|
|
2028
|
+
user_template = (
|
|
2029
|
+
self._settings.mindmap_user_prompt_template.strip()
|
|
2030
|
+
if self._settings.mindmap_user_prompt_template.strip()
|
|
2031
|
+
else """请阅读下面的视频资料,并输出一个 JSON 对象。
|
|
2032
|
+
注意:你必须返回合法的 json 对象,且只返回 json。
|
|
2033
|
+
|
|
2034
|
+
目标:
|
|
2035
|
+
把当前视频内容组织成一棵真正“像思维导图”的知识树。它必须以概念、主题、方法、结论之间的关系为核心,而不是把章节标题换个层级重新排列。最末层节点仍然必须能回到原视频片段。
|
|
2036
|
+
|
|
2037
|
+
强约束:
|
|
2038
|
+
1. 顶层只允许包含 title、root、nodes 三个字段。
|
|
2039
|
+
2. root 必须是整棵导图的根节点 id。
|
|
2040
|
+
3. nodes 必须是数组,其中包含唯一的根节点;每个节点必须包含 id、label、type、summary、children、time_anchor、source_chapter_titles、source_chapter_starts。
|
|
2041
|
+
4. type 只能是 root、theme、topic、leaf 之一。
|
|
2042
|
+
5. 整体结构必须是树,最大深度为 root -> theme -> topic/leaf -> leaf。
|
|
2043
|
+
6. 顶层 theme 数量应为 4 到 8 个,每个 theme 下应有 3 到 6 个 topic 或 leaf;除非原内容本身很短,否则不要生成过于稀疏的导图。
|
|
2044
|
+
7. leaf 节点必须能映射到原章节,并带真实时间点;time_anchor 必须取自 source_chapter_starts 中最早的时间点。
|
|
2045
|
+
8. source_chapter_titles 和 source_chapter_starts 只保留最相关的 1 到 3 项,且数量一致。
|
|
2046
|
+
9. label 必须是有内容的主题名,禁止“主题1”“Part 1”“Section 1”等占位标题。
|
|
2047
|
+
10. summary 要适合学习复盘,直接写信息本体,不要重复整段知识笔记;theme/topic 的 summary 尽量写成 2 到 4 句,leaf 的 summary 至少要交代“结论 / 方法 / 条件 / 例子”中的两项。
|
|
2048
|
+
11. label 和 summary 内如果出现数学内容,优先使用 KaTeX 兼容的 LaTeX 写法,例如 `$\\frac{1}{n}$`、`$(-1)^n$`、`$\\varepsilon$-$N$`;不要输出无法解析的伪公式。
|
|
2049
|
+
12. 只允许输出 JSON;但 JSON 字符串内部允许包含少量 Markdown 和 `$...$` / `$$...$$` 数学公式。
|
|
2050
|
+
13. 不要把 chapters 或 chapterGroups 直接一一平移成 theme/topic;必须先做语义归纳,再组织层级。
|
|
2051
|
+
14. 如果多个章节都在讲同一个概念、同一种方法、同一类例子,应该聚合成一个主题,而不是拆成多个并列节点。
|
|
2052
|
+
15. 导图的每一层都应体现“父主题如何拆成子主题”,而不是简单的时间顺序。
|
|
2053
|
+
|
|
2054
|
+
写作要求:
|
|
2055
|
+
- 优先按“概念定义 / 推导方法 / 典型例子 / 易错点 / 结论判断 / 应用条件”这类知识结构重组。
|
|
2056
|
+
- 根节点应该是整支视频真正的学习主题,不要只是视频标题原样重复,除非标题本身已经是明确概念。
|
|
2057
|
+
- theme 层应该是 4 到 8 个最核心的大主题,彼此之间要有明显区分。
|
|
2058
|
+
- topic 层应承担细化作用,只有当某个 theme 下确实存在两到三类不同子议题时才保留 topic;否则可直接挂 leaf。
|
|
2059
|
+
- leaf 节点要具体、短促、可点击后立刻看懂,不要写成长句,也不要只是“第X部分”。
|
|
2060
|
+
- 允许把多个来源章节压缩成一个更抽象、更像脑图节点的表达。
|
|
2061
|
+
- 如果原文本身是教程或知识讲解,优先提炼“知识结构”;如果原文是评论或资讯,优先提炼“观点结构”和“因果关系”。
|
|
2062
|
+
- 若视频包含公式、定义、判别条件、证明步骤、典型例题,不要省略它们;应把它们拆成独立主题或叶子节点,而不是只保留一个笼统标题。
|
|
2063
|
+
- 不要怕信息多,只要层级清楚即可;优先保证“覆盖完整”和“节点可学”,不要只给每个主题一个空泛标签。
|
|
2064
|
+
- 对于数学/理工类内容,优先把“定义、命题、判别条件、证明思路、典型例题、易错点”拆成不同节点;不要把整段证明压成一句泛泛描述。
|
|
2065
|
+
- 如果一个 theme 下只生成了 1 个叶子节点,优先继续细化,除非原文确实只讲了这一点。
|
|
2066
|
+
- 如果知识笔记已经给出分点、例题或条件,你应该把这些信息展开到对应节点,而不是只复述 theme 名称。
|
|
2067
|
+
- 最终观感要像学习者自己整理出来的脑图,而不是讲稿目录。
|
|
2068
|
+
|
|
2069
|
+
输出格式示例:
|
|
2070
|
+
{{"title":"","root":"root","nodes":[{{"id":"root","label":"","type":"root","summary":"","children":[{{"id":"theme-1","label":"","type":"theme","summary":"","children":[{{"id":"leaf-1","label":"","type":"leaf","summary":"","children":[],"time_anchor":0,"source_chapter_titles":[""],"source_chapter_starts":[0]}}],"source_chapter_titles":[],"source_chapter_starts":[]}}],"source_chapter_titles":[],"source_chapter_starts":[]}}]}}
|
|
2071
|
+
|
|
2072
|
+
视频标题:
|
|
2073
|
+
{title}
|
|
2074
|
+
|
|
2075
|
+
已有结构化摘要:
|
|
2076
|
+
{summary_json}
|
|
2077
|
+
|
|
2078
|
+
知识笔记:
|
|
2079
|
+
{knowledge_note_markdown}"""
|
|
2080
|
+
)
|
|
2081
|
+
return [
|
|
2082
|
+
{"role": "system", "content": system_prompt},
|
|
2083
|
+
{
|
|
2084
|
+
"role": "user",
|
|
2085
|
+
"content": self._render_user_prompt_template(
|
|
2086
|
+
user_template,
|
|
2087
|
+
title=title,
|
|
2088
|
+
transcript_excerpt="",
|
|
2089
|
+
segments_excerpt="",
|
|
2090
|
+
summary_json=summary_json,
|
|
2091
|
+
knowledge_note_markdown=knowledge_note_markdown,
|
|
2092
|
+
),
|
|
2093
|
+
},
|
|
2094
|
+
]
|
|
2095
|
+
|
|
2096
|
+
def _build_llm_mindmap_payload(
|
|
2097
|
+
self,
|
|
2098
|
+
title: str,
|
|
2099
|
+
summary_json: str,
|
|
2100
|
+
knowledge_note_markdown: str,
|
|
2101
|
+
) -> dict[str, object]:
|
|
2102
|
+
messages = self._build_mindmap_messages(title, summary_json, knowledge_note_markdown)
|
|
2103
|
+
messages = self._ensure_json_keyword_in_messages(messages)
|
|
2104
|
+
return {
|
|
2105
|
+
"model": self._settings.llm_model,
|
|
2106
|
+
"messages": messages,
|
|
2107
|
+
"response_format": {"type": "json_object"},
|
|
2108
|
+
"enable_thinking": False,
|
|
2109
|
+
}
|
|
2110
|
+
|
|
2111
|
+
def _generate_mindmap_with_llm(
|
|
2112
|
+
self,
|
|
2113
|
+
title: str,
|
|
2114
|
+
result: TaskResult,
|
|
2115
|
+
) -> TaskMindMap:
|
|
2116
|
+
base_url = (self._settings.llm_base_url or "").rstrip("/")
|
|
2117
|
+
if not base_url or not self._settings.llm_model:
|
|
2118
|
+
raise LLMConfigurationError("LLM 配置不完整,请检查 Base URL 和模型名。")
|
|
2119
|
+
|
|
2120
|
+
summary_json = _truncate_text(
|
|
2121
|
+
json.dumps(
|
|
2122
|
+
{
|
|
2123
|
+
"title": title,
|
|
2124
|
+
"overview": result.overview,
|
|
2125
|
+
"keyPoints": result.key_points,
|
|
2126
|
+
"chapters": result.timeline,
|
|
2127
|
+
"chapterGroups": result.chapter_groups,
|
|
2128
|
+
},
|
|
2129
|
+
ensure_ascii=False,
|
|
2130
|
+
),
|
|
2131
|
+
9000,
|
|
2132
|
+
)
|
|
2133
|
+
knowledge_note_markdown = _truncate_text(result.knowledge_note_markdown or "", 12000)
|
|
2134
|
+
logger.info(
|
|
2135
|
+
"llm mindmap request model=%s summary_chars=%d knowledge_note_chars=%d key_points=%d chapters=%d chapter_groups=%d",
|
|
2136
|
+
self._settings.llm_model,
|
|
2137
|
+
len(summary_json),
|
|
2138
|
+
len(knowledge_note_markdown),
|
|
2139
|
+
len(result.key_points or []),
|
|
2140
|
+
len(result.timeline or []),
|
|
2141
|
+
len(result.chapter_groups or []),
|
|
2142
|
+
)
|
|
2143
|
+
payload = self._build_llm_mindmap_payload(title, summary_json, knowledge_note_markdown)
|
|
2144
|
+
llm_result = self._request_llm_json(base_url=base_url, payload=payload)
|
|
2145
|
+
return self._normalize_mindmap_payload(llm_result, title=title, result=result)
|
|
2146
|
+
|
|
2147
|
+
def _normalize_mindmap_payload(
|
|
2148
|
+
self,
|
|
2149
|
+
payload: dict[str, object],
|
|
2150
|
+
*,
|
|
2151
|
+
title: str,
|
|
2152
|
+
result: TaskResult,
|
|
2153
|
+
) -> TaskMindMap:
|
|
2154
|
+
chapters = [
|
|
2155
|
+
{
|
|
2156
|
+
"title": str(item.get("title") or "").strip(),
|
|
2157
|
+
"start": float(item.get("start") or 0),
|
|
2158
|
+
"summary": str(item.get("summary") or "").strip(),
|
|
2159
|
+
}
|
|
2160
|
+
for item in result.timeline
|
|
2161
|
+
if isinstance(item, dict)
|
|
2162
|
+
]
|
|
2163
|
+
root_id = str(payload.get("root") or "root").strip() or "root"
|
|
2164
|
+
raw_nodes = payload.get("nodes")
|
|
2165
|
+
root_node_payload: dict[str, object] | None = None
|
|
2166
|
+
if isinstance(raw_nodes, list):
|
|
2167
|
+
for item in raw_nodes:
|
|
2168
|
+
if isinstance(item, dict) and str(item.get("id") or "").strip() == root_id:
|
|
2169
|
+
root_node_payload = item
|
|
2170
|
+
break
|
|
2171
|
+
if root_node_payload is None:
|
|
2172
|
+
root_node_payload = next((item for item in raw_nodes if isinstance(item, dict)), None)
|
|
2173
|
+
elif isinstance(raw_nodes, dict):
|
|
2174
|
+
root_node_payload = raw_nodes
|
|
2175
|
+
if root_node_payload is None:
|
|
2176
|
+
root_node_payload = {"id": root_id, "label": title, "type": "root", "summary": result.overview, "children": []}
|
|
2177
|
+
|
|
2178
|
+
used_ids: set[str] = set()
|
|
2179
|
+
root_node = self._normalize_mindmap_node(
|
|
2180
|
+
root_node_payload,
|
|
2181
|
+
depth=0,
|
|
2182
|
+
title=title,
|
|
2183
|
+
chapters=chapters,
|
|
2184
|
+
used_ids=used_ids,
|
|
2185
|
+
)
|
|
2186
|
+
root_node.id = root_id
|
|
2187
|
+
root_node.type = "root"
|
|
2188
|
+
if not root_node.label.strip():
|
|
2189
|
+
root_node.label = title
|
|
2190
|
+
|
|
2191
|
+
if len(root_node.children) > 8:
|
|
2192
|
+
root_node.children = root_node.children[:8]
|
|
2193
|
+
|
|
2194
|
+
return TaskMindMap(version=1, title=str(payload.get("title") or title).strip() or title, root=root_node.id, nodes=[root_node])
|
|
2195
|
+
|
|
2196
|
+
def _normalize_mindmap_node(
|
|
2197
|
+
self,
|
|
2198
|
+
payload: dict[str, object],
|
|
2199
|
+
*,
|
|
2200
|
+
depth: int,
|
|
2201
|
+
title: str,
|
|
2202
|
+
chapters: list[dict[str, object]],
|
|
2203
|
+
used_ids: set[str],
|
|
2204
|
+
) -> MindMapNode:
|
|
2205
|
+
raw_children = payload.get("children")
|
|
2206
|
+
child_payloads = [item for item in raw_children if isinstance(item, dict)] if isinstance(raw_children, list) else []
|
|
2207
|
+
normalized_children: list[MindMapNode] = []
|
|
2208
|
+
if depth < 3:
|
|
2209
|
+
max_children = 8 if depth == 0 else 6
|
|
2210
|
+
for child in child_payloads[:max_children]:
|
|
2211
|
+
normalized_children.append(
|
|
2212
|
+
self._normalize_mindmap_node(
|
|
2213
|
+
child,
|
|
2214
|
+
depth=depth + 1,
|
|
2215
|
+
title=title,
|
|
2216
|
+
chapters=chapters,
|
|
2217
|
+
used_ids=used_ids,
|
|
2218
|
+
)
|
|
2219
|
+
)
|
|
2220
|
+
|
|
2221
|
+
label = self._normalize_content_title(
|
|
2222
|
+
str(payload.get("label") or payload.get("title") or "").strip(),
|
|
2223
|
+
fallback_text=str(payload.get("summary") or "").strip(),
|
|
2224
|
+
fallback_prefix="主题" if depth <= 1 else "节点",
|
|
2225
|
+
fallback_index=max(1, len(used_ids) + 1),
|
|
2226
|
+
)
|
|
2227
|
+
summary = _truncate_text(str(payload.get("summary") or "").strip(), 360)
|
|
2228
|
+
source_titles = [str(item).strip() for item in (payload.get("source_chapter_titles") or []) if str(item).strip()]
|
|
2229
|
+
source_starts: list[float] = []
|
|
2230
|
+
for item in (payload.get("source_chapter_starts") or []):
|
|
2231
|
+
if item is None or str(item).strip() == "":
|
|
2232
|
+
continue
|
|
2233
|
+
try:
|
|
2234
|
+
source_starts.append(float(item))
|
|
2235
|
+
except (TypeError, ValueError):
|
|
2236
|
+
continue
|
|
2237
|
+
|
|
2238
|
+
if not source_titles and not source_starts:
|
|
2239
|
+
inferred_titles, inferred_starts = self._infer_mindmap_sources(label, summary, chapters)
|
|
2240
|
+
source_titles = inferred_titles
|
|
2241
|
+
source_starts = inferred_starts
|
|
2242
|
+
|
|
2243
|
+
if len(source_titles) > 3:
|
|
2244
|
+
source_titles = source_titles[:3]
|
|
2245
|
+
if len(source_starts) > 3:
|
|
2246
|
+
source_starts = source_starts[:3]
|
|
2247
|
+
pair_count = min(len(source_titles), len(source_starts))
|
|
2248
|
+
if pair_count:
|
|
2249
|
+
source_titles = source_titles[:pair_count]
|
|
2250
|
+
source_starts = source_starts[:pair_count]
|
|
2251
|
+
|
|
2252
|
+
raw_time_anchor = payload.get("time_anchor")
|
|
2253
|
+
time_anchor = float(raw_time_anchor) if raw_time_anchor is not None and str(raw_time_anchor).strip() != "" else None
|
|
2254
|
+
if time_anchor is None and source_starts:
|
|
2255
|
+
time_anchor = min(source_starts)
|
|
2256
|
+
if time_anchor is None and normalized_children:
|
|
2257
|
+
child_anchors = [child.time_anchor for child in normalized_children if child.time_anchor is not None]
|
|
2258
|
+
if child_anchors:
|
|
2259
|
+
time_anchor = min(child_anchors)
|
|
2260
|
+
|
|
2261
|
+
node_type = str(payload.get("type") or "").strip().lower()
|
|
2262
|
+
if depth == 0:
|
|
2263
|
+
node_type = "root"
|
|
2264
|
+
elif depth == 1:
|
|
2265
|
+
node_type = "theme"
|
|
2266
|
+
elif normalized_children and depth < 3:
|
|
2267
|
+
node_type = "topic"
|
|
2268
|
+
else:
|
|
2269
|
+
node_type = "leaf"
|
|
2270
|
+
normalized_children = []
|
|
2271
|
+
|
|
2272
|
+
if node_type == "leaf" and time_anchor is None:
|
|
2273
|
+
inferred_titles, inferred_starts = self._infer_mindmap_sources(label, summary, chapters)
|
|
2274
|
+
if inferred_starts:
|
|
2275
|
+
source_titles = inferred_titles
|
|
2276
|
+
source_starts = inferred_starts
|
|
2277
|
+
time_anchor = min(inferred_starts)
|
|
2278
|
+
|
|
2279
|
+
node_id = self._normalize_mindmap_node_id(str(payload.get("id") or "").strip(), label, node_type, used_ids)
|
|
2280
|
+
|
|
2281
|
+
return MindMapNode(
|
|
2282
|
+
id=node_id,
|
|
2283
|
+
label=label or ("根节点" if depth == 0 else "主题"),
|
|
2284
|
+
type=node_type,
|
|
2285
|
+
summary=summary,
|
|
2286
|
+
children=normalized_children,
|
|
2287
|
+
time_anchor=time_anchor if node_type == "leaf" else None,
|
|
2288
|
+
source_chapter_titles=source_titles,
|
|
2289
|
+
source_chapter_starts=source_starts,
|
|
2290
|
+
)
|
|
2291
|
+
|
|
2292
|
+
def _normalize_mindmap_node_id(
|
|
2293
|
+
self,
|
|
2294
|
+
raw_id: str,
|
|
2295
|
+
label: str,
|
|
2296
|
+
node_type: str,
|
|
2297
|
+
used_ids: set[str],
|
|
2298
|
+
) -> str:
|
|
2299
|
+
base = raw_id or re.sub(r"[^a-z0-9]+", "-", self._dedupe_text_key(label or node_type)).strip("-")
|
|
2300
|
+
if not base:
|
|
2301
|
+
base = node_type
|
|
2302
|
+
candidate = base
|
|
2303
|
+
suffix = 2
|
|
2304
|
+
while candidate in used_ids:
|
|
2305
|
+
candidate = f"{base}-{suffix}"
|
|
2306
|
+
suffix += 1
|
|
2307
|
+
used_ids.add(candidate)
|
|
2308
|
+
return candidate
|
|
2309
|
+
|
|
2310
|
+
def _infer_mindmap_sources(
|
|
2311
|
+
self,
|
|
2312
|
+
label: str,
|
|
2313
|
+
summary: str,
|
|
2314
|
+
chapters: list[dict[str, object]],
|
|
2315
|
+
) -> tuple[list[str], list[float]]:
|
|
2316
|
+
if not chapters:
|
|
2317
|
+
return [], []
|
|
2318
|
+
query = self._dedupe_text_key(f"{label} {summary}")
|
|
2319
|
+
ranked: list[tuple[int, dict[str, object]]] = []
|
|
2320
|
+
for chapter in chapters:
|
|
2321
|
+
title = str(chapter.get("title") or "").strip()
|
|
2322
|
+
chapter_summary = str(chapter.get("summary") or "").strip()
|
|
2323
|
+
haystack = self._dedupe_text_key(f"{title} {chapter_summary}")
|
|
2324
|
+
score = 0
|
|
2325
|
+
if label and self._dedupe_text_key(label) and self._dedupe_text_key(label) in haystack:
|
|
2326
|
+
score += 5
|
|
2327
|
+
if summary and self._dedupe_text_key(summary) and self._dedupe_text_key(summary)[:12] in haystack:
|
|
2328
|
+
score += 4
|
|
2329
|
+
if query and haystack:
|
|
2330
|
+
score += len(set(query[:24]) & set(haystack[:24]))
|
|
2331
|
+
ranked.append((score, chapter))
|
|
2332
|
+
ranked.sort(key=lambda item: (-item[0], float(item[1].get("start") or 0)))
|
|
2333
|
+
selected = [item[1] for item in ranked[:3] if item[0] > 0] or [ranked[0][1]]
|
|
2334
|
+
titles = [str(item.get("title") or "").strip() for item in selected if str(item.get("title") or "").strip()]
|
|
2335
|
+
starts = [float(item.get("start") or 0) for item in selected]
|
|
2336
|
+
return titles[:3], starts[:3]
|
|
2337
|
+
|
|
2338
|
+
def _build_summary_chunks(self, segments: list[dict[str, object]]) -> list[dict[str, object]]:
|
|
2339
|
+
if not segments:
|
|
2340
|
+
return []
|
|
2341
|
+
|
|
2342
|
+
chunks: list[dict[str, object]] = []
|
|
2343
|
+
target_chars = max(800, int(self._settings.summary_chunk_target_chars))
|
|
2344
|
+
overlap = max(0, int(self._settings.summary_chunk_overlap_segments))
|
|
2345
|
+
start = 0
|
|
2346
|
+
index = 1
|
|
2347
|
+
total = len(segments)
|
|
2348
|
+
|
|
2349
|
+
while start < total:
|
|
2350
|
+
current: list[dict[str, object]] = []
|
|
2351
|
+
current_chars = 0
|
|
2352
|
+
cursor = start
|
|
2353
|
+
while cursor < total:
|
|
2354
|
+
segment = segments[cursor]
|
|
2355
|
+
text = str(segment.get("text") or "").strip()
|
|
2356
|
+
estimated = len(text) + 24
|
|
2357
|
+
if current and current_chars + estimated > target_chars:
|
|
2358
|
+
break
|
|
2359
|
+
current.append(segment)
|
|
2360
|
+
current_chars += estimated
|
|
2361
|
+
cursor += 1
|
|
2362
|
+
|
|
2363
|
+
if not current:
|
|
2364
|
+
current = [segments[start]]
|
|
2365
|
+
cursor = start + 1
|
|
2366
|
+
|
|
2367
|
+
chunk_lines = [
|
|
2368
|
+
f"[{self._format_seconds(float(item.get('start') or 0))}] {str(item.get('text') or '').strip()}"
|
|
2369
|
+
for item in current
|
|
2370
|
+
if str(item.get("text") or "").strip()
|
|
2371
|
+
]
|
|
2372
|
+
compact_segments = [
|
|
2373
|
+
{
|
|
2374
|
+
"start": float(item.get("start") or 0),
|
|
2375
|
+
"text": _truncate_text(str(item.get("text") or "").strip(), 120),
|
|
2376
|
+
}
|
|
2377
|
+
for item in current
|
|
2378
|
+
if str(item.get("text") or "").strip()
|
|
2379
|
+
]
|
|
2380
|
+
chunks.append(
|
|
2381
|
+
{
|
|
2382
|
+
"index": index,
|
|
2383
|
+
"transcript": _truncate_text("\n".join(chunk_lines), target_chars + 400),
|
|
2384
|
+
"segments_json": _truncate_text(json.dumps(compact_segments, ensure_ascii=False), target_chars + 400),
|
|
2385
|
+
"chunk_start": float(current[0].get("start") or 0),
|
|
2386
|
+
"chunk_end": float(current[-1].get("end") or current[-1].get("start") or 0),
|
|
2387
|
+
"source_segment_count": len(current),
|
|
2388
|
+
}
|
|
2389
|
+
)
|
|
2390
|
+
index += 1
|
|
2391
|
+
if cursor >= total:
|
|
2392
|
+
break
|
|
2393
|
+
start = max(cursor - overlap, start + 1)
|
|
2394
|
+
return chunks
|
|
2395
|
+
|
|
2396
|
+
def _build_aggregate_summary_inputs(
|
|
2397
|
+
self,
|
|
2398
|
+
partial_summaries: list[dict[str, object]],
|
|
2399
|
+
merged_chapters: list[dict[str, object]] | None = None,
|
|
2400
|
+
) -> tuple[str, str]:
|
|
2401
|
+
lines: list[str] = []
|
|
2402
|
+
segments: list[dict[str, object]] = []
|
|
2403
|
+
final_chapters = merged_chapters or []
|
|
2404
|
+
|
|
2405
|
+
if final_chapters:
|
|
2406
|
+
lines.append("## 合并后的全局章节")
|
|
2407
|
+
for chapter in final_chapters:
|
|
2408
|
+
start = float(chapter.get("start") or 0)
|
|
2409
|
+
chapter_title = str(chapter.get("title") or "").strip()
|
|
2410
|
+
summary = str(chapter.get("summary") or "").strip()
|
|
2411
|
+
if not summary:
|
|
2412
|
+
continue
|
|
2413
|
+
lines.append(f"[{self._format_seconds(start)}] {chapter_title}:{summary}")
|
|
2414
|
+
segments.append(
|
|
2415
|
+
{
|
|
2416
|
+
"start": start,
|
|
2417
|
+
"text": _truncate_text(f"{chapter_title}:{summary}", 180),
|
|
2418
|
+
}
|
|
2419
|
+
)
|
|
2420
|
+
lines.append("")
|
|
2421
|
+
|
|
2422
|
+
for item in partial_summaries:
|
|
2423
|
+
chunk_index = int(item.get("chunk_index") or 0)
|
|
2424
|
+
title = str(item.get("title") or f"分块 {chunk_index}")
|
|
2425
|
+
overview = str(item.get("overview") or "").strip()
|
|
2426
|
+
bullet_points = [str(point).strip() for point in item.get("bulletPoints") or [] if str(point).strip()]
|
|
2427
|
+
chapters = [chapter for chapter in item.get("chapters") or [] if isinstance(chapter, dict)]
|
|
2428
|
+
|
|
2429
|
+
lines.append(f"### 分块 {chunk_index}: {title}")
|
|
2430
|
+
if overview:
|
|
2431
|
+
lines.append(f"概览:{overview}")
|
|
2432
|
+
for point in bullet_points[:6]:
|
|
2433
|
+
lines.append(f"- {point}")
|
|
2434
|
+
for chapter in chapters[:6]:
|
|
2435
|
+
start = float(chapter.get("start") or 0)
|
|
2436
|
+
summary = str(chapter.get("summary") or "").strip()
|
|
2437
|
+
chapter_title = str(chapter.get("title") or f"章节 {chunk_index}")
|
|
2438
|
+
if summary:
|
|
2439
|
+
lines.append(f"[{self._format_seconds(start)}] {chapter_title}:{summary}")
|
|
2440
|
+
lines.append("")
|
|
2441
|
+
return _truncate_text("\n".join(lines).strip(), 5200), _truncate_text(json.dumps(segments, ensure_ascii=False), 2600)
|
|
2442
|
+
|
|
2443
|
+
def _format_seconds(self, value: float) -> str:
|
|
2444
|
+
total = max(0, int(value))
|
|
2445
|
+
minutes = total // 60
|
|
2446
|
+
seconds = total % 60
|
|
2447
|
+
return f"{minutes:02d}:{seconds:02d}"
|
|
2448
|
+
|
|
2449
|
+
def _build_transcript_excerpt(self, transcript: str) -> str:
|
|
2450
|
+
lines = [line.strip() for line in transcript.splitlines() if line.strip()]
|
|
2451
|
+
if len(lines) <= 120:
|
|
2452
|
+
return _truncate_text("\n".join(lines), 3600)
|
|
2453
|
+
head = lines[:70]
|
|
2454
|
+
tail = lines[-35:]
|
|
2455
|
+
return _truncate_text("\n".join(head + ["[...中间转写已省略...]"] + tail), 3600)
|
|
2456
|
+
|
|
2457
|
+
def _build_aggregate_series_excerpt(self, transcript: str) -> str:
|
|
2458
|
+
blocks = [block.strip() for block in re.split(r"\n(?=##\s*P\d+\s)", transcript) if block.strip()]
|
|
2459
|
+
if not blocks:
|
|
2460
|
+
return _truncate_text(transcript, 10_000)
|
|
2461
|
+
|
|
2462
|
+
# 根据总 P 数动态调整压缩策略
|
|
2463
|
+
page_count = len(blocks)
|
|
2464
|
+
|
|
2465
|
+
# 动态计算每 P 的字符预算,保证所有 P 都能被包含
|
|
2466
|
+
if page_count <= 20:
|
|
2467
|
+
per_page_budget = 500
|
|
2468
|
+
total_budget = 15_000
|
|
2469
|
+
elif page_count <= 40:
|
|
2470
|
+
per_page_budget = 350
|
|
2471
|
+
total_budget = 18_000
|
|
2472
|
+
elif page_count <= 60:
|
|
2473
|
+
per_page_budget = 250
|
|
2474
|
+
total_budget = 20_000
|
|
2475
|
+
else:
|
|
2476
|
+
per_page_budget = 180
|
|
2477
|
+
total_budget = 24_000
|
|
2478
|
+
|
|
2479
|
+
# 优先级:概览 > 要点 > 章节 > 笔记
|
|
2480
|
+
compact_blocks: list[str] = []
|
|
2481
|
+
for block in blocks:
|
|
2482
|
+
lines = [line.strip() for line in block.splitlines() if line.strip()]
|
|
2483
|
+
if not lines:
|
|
2484
|
+
continue
|
|
2485
|
+
|
|
2486
|
+
head = lines[0] # ## P 数 标题
|
|
2487
|
+
remaining_budget = per_page_budget - len(head) - 2
|
|
2488
|
+
|
|
2489
|
+
# 分类不同优先级的行
|
|
2490
|
+
overview_lines = []
|
|
2491
|
+
keypoint_lines = []
|
|
2492
|
+
chapter_lines = []
|
|
2493
|
+
note_lines = []
|
|
2494
|
+
|
|
2495
|
+
for line in lines[1:]:
|
|
2496
|
+
if not line.startswith("[重点-"):
|
|
2497
|
+
continue
|
|
2498
|
+
if line.startswith("[重点 - 概览]"):
|
|
2499
|
+
overview_lines.append(line)
|
|
2500
|
+
elif line.startswith("[重点 - 要点]"):
|
|
2501
|
+
keypoint_lines.append(line)
|
|
2502
|
+
elif line.startswith("[重点 - 章节]"):
|
|
2503
|
+
chapter_lines.append(line)
|
|
2504
|
+
elif line.startswith("[重点 - 笔记]"):
|
|
2505
|
+
note_lines.append(line)
|
|
2506
|
+
|
|
2507
|
+
# 按优先级选择行,确保高优先级内容优先保留
|
|
2508
|
+
selected_lines = []
|
|
2509
|
+
|
|
2510
|
+
# 1. 概览优先级最高,最多保留 1 条
|
|
2511
|
+
for line in overview_lines[:1]:
|
|
2512
|
+
truncated = _truncate_text(line, min(remaining_budget - 2, 200))
|
|
2513
|
+
if truncated:
|
|
2514
|
+
selected_lines.append(truncated)
|
|
2515
|
+
remaining_budget -= len(truncated) + 2
|
|
2516
|
+
|
|
2517
|
+
# 2. 要点优先级次之,最多保留 2 条
|
|
2518
|
+
for line in keypoint_lines[:2]:
|
|
2519
|
+
if remaining_budget <= 20:
|
|
2520
|
+
break
|
|
2521
|
+
truncated = _truncate_text(line, min(remaining_budget - 2, 150))
|
|
2522
|
+
if truncated:
|
|
2523
|
+
selected_lines.append(truncated)
|
|
2524
|
+
remaining_budget -= len(truncated) + 2
|
|
2525
|
+
|
|
2526
|
+
# 3. 章节和笔记根据剩余预算决定
|
|
2527
|
+
if remaining_budget > 40:
|
|
2528
|
+
# 章节和笔记合并考虑,优先章节
|
|
2529
|
+
other_lines = chapter_lines[:2] + note_lines[:1]
|
|
2530
|
+
for line in other_lines:
|
|
2531
|
+
if remaining_budget <= 30:
|
|
2532
|
+
break
|
|
2533
|
+
truncated = _truncate_text(line, min(remaining_budget - 2, 100))
|
|
2534
|
+
if truncated:
|
|
2535
|
+
selected_lines.append(truncated)
|
|
2536
|
+
remaining_budget -= len(truncated) + 2
|
|
2537
|
+
|
|
2538
|
+
compact_blocks.append("\n".join([head, *selected_lines]))
|
|
2539
|
+
|
|
2540
|
+
return _truncate_text("\n\n".join(compact_blocks), total_budget)
|
|
2541
|
+
|
|
2542
|
+
def _build_aggregate_series_segments_excerpt(self, segments: list[dict[str, object]]) -> str:
|
|
2543
|
+
seen_pages: set[int] = set()
|
|
2544
|
+
compact_segments: list[dict[str, object]] = []
|
|
2545
|
+
|
|
2546
|
+
# 根据总 P 数动态调整每 P 的字符预算
|
|
2547
|
+
total_pages = len({int(float(item.get("start") or 0)) for item in segments if item.get("start") is not None})
|
|
2548
|
+
|
|
2549
|
+
if total_pages <= 20:
|
|
2550
|
+
per_page_label_limit = 150
|
|
2551
|
+
total_limit = 4800
|
|
2552
|
+
elif total_pages <= 40:
|
|
2553
|
+
per_page_label_limit = 120
|
|
2554
|
+
total_limit = 6000
|
|
2555
|
+
elif total_pages <= 60:
|
|
2556
|
+
per_page_label_limit = 100
|
|
2557
|
+
total_limit = 7200
|
|
2558
|
+
else:
|
|
2559
|
+
per_page_label_limit = 80
|
|
2560
|
+
total_limit = 9600
|
|
2561
|
+
|
|
2562
|
+
for item in segments:
|
|
2563
|
+
try:
|
|
2564
|
+
page_number = int(float(item.get("start") or 0))
|
|
2565
|
+
except (TypeError, ValueError):
|
|
2566
|
+
continue
|
|
2567
|
+
if page_number <= 0 or page_number in seen_pages:
|
|
2568
|
+
continue
|
|
2569
|
+
seen_pages.add(page_number)
|
|
2570
|
+
compact_segments.append(
|
|
2571
|
+
{
|
|
2572
|
+
"page": page_number,
|
|
2573
|
+
"label": _truncate_text(str(item.get("text") or "").strip(), per_page_label_limit),
|
|
2574
|
+
}
|
|
2575
|
+
)
|
|
2576
|
+
return _truncate_text(json.dumps(compact_segments, ensure_ascii=False), total_limit)
|
|
2577
|
+
|
|
2578
|
+
def _build_segments_excerpt(self, segments: list[dict[str, object]]) -> str:
|
|
2579
|
+
if len(segments) <= 32:
|
|
2580
|
+
selected = segments
|
|
2581
|
+
else:
|
|
2582
|
+
head = segments[:12]
|
|
2583
|
+
middle_start = max(12, len(segments) // 2 - 4)
|
|
2584
|
+
middle = segments[middle_start : middle_start + 8]
|
|
2585
|
+
tail = segments[-12:]
|
|
2586
|
+
selected = [*head, {"start": "...", "text": "中间分段已省略"}, *middle, *tail]
|
|
2587
|
+
|
|
2588
|
+
compact_segments: list[dict[str, object]] = []
|
|
2589
|
+
for item in selected:
|
|
2590
|
+
compact_segments.append(
|
|
2591
|
+
{
|
|
2592
|
+
"start": item.get("start"),
|
|
2593
|
+
"text": _truncate_text(str(item.get("text") or ""), 72),
|
|
2594
|
+
}
|
|
2595
|
+
)
|
|
2596
|
+
return _truncate_text(json.dumps(compact_segments, ensure_ascii=False), 1800)
|
|
2597
|
+
|
|
2598
|
+
def _summarize_with_rules(
|
|
2599
|
+
self,
|
|
2600
|
+
transcript: str,
|
|
2601
|
+
segments: list[dict[str, object]],
|
|
2602
|
+
title: str,
|
|
2603
|
+
) -> dict[str, object]:
|
|
2604
|
+
lines = [line.strip() for line in transcript.splitlines() if line.strip()]
|
|
2605
|
+
overview = "\n".join(lines[:3])[:400]
|
|
2606
|
+
bullet_points = [line.split("] ", 1)[-1][:80] for line in lines[:5]]
|
|
2607
|
+
chapters = []
|
|
2608
|
+
if segments:
|
|
2609
|
+
step = max(1, len(segments) // 4)
|
|
2610
|
+
for index in range(0, len(segments), step):
|
|
2611
|
+
group = segments[index : index + step]
|
|
2612
|
+
chapters.append(
|
|
2613
|
+
{
|
|
2614
|
+
"title": f"章节 {len(chapters) + 1}",
|
|
2615
|
+
"start": group[0]["start"],
|
|
2616
|
+
"summary": " ".join(str(item["text"]) for item in group)[:120],
|
|
2617
|
+
}
|
|
2618
|
+
)
|
|
2619
|
+
if len(chapters) >= 4:
|
|
2620
|
+
break
|
|
2621
|
+
return {
|
|
2622
|
+
"title": title or "视频摘要",
|
|
2623
|
+
"overview": overview,
|
|
2624
|
+
"bulletPoints": bullet_points,
|
|
2625
|
+
"chapters": chapters,
|
|
2626
|
+
"chapterGroups": self._build_chapter_groups_from_chapters(chapters),
|
|
2627
|
+
"knowledgeNoteMarkdown": self._build_knowledge_note_markdown(
|
|
2628
|
+
title=title or "视频摘要",
|
|
2629
|
+
overview=overview,
|
|
2630
|
+
bullet_points=bullet_points,
|
|
2631
|
+
chapters=chapters,
|
|
2632
|
+
transcript=transcript,
|
|
2633
|
+
),
|
|
2634
|
+
}
|
|
2635
|
+
|
|
2636
|
+
def _normalize_summary(
|
|
2637
|
+
self,
|
|
2638
|
+
summary: dict[str, object],
|
|
2639
|
+
transcript: str,
|
|
2640
|
+
segments: list[dict[str, object]],
|
|
2641
|
+
title: str,
|
|
2642
|
+
) -> dict[str, object]:
|
|
2643
|
+
normalized = dict(summary)
|
|
2644
|
+
normalized["title"] = str(normalized.get("title") or title or "视频摘要")
|
|
2645
|
+
|
|
2646
|
+
overview = str(normalized.get("overview") or "").strip()
|
|
2647
|
+
if not overview:
|
|
2648
|
+
overview = self._build_overview_fallback(transcript)
|
|
2649
|
+
normalized["overview"] = overview
|
|
2650
|
+
|
|
2651
|
+
bullet_points = self._coerce_bullet_points(normalized.get("bulletPoints"))
|
|
2652
|
+
if not bullet_points:
|
|
2653
|
+
bullet_points = self._build_bullet_points_fallback(overview, transcript, segments)
|
|
2654
|
+
normalized["bulletPoints"] = bullet_points
|
|
2655
|
+
|
|
2656
|
+
chapters = self._coerce_chapters(normalized.get("chapters"), segments)
|
|
2657
|
+
if not chapters:
|
|
2658
|
+
chapters = self._build_chapters_fallback(segments)
|
|
2659
|
+
normalized["chapters"] = chapters
|
|
2660
|
+
chapter_groups = self._coerce_chapter_groups(normalized.get("chapterGroups"), chapters)
|
|
2661
|
+
if not chapter_groups:
|
|
2662
|
+
chapter_groups = self._build_chapter_groups_from_chapters(chapters)
|
|
2663
|
+
normalized["chapterGroups"] = chapter_groups
|
|
2664
|
+
|
|
2665
|
+
knowledge_note_markdown = str(normalized.get("knowledgeNoteMarkdown") or "").strip()
|
|
2666
|
+
if not knowledge_note_markdown:
|
|
2667
|
+
knowledge_note_markdown = self._build_knowledge_note_markdown(
|
|
2668
|
+
title=str(normalized["title"]),
|
|
2669
|
+
overview=overview,
|
|
2670
|
+
bullet_points=bullet_points,
|
|
2671
|
+
chapters=chapters,
|
|
2672
|
+
transcript=transcript,
|
|
2673
|
+
)
|
|
2674
|
+
normalized["knowledgeNoteMarkdown"] = knowledge_note_markdown
|
|
2675
|
+
return normalized
|
|
2676
|
+
|
|
2677
|
+
def _build_knowledge_note_markdown(
|
|
2678
|
+
self,
|
|
2679
|
+
title: str,
|
|
2680
|
+
overview: str,
|
|
2681
|
+
bullet_points: list[str],
|
|
2682
|
+
chapters: list[dict[str, object]],
|
|
2683
|
+
transcript: str,
|
|
2684
|
+
) -> str:
|
|
2685
|
+
sections: list[str] = [f"# {title or '知识笔记'}"]
|
|
2686
|
+
|
|
2687
|
+
if overview:
|
|
2688
|
+
sections.extend(["", "## 核心概览", "", overview.strip()])
|
|
2689
|
+
|
|
2690
|
+
if bullet_points:
|
|
2691
|
+
sections.extend(["", "## 关键要点", ""])
|
|
2692
|
+
sections.extend(f"- {point.strip()}" for point in bullet_points if point.strip())
|
|
2693
|
+
|
|
2694
|
+
if chapters:
|
|
2695
|
+
sections.extend(["", "## 内容展开"])
|
|
2696
|
+
for index, chapter in enumerate(chapters, start=1):
|
|
2697
|
+
chapter_title = str(chapter.get("title") or f"章节 {index}").strip()
|
|
2698
|
+
chapter_summary = str(chapter.get("summary") or "").strip()
|
|
2699
|
+
start = float(chapter.get("start") or 0)
|
|
2700
|
+
sections.extend(
|
|
2701
|
+
[
|
|
2702
|
+
"",
|
|
2703
|
+
f"### {chapter_title}",
|
|
2704
|
+
"",
|
|
2705
|
+
f"- 时间点:{self._format_seconds(start)}",
|
|
2706
|
+
]
|
|
2707
|
+
)
|
|
2708
|
+
if chapter_summary:
|
|
2709
|
+
sections.extend(["", chapter_summary])
|
|
2710
|
+
|
|
2711
|
+
transcript_lines = [line.strip() for line in transcript.splitlines() if line.strip()]
|
|
2712
|
+
if transcript_lines:
|
|
2713
|
+
sections.extend(["", "## 原文摘录", ""])
|
|
2714
|
+
excerpt = transcript_lines[:6]
|
|
2715
|
+
sections.extend(f"> {line}" for line in excerpt)
|
|
2716
|
+
if len(transcript_lines) > len(excerpt):
|
|
2717
|
+
sections.extend(["", "> ..."])
|
|
2718
|
+
|
|
2719
|
+
return "\n".join(sections).strip()
|
|
2720
|
+
|
|
2721
|
+
def _build_overview_fallback(self, transcript: str) -> str:
|
|
2722
|
+
lines = [line.strip() for line in transcript.splitlines() if line.strip()]
|
|
2723
|
+
return "\n".join(lines[:3])[:400]
|
|
2724
|
+
|
|
2725
|
+
def _coerce_bullet_points(self, value: object) -> list[str]:
|
|
2726
|
+
if not isinstance(value, list):
|
|
2727
|
+
return []
|
|
2728
|
+
items = [str(item).strip() for item in value if str(item).strip()]
|
|
2729
|
+
return items[:6]
|
|
2730
|
+
|
|
2731
|
+
def _build_bullet_points_fallback(
|
|
2732
|
+
self,
|
|
2733
|
+
overview: str,
|
|
2734
|
+
transcript: str,
|
|
2735
|
+
segments: list[dict[str, object]],
|
|
2736
|
+
) -> list[str]:
|
|
2737
|
+
points: list[str] = []
|
|
2738
|
+
if overview:
|
|
2739
|
+
chunks = [part.strip(" \n\r\t-•") for part in overview.replace("。", "。\n").splitlines()]
|
|
2740
|
+
for chunk in chunks:
|
|
2741
|
+
if chunk:
|
|
2742
|
+
points.append(chunk[:80])
|
|
2743
|
+
if len(points) >= 4:
|
|
2744
|
+
break
|
|
2745
|
+
|
|
2746
|
+
if len(points) < 3:
|
|
2747
|
+
transcript_lines = [line.strip() for line in transcript.splitlines() if line.strip()]
|
|
2748
|
+
for line in transcript_lines:
|
|
2749
|
+
text = line.split("] ", 1)[-1].strip()
|
|
2750
|
+
if text and text not in points:
|
|
2751
|
+
points.append(text[:80])
|
|
2752
|
+
if len(points) >= 5:
|
|
2753
|
+
break
|
|
2754
|
+
|
|
2755
|
+
if len(points) < 3 and segments:
|
|
2756
|
+
step = max(1, len(segments) // 5)
|
|
2757
|
+
for index in range(0, len(segments), step):
|
|
2758
|
+
text = str(segments[index].get("text") or "").strip()
|
|
2759
|
+
if text and text not in points:
|
|
2760
|
+
points.append(text[:80])
|
|
2761
|
+
if len(points) >= 5:
|
|
2762
|
+
break
|
|
2763
|
+
return points[:5]
|
|
2764
|
+
|
|
2765
|
+
def _merge_structured_summary(
|
|
2766
|
+
self,
|
|
2767
|
+
merged: dict[str, object],
|
|
2768
|
+
partial_summaries: list[dict[str, object]],
|
|
2769
|
+
merged_chapters: list[dict[str, object]],
|
|
2770
|
+
transcript: str,
|
|
2771
|
+
segments: list[dict[str, object]],
|
|
2772
|
+
title: str,
|
|
2773
|
+
) -> dict[str, object]:
|
|
2774
|
+
result = dict(merged)
|
|
2775
|
+
structural_chapters = merged_chapters or self._merge_partial_chapters(partial_summaries, segments)
|
|
2776
|
+
target_count = self._suggest_chapter_count(segments)
|
|
2777
|
+
llm_chapters = self._coerce_chapters(result.get("chapters"), segments)
|
|
2778
|
+
|
|
2779
|
+
if len(llm_chapters) >= max(3, min(target_count, len(structural_chapters))) and len(llm_chapters) >= len(structural_chapters):
|
|
2780
|
+
final_chapters = llm_chapters
|
|
2781
|
+
else:
|
|
2782
|
+
final_chapters = structural_chapters or llm_chapters or self._build_chapters_fallback(segments)
|
|
2783
|
+
final_chapters = self._rebalance_chapters_for_coverage(final_chapters, segments)
|
|
2784
|
+
|
|
2785
|
+
result["title"] = str(result.get("title") or title or "视频摘要").strip()
|
|
2786
|
+
result["overview"] = str(result.get("overview") or "").strip() or self._build_overview_from_partials(partial_summaries, transcript)
|
|
2787
|
+
bullet_points = self._coerce_bullet_points(result.get("bulletPoints"))
|
|
2788
|
+
if len(bullet_points) < min(5, max(3, math.ceil(target_count / 2))):
|
|
2789
|
+
bullet_points = self._build_bullet_points_from_partials(partial_summaries, final_chapters, result["overview"], transcript, segments)
|
|
2790
|
+
result["bulletPoints"] = bullet_points
|
|
2791
|
+
result["chapters"] = final_chapters
|
|
2792
|
+
result["chapterGroups"] = self._build_chapter_groups_from_chapters(final_chapters)
|
|
2793
|
+
return result
|
|
2794
|
+
|
|
2795
|
+
def _merge_partial_chapters(
|
|
2796
|
+
self,
|
|
2797
|
+
partial_summaries: list[dict[str, object]],
|
|
2798
|
+
segments: list[dict[str, object]],
|
|
2799
|
+
) -> list[dict[str, object]]:
|
|
2800
|
+
merged: list[dict[str, object]] = []
|
|
2801
|
+
seen_keys: set[tuple[int, str]] = set()
|
|
2802
|
+
|
|
2803
|
+
for partial in partial_summaries:
|
|
2804
|
+
chunk_start = float(partial.get("chunk_start") or 0)
|
|
2805
|
+
chunk_end = float(partial.get("chunk_end") or chunk_start)
|
|
2806
|
+
chapters = self._coerce_chapters(partial.get("chapters"), segments)
|
|
2807
|
+
for chapter in chapters:
|
|
2808
|
+
start = self._align_chapter_start(float(chapter.get("start") or chunk_start), segments, chunk_start, chunk_end)
|
|
2809
|
+
title = str(chapter.get("title") or "").strip()
|
|
2810
|
+
summary = str(chapter.get("summary") or "").strip()
|
|
2811
|
+
if not summary:
|
|
2812
|
+
continue
|
|
2813
|
+
dedupe_key = (int(start), self._dedupe_text_key(title or summary))
|
|
2814
|
+
if dedupe_key in seen_keys:
|
|
2815
|
+
continue
|
|
2816
|
+
seen_keys.add(dedupe_key)
|
|
2817
|
+
self._append_or_merge_chapter(
|
|
2818
|
+
merged,
|
|
2819
|
+
{
|
|
2820
|
+
"title": title,
|
|
2821
|
+
"start": start,
|
|
2822
|
+
"summary": summary,
|
|
2823
|
+
},
|
|
2824
|
+
)
|
|
2825
|
+
|
|
2826
|
+
merged.sort(key=lambda item: float(item.get("start") or 0))
|
|
2827
|
+
normalized: list[dict[str, object]] = []
|
|
2828
|
+
for index, chapter in enumerate(merged, start=1):
|
|
2829
|
+
title = self._normalize_content_title(
|
|
2830
|
+
str(chapter.get("title") or "").strip(),
|
|
2831
|
+
fallback_text=str(chapter.get("summary") or "").strip(),
|
|
2832
|
+
fallback_prefix="章节",
|
|
2833
|
+
fallback_index=index,
|
|
2834
|
+
)
|
|
2835
|
+
normalized.append(
|
|
2836
|
+
{
|
|
2837
|
+
"title": title,
|
|
2838
|
+
"start": float(chapter.get("start") or 0),
|
|
2839
|
+
"summary": str(chapter.get("summary") or "").strip()[:160],
|
|
2840
|
+
}
|
|
2841
|
+
)
|
|
2842
|
+
return self._rebalance_chapters_for_coverage(normalized, segments)
|
|
2843
|
+
|
|
2844
|
+
def _append_or_merge_chapter(
|
|
2845
|
+
self,
|
|
2846
|
+
chapters: list[dict[str, object]],
|
|
2847
|
+
chapter: dict[str, object],
|
|
2848
|
+
) -> None:
|
|
2849
|
+
start = float(chapter.get("start") or 0)
|
|
2850
|
+
title_key = self._dedupe_text_key(str(chapter.get("title") or ""))
|
|
2851
|
+
summary = str(chapter.get("summary") or "").strip()
|
|
2852
|
+
|
|
2853
|
+
for existing in chapters:
|
|
2854
|
+
existing_start = float(existing.get("start") or 0)
|
|
2855
|
+
existing_title_key = self._dedupe_text_key(str(existing.get("title") or ""))
|
|
2856
|
+
if abs(existing_start - start) <= 45 or (title_key and title_key == existing_title_key):
|
|
2857
|
+
existing_summary = str(existing.get("summary") or "").strip()
|
|
2858
|
+
if len(summary) > len(existing_summary):
|
|
2859
|
+
existing["summary"] = summary
|
|
2860
|
+
if len(str(chapter.get("title") or "")) > len(str(existing.get("title") or "")):
|
|
2861
|
+
existing["title"] = str(chapter.get("title") or "")
|
|
2862
|
+
existing["start"] = min(existing_start, start)
|
|
2863
|
+
return
|
|
2864
|
+
chapters.append(dict(chapter))
|
|
2865
|
+
|
|
2866
|
+
def _align_chapter_start(
|
|
2867
|
+
self,
|
|
2868
|
+
start: float,
|
|
2869
|
+
segments: list[dict[str, object]],
|
|
2870
|
+
chunk_start: float,
|
|
2871
|
+
chunk_end: float,
|
|
2872
|
+
) -> float:
|
|
2873
|
+
if not segments:
|
|
2874
|
+
return max(0.0, start)
|
|
2875
|
+
candidates = [
|
|
2876
|
+
float(segment.get("start") or 0)
|
|
2877
|
+
for segment in segments
|
|
2878
|
+
if chunk_start - 1e-6 <= float(segment.get("start") or 0) <= max(chunk_end, chunk_start)
|
|
2879
|
+
]
|
|
2880
|
+
if not candidates:
|
|
2881
|
+
candidates = [float(segment.get("start") or 0) for segment in segments]
|
|
2882
|
+
if not candidates:
|
|
2883
|
+
return max(0.0, start)
|
|
2884
|
+
return min(candidates, key=lambda item: abs(item - start))
|
|
2885
|
+
|
|
2886
|
+
def _build_overview_from_partials(
|
|
2887
|
+
self,
|
|
2888
|
+
partial_summaries: list[dict[str, object]],
|
|
2889
|
+
transcript: str,
|
|
2890
|
+
) -> str:
|
|
2891
|
+
sentences: list[str] = []
|
|
2892
|
+
for partial in partial_summaries:
|
|
2893
|
+
overview = str(partial.get("overview") or "").strip()
|
|
2894
|
+
if not overview:
|
|
2895
|
+
continue
|
|
2896
|
+
for piece in re.split(r"(?<=[。!?!?])", overview):
|
|
2897
|
+
clean_piece = piece.strip()
|
|
2898
|
+
if clean_piece and clean_piece not in sentences:
|
|
2899
|
+
sentences.append(clean_piece)
|
|
2900
|
+
if len(sentences) >= 5:
|
|
2901
|
+
return "".join(sentences)[:400]
|
|
2902
|
+
return self._build_overview_fallback(transcript)
|
|
2903
|
+
|
|
2904
|
+
def _build_bullet_points_from_partials(
|
|
2905
|
+
self,
|
|
2906
|
+
partial_summaries: list[dict[str, object]],
|
|
2907
|
+
chapters: list[dict[str, object]],
|
|
2908
|
+
overview: str,
|
|
2909
|
+
transcript: str,
|
|
2910
|
+
segments: list[dict[str, object]],
|
|
2911
|
+
) -> list[str]:
|
|
2912
|
+
points: list[str] = []
|
|
2913
|
+
for partial in partial_summaries:
|
|
2914
|
+
for point in partial.get("bulletPoints") or []:
|
|
2915
|
+
clean_point = str(point).strip()
|
|
2916
|
+
if not clean_point:
|
|
2917
|
+
continue
|
|
2918
|
+
key = self._dedupe_text_key(clean_point)
|
|
2919
|
+
if any(self._dedupe_text_key(existing) == key for existing in points):
|
|
2920
|
+
continue
|
|
2921
|
+
points.append(clean_point[:88])
|
|
2922
|
+
if len(points) >= 8:
|
|
2923
|
+
return points
|
|
2924
|
+
|
|
2925
|
+
for chapter in chapters:
|
|
2926
|
+
summary = str(chapter.get("summary") or "").strip()
|
|
2927
|
+
if not summary:
|
|
2928
|
+
continue
|
|
2929
|
+
candidate = summary[:88]
|
|
2930
|
+
key = self._dedupe_text_key(candidate)
|
|
2931
|
+
if any(self._dedupe_text_key(existing) == key for existing in points):
|
|
2932
|
+
continue
|
|
2933
|
+
points.append(candidate)
|
|
2934
|
+
if len(points) >= 8:
|
|
2935
|
+
return points
|
|
2936
|
+
|
|
2937
|
+
fallback = self._build_bullet_points_fallback(overview, transcript, segments)
|
|
2938
|
+
for point in fallback:
|
|
2939
|
+
clean_point = str(point).strip()
|
|
2940
|
+
key = self._dedupe_text_key(clean_point)
|
|
2941
|
+
if clean_point and not any(self._dedupe_text_key(existing) == key for existing in points):
|
|
2942
|
+
points.append(clean_point)
|
|
2943
|
+
if len(points) >= 8:
|
|
2944
|
+
break
|
|
2945
|
+
return points[:8]
|
|
2946
|
+
|
|
2947
|
+
def _dedupe_text_key(self, value: str) -> str:
|
|
2948
|
+
normalized = re.sub(r"[\W_]+", "", str(value or "").lower())
|
|
2949
|
+
return normalized[:24]
|
|
2950
|
+
|
|
2951
|
+
def _coerce_chapters(
|
|
2952
|
+
self,
|
|
2953
|
+
value: object,
|
|
2954
|
+
segments: list[dict[str, object]] | None = None,
|
|
2955
|
+
) -> list[dict[str, object]]:
|
|
2956
|
+
if not isinstance(value, list):
|
|
2957
|
+
return []
|
|
2958
|
+
chapters: list[dict[str, object]] = []
|
|
2959
|
+
limit = self._chapter_limit(segments or [])
|
|
2960
|
+
for index, item in enumerate(value):
|
|
2961
|
+
if not isinstance(item, dict):
|
|
2962
|
+
continue
|
|
2963
|
+
summary = str(item.get("summary") or "").strip()
|
|
2964
|
+
if not summary:
|
|
2965
|
+
continue
|
|
2966
|
+
title = self._normalize_content_title(
|
|
2967
|
+
str(item.get("title") or "").strip(),
|
|
2968
|
+
fallback_text=summary,
|
|
2969
|
+
fallback_prefix="章节",
|
|
2970
|
+
fallback_index=index + 1,
|
|
2971
|
+
)
|
|
2972
|
+
chapters.append(
|
|
2973
|
+
{
|
|
2974
|
+
"title": title,
|
|
2975
|
+
"start": float(item.get("start") or 0),
|
|
2976
|
+
"summary": summary[:160],
|
|
2977
|
+
}
|
|
2978
|
+
)
|
|
2979
|
+
return chapters[:limit]
|
|
2980
|
+
|
|
2981
|
+
def _build_chapters_fallback(self, segments: list[dict[str, object]]) -> list[dict[str, object]]:
|
|
2982
|
+
if not segments:
|
|
2983
|
+
return []
|
|
2984
|
+
chapters: list[dict[str, object]] = []
|
|
2985
|
+
target_count = self._suggest_chapter_count(segments)
|
|
2986
|
+
step = max(1, math.ceil(len(segments) / max(1, target_count)))
|
|
2987
|
+
for index in range(0, len(segments), step):
|
|
2988
|
+
group = segments[index : index + step]
|
|
2989
|
+
if not group:
|
|
2990
|
+
continue
|
|
2991
|
+
summary = " ".join(str(item.get("text") or "").strip() for item in group if str(item.get("text") or "").strip())
|
|
2992
|
+
if not summary:
|
|
2993
|
+
continue
|
|
2994
|
+
chapters.append(
|
|
2995
|
+
{
|
|
2996
|
+
"title": self._normalize_content_title(
|
|
2997
|
+
"",
|
|
2998
|
+
fallback_text=summary,
|
|
2999
|
+
fallback_prefix="章节",
|
|
3000
|
+
fallback_index=len(chapters) + 1,
|
|
3001
|
+
),
|
|
3002
|
+
"start": float(group[0].get("start") or 0),
|
|
3003
|
+
"summary": summary[:160],
|
|
3004
|
+
}
|
|
3005
|
+
)
|
|
3006
|
+
if len(chapters) >= self._chapter_limit(segments):
|
|
3007
|
+
break
|
|
3008
|
+
return self._rebalance_chapters_for_coverage(chapters, segments)
|
|
3009
|
+
|
|
3010
|
+
def _coerce_chapter_groups(
|
|
3011
|
+
self,
|
|
3012
|
+
value: object,
|
|
3013
|
+
chapters: list[dict[str, object]] | None = None,
|
|
3014
|
+
) -> list[dict[str, object]]:
|
|
3015
|
+
if not isinstance(value, list):
|
|
3016
|
+
return []
|
|
3017
|
+
groups: list[dict[str, object]] = []
|
|
3018
|
+
limit = self._chapter_group_limit(chapters or [])
|
|
3019
|
+
for index, item in enumerate(value):
|
|
3020
|
+
if not isinstance(item, dict):
|
|
3021
|
+
continue
|
|
3022
|
+
children = self._coerce_chapters(item.get("children"), chapters)
|
|
3023
|
+
if not children:
|
|
3024
|
+
continue
|
|
3025
|
+
summary = str(item.get("summary") or "").strip()[:120]
|
|
3026
|
+
title = self._normalize_content_title(
|
|
3027
|
+
str(item.get("title") or "").strip(),
|
|
3028
|
+
fallback_text=summary,
|
|
3029
|
+
fallback_prefix="主题",
|
|
3030
|
+
fallback_index=index + 1,
|
|
3031
|
+
child_titles=[str(child.get("title") or "").strip() for child in children],
|
|
3032
|
+
)
|
|
3033
|
+
groups.append(
|
|
3034
|
+
{
|
|
3035
|
+
"title": title,
|
|
3036
|
+
"start": float(item.get("start") or children[0].get("start") or 0),
|
|
3037
|
+
"summary": summary,
|
|
3038
|
+
"children": children,
|
|
3039
|
+
}
|
|
3040
|
+
)
|
|
3041
|
+
return groups[:limit]
|
|
3042
|
+
|
|
3043
|
+
def _build_chapter_groups_from_chapters(self, chapters: list[dict[str, object]]) -> list[dict[str, object]]:
|
|
3044
|
+
if not chapters:
|
|
3045
|
+
return []
|
|
3046
|
+
desired_groups = self._suggest_chapter_group_count(chapters)
|
|
3047
|
+
group_size = max(1, math.ceil(len(chapters) / max(1, desired_groups)))
|
|
3048
|
+
groups: list[dict[str, object]] = []
|
|
3049
|
+
for index in range(0, len(chapters), group_size):
|
|
3050
|
+
items = chapters[index : index + group_size]
|
|
3051
|
+
if not items:
|
|
3052
|
+
continue
|
|
3053
|
+
first = items[0]
|
|
3054
|
+
last = items[-1]
|
|
3055
|
+
summary = ";".join(str(item.get("title") or "").strip() for item in items if str(item.get("title") or "").strip())
|
|
3056
|
+
groups.append(
|
|
3057
|
+
{
|
|
3058
|
+
"title": self._normalize_content_title(
|
|
3059
|
+
"",
|
|
3060
|
+
fallback_text=summary,
|
|
3061
|
+
fallback_prefix="主题",
|
|
3062
|
+
fallback_index=len(groups) + 1,
|
|
3063
|
+
child_titles=[str(item.get("title") or "").strip() for item in items],
|
|
3064
|
+
),
|
|
3065
|
+
"start": float(first.get("start") or 0),
|
|
3066
|
+
"summary": summary[:120] or f"{self._format_seconds(float(first.get('start') or 0))} - {self._format_seconds(float(last.get('start') or 0))}",
|
|
3067
|
+
"children": items,
|
|
3068
|
+
}
|
|
3069
|
+
)
|
|
3070
|
+
return groups[: self._chapter_group_limit(chapters)]
|
|
3071
|
+
|
|
3072
|
+
def _chapter_limit(self, segments: list[dict[str, object]]) -> int:
|
|
3073
|
+
if not segments:
|
|
3074
|
+
return 12
|
|
3075
|
+
|
|
3076
|
+
# 检测是否为全集总结场景:segments 的 start 值是连续整数(分 P 号),且范围较大
|
|
3077
|
+
is_aggregate_series = self._is_aggregate_series_segments(segments)
|
|
3078
|
+
|
|
3079
|
+
if is_aggregate_series:
|
|
3080
|
+
# 全集总结场景:根据 P 数决定限制,最多允许 100 个章节
|
|
3081
|
+
page_count = len({int(float(item.get("start") or 0)) for item in segments if item.get("start") is not None})
|
|
3082
|
+
return max(20, min(100, page_count))
|
|
3083
|
+
|
|
3084
|
+
# 普通单 P 视频场景:基于时长计算
|
|
3085
|
+
target = self._suggest_chapter_count(segments)
|
|
3086
|
+
return max(8, min(48, target * 2))
|
|
3087
|
+
|
|
3088
|
+
def _is_aggregate_series_segments(self, segments: list[dict[str, object]]) -> bool:
|
|
3089
|
+
"""检测 segments 是否属于全集总结场景(start 值为分 P 号而非时间戳)"""
|
|
3090
|
+
if len(segments) < 5:
|
|
3091
|
+
return False
|
|
3092
|
+
|
|
3093
|
+
# 提取所有 start 值并转换为整数
|
|
3094
|
+
start_values = []
|
|
3095
|
+
for item in segments:
|
|
3096
|
+
start = item.get("start")
|
|
3097
|
+
if start is not None:
|
|
3098
|
+
try:
|
|
3099
|
+
start_values.append(int(float(start)))
|
|
3100
|
+
except (TypeError, ValueError):
|
|
3101
|
+
continue
|
|
3102
|
+
|
|
3103
|
+
if len(start_values) < 5:
|
|
3104
|
+
return False
|
|
3105
|
+
|
|
3106
|
+
# 检查是否为连续或接近连续的整数序列(分 P 号)
|
|
3107
|
+
# 全集总结的 segments 通常 start 值范围较大且为整数
|
|
3108
|
+
min_start = min(start_values)
|
|
3109
|
+
max_start = max(start_values)
|
|
3110
|
+
range_span = max_start - min_start
|
|
3111
|
+
|
|
3112
|
+
# 如果 start 值范围超过 10 且平均间隔接近 1,判断为全集总结
|
|
3113
|
+
if range_span > 10:
|
|
3114
|
+
unique_starts = sorted(set(start_values))
|
|
3115
|
+
if len(unique_starts) >= 5:
|
|
3116
|
+
# 计算平均间隔
|
|
3117
|
+
avg_gap = (unique_starts[-1] - unique_starts[0]) / (len(unique_starts) - 1) if len(unique_starts) > 1 else 1
|
|
3118
|
+
# 如果平均间隔在 0.8-1.5 之间,很可能是分 P 号
|
|
3119
|
+
return 0.8 <= avg_gap <= 1.5
|
|
3120
|
+
|
|
3121
|
+
return False
|
|
3122
|
+
|
|
3123
|
+
def _chapter_group_limit(self, chapters: list[dict[str, object]]) -> int:
|
|
3124
|
+
if not chapters:
|
|
3125
|
+
return 5
|
|
3126
|
+
return max(2, min(8, self._suggest_chapter_group_count(chapters) + 1))
|
|
3127
|
+
|
|
3128
|
+
def _suggest_chapter_count(self, segments: list[dict[str, object]]) -> int:
|
|
3129
|
+
if not segments:
|
|
3130
|
+
return 4
|
|
3131
|
+
|
|
3132
|
+
# 检测是否为全集总结场景
|
|
3133
|
+
is_aggregate_series = self._is_aggregate_series_segments(segments)
|
|
3134
|
+
|
|
3135
|
+
if is_aggregate_series:
|
|
3136
|
+
# 全集总结场景:根据 P 数决定章节数,每 3-5P 一个章节
|
|
3137
|
+
page_count = len({int(float(item.get("start") or 0)) for item in segments if item.get("start") is not None})
|
|
3138
|
+
# 目标章节数:每 4P 一个章节,最少 8 个,最多 50 个
|
|
3139
|
+
return max(8, min(50, math.ceil(page_count / 4)))
|
|
3140
|
+
|
|
3141
|
+
# 普通单 P 视频场景:基于时长计算
|
|
3142
|
+
first_start = float(segments[0].get("start") or 0)
|
|
3143
|
+
last_end = float(segments[-1].get("end") or segments[-1].get("start") or first_start)
|
|
3144
|
+
duration = max(0.0, last_end - first_start)
|
|
3145
|
+
segment_count = len(segments)
|
|
3146
|
+
duration_minutes = duration / 60.0 if duration > 0 else 0.0
|
|
3147
|
+
duration_target = math.ceil(duration_minutes / 3.5) if duration_minutes > 0 else 4
|
|
3148
|
+
density_target = math.ceil(segment_count / 120) if segment_count > 0 else 0
|
|
3149
|
+
return max(4, min(28, max(duration_target, density_target, 4)))
|
|
3150
|
+
|
|
3151
|
+
def _suggest_chapter_group_count(self, chapters: list[dict[str, object]]) -> int:
|
|
3152
|
+
chapter_count = len(chapters)
|
|
3153
|
+
return max(1, min(8, math.ceil(chapter_count / 4)))
|
|
3154
|
+
|
|
3155
|
+
def _rebalance_chapters_for_coverage(
|
|
3156
|
+
self,
|
|
3157
|
+
chapters: list[dict[str, object]],
|
|
3158
|
+
segments: list[dict[str, object]],
|
|
3159
|
+
) -> list[dict[str, object]]:
|
|
3160
|
+
if not chapters:
|
|
3161
|
+
return []
|
|
3162
|
+
|
|
3163
|
+
# 检测是否为全集总结场景
|
|
3164
|
+
is_aggregate_series = self._is_aggregate_series_segments(segments)
|
|
3165
|
+
|
|
3166
|
+
ordered = sorted(chapters, key=lambda item: float(item.get("start") or 0))
|
|
3167
|
+
target_count = self._suggest_chapter_count(segments)
|
|
3168
|
+
|
|
3169
|
+
if len(ordered) <= target_count:
|
|
3170
|
+
return ordered
|
|
3171
|
+
|
|
3172
|
+
# 全集总结场景:使用基于 P 数的窗口划分
|
|
3173
|
+
if is_aggregate_series:
|
|
3174
|
+
page_count = len({int(float(item.get("start") or 0)) for item in segments if item.get("start") is not None})
|
|
3175
|
+
window_size = max(3.0, page_count / max(1, target_count)) # 每窗口约 3-5P
|
|
3176
|
+
|
|
3177
|
+
buckets: list[list[dict[str, object]]] = []
|
|
3178
|
+
for chapter in ordered:
|
|
3179
|
+
start = float(chapter.get("start") or 0)
|
|
3180
|
+
bucket_index = min(target_count - 1, max(0, int((start - 1) / window_size)))
|
|
3181
|
+
while len(buckets) <= bucket_index:
|
|
3182
|
+
buckets.append([])
|
|
3183
|
+
buckets[bucket_index].append(chapter)
|
|
3184
|
+
|
|
3185
|
+
merged_buckets: list[dict[str, object]] = []
|
|
3186
|
+
for bucket in buckets:
|
|
3187
|
+
if not bucket:
|
|
3188
|
+
continue
|
|
3189
|
+
merged_buckets.append(self._merge_chapter_bucket(bucket))
|
|
3190
|
+
|
|
3191
|
+
if len(merged_buckets) > target_count:
|
|
3192
|
+
sampled: list[dict[str, object]] = []
|
|
3193
|
+
for index in range(target_count):
|
|
3194
|
+
source_index = min(len(merged_buckets) - 1, round(index * (len(merged_buckets) - 1) / max(1, target_count - 1)))
|
|
3195
|
+
sampled.append(merged_buckets[source_index])
|
|
3196
|
+
deduped: list[dict[str, object]] = []
|
|
3197
|
+
seen: set[tuple[int, str]] = set()
|
|
3198
|
+
for chapter in sampled:
|
|
3199
|
+
key = (int(float(chapter.get("start") or 0)), self._dedupe_text_key(str(chapter.get("title") or "")))
|
|
3200
|
+
if key in seen:
|
|
3201
|
+
continue
|
|
3202
|
+
seen.add(key)
|
|
3203
|
+
deduped.append(chapter)
|
|
3204
|
+
merged_buckets = deduped
|
|
3205
|
+
|
|
3206
|
+
return merged_buckets
|
|
3207
|
+
|
|
3208
|
+
# 普通单 P 视频场景:基于时长的窗口划分
|
|
3209
|
+
first_start = float(segments[0].get("start") or ordered[0].get("start") or 0) if segments else float(ordered[0].get("start") or 0)
|
|
3210
|
+
last_end = (
|
|
3211
|
+
float(segments[-1].get("end") or segments[-1].get("start") or ordered[-1].get("start") or first_start)
|
|
3212
|
+
if segments
|
|
3213
|
+
else float(ordered[-1].get("start") or first_start)
|
|
3214
|
+
)
|
|
3215
|
+
duration = max(1.0, last_end - first_start)
|
|
3216
|
+
window_size = max(45.0, duration / max(1, target_count))
|
|
3217
|
+
|
|
3218
|
+
buckets: list[list[dict[str, object]]] = []
|
|
3219
|
+
for chapter in ordered:
|
|
3220
|
+
start = float(chapter.get("start") or 0)
|
|
3221
|
+
bucket_index = min(target_count - 1, max(0, int((start - first_start) / window_size)))
|
|
3222
|
+
while len(buckets) <= bucket_index:
|
|
3223
|
+
buckets.append([])
|
|
3224
|
+
buckets[bucket_index].append(chapter)
|
|
3225
|
+
|
|
3226
|
+
merged_buckets: list[dict[str, object]] = []
|
|
3227
|
+
for bucket in buckets:
|
|
3228
|
+
if not bucket:
|
|
3229
|
+
continue
|
|
3230
|
+
merged_buckets.append(self._merge_chapter_bucket(bucket))
|
|
3231
|
+
|
|
3232
|
+
if len(merged_buckets) > target_count:
|
|
3233
|
+
sampled: list[dict[str, object]] = []
|
|
3234
|
+
for index in range(target_count):
|
|
3235
|
+
source_index = min(len(merged_buckets) - 1, round(index * (len(merged_buckets) - 1) / max(1, target_count - 1)))
|
|
3236
|
+
sampled.append(merged_buckets[source_index])
|
|
3237
|
+
deduped: list[dict[str, object]] = []
|
|
3238
|
+
seen: set[tuple[int, str]] = set()
|
|
3239
|
+
for chapter in sampled:
|
|
3240
|
+
key = (int(float(chapter.get("start") or 0)), self._dedupe_text_key(str(chapter.get("title") or "")))
|
|
3241
|
+
if key in seen:
|
|
3242
|
+
continue
|
|
3243
|
+
seen.add(key)
|
|
3244
|
+
deduped.append(chapter)
|
|
3245
|
+
merged_buckets = deduped
|
|
3246
|
+
|
|
3247
|
+
return merged_buckets
|
|
3248
|
+
|
|
3249
|
+
def _merge_chapter_bucket(self, bucket: list[dict[str, object]]) -> dict[str, object]:
|
|
3250
|
+
first = bucket[0]
|
|
3251
|
+
titles = [str(item.get("title") or "").strip() for item in bucket if str(item.get("title") or "").strip()]
|
|
3252
|
+
summaries = [str(item.get("summary") or "").strip() for item in bucket if str(item.get("summary") or "").strip()]
|
|
3253
|
+
title = self._normalize_content_title(
|
|
3254
|
+
titles[0] if titles else "",
|
|
3255
|
+
fallback_text=";".join(summaries),
|
|
3256
|
+
fallback_prefix="章节",
|
|
3257
|
+
fallback_index=1,
|
|
3258
|
+
child_titles=titles[1:],
|
|
3259
|
+
)
|
|
3260
|
+
summary_parts: list[str] = []
|
|
3261
|
+
seen_summary_keys: set[str] = set()
|
|
3262
|
+
for summary in summaries:
|
|
3263
|
+
key = self._dedupe_text_key(summary)
|
|
3264
|
+
if not key or key in seen_summary_keys:
|
|
3265
|
+
continue
|
|
3266
|
+
seen_summary_keys.add(key)
|
|
3267
|
+
summary_parts.append(summary)
|
|
3268
|
+
if len(";".join(summary_parts)) >= 150:
|
|
3269
|
+
break
|
|
3270
|
+
return {
|
|
3271
|
+
"title": title,
|
|
3272
|
+
"start": float(first.get("start") or 0),
|
|
3273
|
+
"summary": ";".join(summary_parts)[:160],
|
|
3274
|
+
}
|
|
3275
|
+
|
|
3276
|
+
def _normalize_content_title(
|
|
3277
|
+
self,
|
|
3278
|
+
title: str,
|
|
3279
|
+
fallback_text: str = "",
|
|
3280
|
+
fallback_prefix: str = "章节",
|
|
3281
|
+
fallback_index: int = 1,
|
|
3282
|
+
child_titles: list[str] | None = None,
|
|
3283
|
+
) -> str:
|
|
3284
|
+
clean_title = self._clean_title(title)
|
|
3285
|
+
if clean_title and not self._is_placeholder_title(clean_title):
|
|
3286
|
+
return clean_title
|
|
3287
|
+
|
|
3288
|
+
for child_title in child_titles or []:
|
|
3289
|
+
normalized = self._clean_title(child_title)
|
|
3290
|
+
if normalized and not self._is_placeholder_title(normalized):
|
|
3291
|
+
return normalized
|
|
3292
|
+
|
|
3293
|
+
derived_from_text = self._derive_title_from_text(fallback_text)
|
|
3294
|
+
if derived_from_text:
|
|
3295
|
+
return derived_from_text
|
|
3296
|
+
|
|
3297
|
+
return f"{fallback_prefix} {fallback_index}"
|
|
3298
|
+
|
|
3299
|
+
def _clean_title(self, value: str) -> str:
|
|
3300
|
+
title = str(value or "").strip()
|
|
3301
|
+
title = re.sub(r"^[\-•\d\.\)\(、\s]+", "", title)
|
|
3302
|
+
if not title:
|
|
3303
|
+
return ""
|
|
3304
|
+
if self._looks_like_rich_title(title):
|
|
3305
|
+
return self._truncate_rich_title(title, max_length=52, extension=24)
|
|
3306
|
+
return title[:24]
|
|
3307
|
+
|
|
3308
|
+
def _looks_like_rich_title(self, value: str) -> bool:
|
|
3309
|
+
title = str(value or "")
|
|
3310
|
+
return bool("$" in title or re.search(r"\\[A-Za-z]+|[_^{}]", title))
|
|
3311
|
+
|
|
3312
|
+
def _truncate_rich_title(self, value: str, *, max_length: int, extension: int = 0) -> str:
|
|
3313
|
+
title = str(value or "").strip()
|
|
3314
|
+
if len(title) <= max_length:
|
|
3315
|
+
return title
|
|
3316
|
+
|
|
3317
|
+
in_math = False
|
|
3318
|
+
last_safe_break = None
|
|
3319
|
+
last_safe_end = None
|
|
3320
|
+
hard_limit = min(len(title), max_length + max(0, extension))
|
|
3321
|
+
|
|
3322
|
+
for index, char in enumerate(title[:hard_limit]):
|
|
3323
|
+
if char == "$" and (index == 0 or title[index - 1] != "\\"):
|
|
3324
|
+
in_math = not in_math
|
|
3325
|
+
if not in_math:
|
|
3326
|
+
last_safe_end = index + 1
|
|
3327
|
+
if char.isspace() or char in ",,。;;::、)])】":
|
|
3328
|
+
last_safe_break = index + 1
|
|
3329
|
+
if index + 1 >= max_length:
|
|
3330
|
+
break
|
|
3331
|
+
|
|
3332
|
+
if in_math:
|
|
3333
|
+
opening_index = title.rfind("$", 0, hard_limit)
|
|
3334
|
+
if opening_index >= 8:
|
|
3335
|
+
return title[:opening_index].rstrip(",,。;;::、 ")
|
|
3336
|
+
|
|
3337
|
+
safe_cut = last_safe_break or last_safe_end
|
|
3338
|
+
if safe_cut is None or safe_cut <= 0:
|
|
3339
|
+
return title[:hard_limit].rstrip(",,。;;::、 ")
|
|
3340
|
+
return title[:safe_cut].rstrip(",,。;;::、 ")
|
|
3341
|
+
|
|
3342
|
+
def _is_placeholder_title(self, value: str) -> bool:
|
|
3343
|
+
title = str(value or "").strip()
|
|
3344
|
+
if not title:
|
|
3345
|
+
return True
|
|
3346
|
+
normalized = title.lower()
|
|
3347
|
+
return bool(
|
|
3348
|
+
re.fullmatch(r"(大)?章节\s*\d+", title)
|
|
3349
|
+
or re.fullmatch(r"主题\s*\d+", title)
|
|
3350
|
+
or re.fullmatch(r"第?\s*\d+\s*(章|节|部分)", title)
|
|
3351
|
+
or re.fullmatch(r"(part|section|chapter)\s*[-::]?\s*\d+", normalized)
|
|
3352
|
+
)
|
|
3353
|
+
|
|
3354
|
+
def _derive_title_from_text(self, value: str) -> str:
|
|
3355
|
+
text = str(value or "").strip()
|
|
3356
|
+
if not text:
|
|
3357
|
+
return ""
|
|
3358
|
+
text = re.sub(r"^(这一部分|本部分|这里|该部分|这一章|本章|本节|这一节)(主要)?(讲|介绍|讨论|说明|分析|围绕)?", "", text)
|
|
3359
|
+
text = re.split(r"[。;!!??\n]", text, maxsplit=1)[0].strip(" ::-")
|
|
3360
|
+
text = re.sub(r"\s+", "", text)
|
|
3361
|
+
if not text:
|
|
3362
|
+
return ""
|
|
3363
|
+
if len(text) > 24:
|
|
3364
|
+
text = text[:24].rstrip(",,、;;::")
|
|
3365
|
+
return text
|
|
3366
|
+
|
|
3367
|
+
def _export_result(
|
|
3368
|
+
self,
|
|
3369
|
+
task_dir: Path,
|
|
3370
|
+
title: str,
|
|
3371
|
+
transcript: str,
|
|
3372
|
+
segments: list[dict[str, object]],
|
|
3373
|
+
summary: dict[str, object],
|
|
3374
|
+
) -> TaskResult:
|
|
3375
|
+
snapshot_result = self._export_transcript_snapshot(task_dir, title, transcript, segments)
|
|
3376
|
+
transcript_path = Path(snapshot_result.artifacts["transcript_path"])
|
|
3377
|
+
summary_path = Path(snapshot_result.artifacts["summary_path"])
|
|
3378
|
+
knowledge_note_path = task_dir / "knowledge_note.md"
|
|
3379
|
+
knowledge_note_markdown = str(summary.get("knowledgeNoteMarkdown") or "").strip()
|
|
3380
|
+
summary_path.write_text(
|
|
3381
|
+
json.dumps({"title": title, "summary": summary, "segments": segments}, ensure_ascii=False, indent=2),
|
|
3382
|
+
encoding="utf-8",
|
|
3383
|
+
)
|
|
3384
|
+
knowledge_note_path.write_text(knowledge_note_markdown, encoding="utf-8")
|
|
3385
|
+
logger.info(
|
|
3386
|
+
"result exported transcript_path=%s summary_path=%s knowledge_note_path=%s",
|
|
3387
|
+
transcript_path,
|
|
3388
|
+
summary_path,
|
|
3389
|
+
knowledge_note_path,
|
|
3390
|
+
)
|
|
3391
|
+
return self._build_task_result(
|
|
3392
|
+
transcript,
|
|
3393
|
+
summary,
|
|
3394
|
+
artifacts={
|
|
3395
|
+
"transcript_path": str(transcript_path),
|
|
3396
|
+
"summary_path": str(summary_path),
|
|
3397
|
+
"knowledge_note_path": str(knowledge_note_path),
|
|
3398
|
+
},
|
|
3399
|
+
)
|
|
3400
|
+
|
|
3401
|
+
def _export_transcript_snapshot(
|
|
3402
|
+
self,
|
|
3403
|
+
task_dir: Path,
|
|
3404
|
+
title: str,
|
|
3405
|
+
transcript: str,
|
|
3406
|
+
segments: list[dict[str, object]],
|
|
3407
|
+
) -> TaskResult:
|
|
3408
|
+
transcript_path = task_dir / "transcript.txt"
|
|
3409
|
+
summary_path = task_dir / "summary.json"
|
|
3410
|
+
transcript_path.write_text(transcript, encoding="utf-8")
|
|
3411
|
+
summary_path.write_text(
|
|
3412
|
+
json.dumps({"title": title, "summary": {}, "segments": segments}, ensure_ascii=False, indent=2),
|
|
3413
|
+
encoding="utf-8",
|
|
3414
|
+
)
|
|
3415
|
+
return self._build_task_result(
|
|
3416
|
+
transcript,
|
|
3417
|
+
{},
|
|
3418
|
+
artifacts={
|
|
3419
|
+
"transcript_path": str(transcript_path),
|
|
3420
|
+
"summary_path": str(summary_path),
|
|
3421
|
+
},
|
|
3422
|
+
)
|
|
3423
|
+
|
|
3424
|
+
def _build_task_result(
|
|
3425
|
+
self,
|
|
3426
|
+
transcript: str,
|
|
3427
|
+
summary: dict[str, object],
|
|
3428
|
+
artifacts: dict[str, str] | None = None,
|
|
3429
|
+
) -> TaskResult:
|
|
3430
|
+
knowledge_note_markdown = str(summary.get("knowledgeNoteMarkdown") or "").strip()
|
|
3431
|
+
return TaskResult(
|
|
3432
|
+
overview=str(summary.get("overview") or ""),
|
|
3433
|
+
knowledge_note_markdown=knowledge_note_markdown,
|
|
3434
|
+
transcript_text=transcript,
|
|
3435
|
+
segment_summaries=[str(item["summary"]) for item in summary.get("chapters", [])],
|
|
3436
|
+
key_points=[str(item) for item in summary.get("bulletPoints", [])],
|
|
3437
|
+
timeline=[
|
|
3438
|
+
{
|
|
3439
|
+
"title": str(item["title"]),
|
|
3440
|
+
"start": item["start"],
|
|
3441
|
+
"summary": str(item["summary"]),
|
|
3442
|
+
}
|
|
3443
|
+
for item in summary.get("chapters", [])
|
|
3444
|
+
],
|
|
3445
|
+
chapter_groups=[
|
|
3446
|
+
{
|
|
3447
|
+
"title": str(group.get("title") or ""),
|
|
3448
|
+
"start": group.get("start"),
|
|
3449
|
+
"summary": str(group.get("summary") or ""),
|
|
3450
|
+
"children": [
|
|
3451
|
+
{
|
|
3452
|
+
"title": str(item.get("title") or ""),
|
|
3453
|
+
"start": item.get("start"),
|
|
3454
|
+
"summary": str(item.get("summary") or ""),
|
|
3455
|
+
}
|
|
3456
|
+
for item in (group.get("children") or [])
|
|
3457
|
+
if isinstance(item, dict)
|
|
3458
|
+
],
|
|
3459
|
+
}
|
|
3460
|
+
for group in summary.get("chapterGroups", [])
|
|
3461
|
+
if isinstance(group, dict)
|
|
3462
|
+
],
|
|
3463
|
+
artifacts=artifacts or {},
|
|
3464
|
+
llm_prompt_tokens=_safe_int(summary.get("llm_prompt_tokens")),
|
|
3465
|
+
llm_completion_tokens=_safe_int(summary.get("llm_completion_tokens")),
|
|
3466
|
+
llm_total_tokens=_safe_int(summary.get("llm_total_tokens")),
|
|
3467
|
+
)
|