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,492 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import time
|
|
4
|
+
from collections.abc import Callable, Iterator
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
|
|
7
|
+
from fastapi import HTTPException
|
|
8
|
+
|
|
9
|
+
from video_sum_infra.config import ServiceSettings
|
|
10
|
+
from video_sum_service.knowledge.index_service import KnowledgeIndexService, format_anchor_seconds
|
|
11
|
+
from video_sum_service.knowledge.local_llm import chat_knowledge_llm, stream_knowledge_llm
|
|
12
|
+
from video_sum_service.knowledge.tag_service import TagService
|
|
13
|
+
from video_sum_service.repository import SqliteTaskRepository
|
|
14
|
+
from video_sum_service.schemas import KnowledgeAskResponse, KnowledgeChatHistoryItem, KnowledgeSourceRef
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
KNOWLEDGE_QA_SYSTEM_PROMPT = (
|
|
18
|
+
"你是 BiliSum 的本地知识库助手,任务是把用户的视频知识库整理成可信、有人味的学习洞察。"
|
|
19
|
+
"请严格基于给出的知识库片段回答,不要编造片段之外的具体事实、时间线或个人经历。"
|
|
20
|
+
"但只要片段能支持合理归纳,就要主动多回答一点:概括主题、解释为什么、"
|
|
21
|
+
"补充相关分支,并给出可行动的学习建议。"
|
|
22
|
+
"当用户询问“我最近在学什么”“我主要关注什么”等学习画像类问题时,"
|
|
23
|
+
"把“知识库中的视频内容”视为可用证据,直接归纳学习主题;"
|
|
24
|
+
"不要说“暂无您的个人学习记录”“建议提供更多上下文/学习记录”这类没有帮助的话。"
|
|
25
|
+
"如果证据有限,请用温和的限定语,例如“从目前命中的视频看”“更像是”“可以初步判断”,"
|
|
26
|
+
"然后仍然给出最大化有用的答案。"
|
|
27
|
+
"如果确实完全没有相关片段,只需简短说明“这次没有检索到足够相关的知识片段”,"
|
|
28
|
+
"并给出一个可继续追问的方向。"
|
|
29
|
+
"语气要高情商、自然、有陪伴感,同时保持学术表达的清晰和克制;回答使用中文。"
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
EMPTY_KNOWLEDGE_ANSWER = "这次没有检索到足够相关的知识片段。可以换一个关键词,或先到工作台用标签缩小范围。"
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@dataclass(frozen=True)
|
|
36
|
+
class KnowledgeAgentPlan:
|
|
37
|
+
query: str
|
|
38
|
+
search_query: str
|
|
39
|
+
context_limit: int
|
|
40
|
+
history: list[KnowledgeChatHistoryItem]
|
|
41
|
+
steps: list[str]
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
@dataclass
|
|
45
|
+
class KnowledgeAgentRun:
|
|
46
|
+
plan: KnowledgeAgentPlan
|
|
47
|
+
chunks: list[dict[str, object]] = field(default_factory=list)
|
|
48
|
+
context_blocks: list[str] = field(default_factory=list)
|
|
49
|
+
sources: list[KnowledgeSourceRef] = field(default_factory=list)
|
|
50
|
+
answer: str = ""
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _tool_event(
|
|
54
|
+
tool_id: str,
|
|
55
|
+
label: str,
|
|
56
|
+
status: str,
|
|
57
|
+
detail: str,
|
|
58
|
+
meta: dict[str, object] | None = None,
|
|
59
|
+
) -> tuple[str, dict[str, object]]:
|
|
60
|
+
payload: dict[str, object] = {
|
|
61
|
+
"id": tool_id,
|
|
62
|
+
"label": label,
|
|
63
|
+
"status": status,
|
|
64
|
+
"detail": detail,
|
|
65
|
+
}
|
|
66
|
+
if meta:
|
|
67
|
+
payload["meta"] = meta
|
|
68
|
+
return "tool", payload
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _normalize_history(history: list[KnowledgeChatHistoryItem] | None, limit: int = 8) -> list[KnowledgeChatHistoryItem]:
|
|
72
|
+
normalized: list[KnowledgeChatHistoryItem] = []
|
|
73
|
+
for item in (history or [])[-limit:]:
|
|
74
|
+
role = "assistant" if str(item.role).strip().lower() == "assistant" else "user"
|
|
75
|
+
content = str(item.content or "").strip()
|
|
76
|
+
if not content:
|
|
77
|
+
continue
|
|
78
|
+
normalized.append(KnowledgeChatHistoryItem(role=role, content=content[:1200]))
|
|
79
|
+
return normalized
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _format_history_for_prompt(history: list[KnowledgeChatHistoryItem]) -> str:
|
|
83
|
+
lines: list[str] = []
|
|
84
|
+
for item in history:
|
|
85
|
+
label = "用户" if item.role == "user" else "助手"
|
|
86
|
+
lines.append(f"{label}:{item.content}")
|
|
87
|
+
return "\n".join(lines)
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def _build_contextual_search_query(query: str, history: list[KnowledgeChatHistoryItem]) -> str:
|
|
91
|
+
if not history:
|
|
92
|
+
return query
|
|
93
|
+
recent_user_turns = [item.content for item in history if item.role == "user"][-3:]
|
|
94
|
+
if not recent_user_turns:
|
|
95
|
+
return query
|
|
96
|
+
return "\n".join(["当前问题:", query, "近期追问线索:", *recent_user_turns])
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def _build_knowledge_user_prompt(
|
|
100
|
+
query: str,
|
|
101
|
+
context_blocks: list[str],
|
|
102
|
+
history: list[KnowledgeChatHistoryItem] | None = None,
|
|
103
|
+
) -> str:
|
|
104
|
+
history_text = _format_history_for_prompt(_normalize_history(history))
|
|
105
|
+
history_block = f"本轮会话上下文:\n---\n{history_text}\n---\n\n" if history_text else ""
|
|
106
|
+
return (
|
|
107
|
+
f"用户问题:{query}\n\n"
|
|
108
|
+
+ history_block
|
|
109
|
+
+ "相关视频内容:\n---\n"
|
|
110
|
+
+ "\n\n".join(context_blocks)
|
|
111
|
+
+ "\n---\n\n"
|
|
112
|
+
+ "请按下面原则组织回答:\n"
|
|
113
|
+
"1. 先直接回答问题,不要先道歉或免责声明。\n"
|
|
114
|
+
"2. 如果适合,按主题分组,并说明每组主题背后的依据。\n"
|
|
115
|
+
"3. 可以给出“下一步学习建议”或“知识结构判断”,但必须和片段内容相关。\n"
|
|
116
|
+
"4. 可以利用会话上下文理解代词、追问和用户偏好,但知识事实仍以视频片段为准。\n"
|
|
117
|
+
"5. 不要输出“暂无个人学习记录”“请提供更多上下文或学习记录”这类空泛句子。"
|
|
118
|
+
)
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
class KnowledgeAgent:
|
|
122
|
+
def __init__(
|
|
123
|
+
self,
|
|
124
|
+
repository: SqliteTaskRepository,
|
|
125
|
+
index_service: KnowledgeIndexService,
|
|
126
|
+
settings: ServiceSettings,
|
|
127
|
+
) -> None:
|
|
128
|
+
self._repository = repository
|
|
129
|
+
self._index_service = index_service
|
|
130
|
+
self._settings = settings
|
|
131
|
+
self._tool_handlers = {
|
|
132
|
+
"conversation_context": self._prepare_context,
|
|
133
|
+
"semantic_search": self._run_semantic_search,
|
|
134
|
+
"context_builder": self._build_answer_context,
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
def make_plan(
|
|
138
|
+
self,
|
|
139
|
+
query: str,
|
|
140
|
+
context_limit: int = 5,
|
|
141
|
+
history: list[KnowledgeChatHistoryItem] | None = None,
|
|
142
|
+
) -> KnowledgeAgentPlan:
|
|
143
|
+
cleaned_query = str(query or "").strip()
|
|
144
|
+
if not cleaned_query:
|
|
145
|
+
raise HTTPException(status_code=400, detail="问题不能为空。")
|
|
146
|
+
|
|
147
|
+
normalized_history = _normalize_history(history)
|
|
148
|
+
steps = ["conversation_context", "semantic_search", "context_builder", "knowledge_llm"]
|
|
149
|
+
return KnowledgeAgentPlan(
|
|
150
|
+
query=cleaned_query,
|
|
151
|
+
search_query=_build_contextual_search_query(cleaned_query, normalized_history),
|
|
152
|
+
context_limit=max(1, context_limit),
|
|
153
|
+
history=normalized_history,
|
|
154
|
+
steps=steps,
|
|
155
|
+
)
|
|
156
|
+
|
|
157
|
+
def execute(
|
|
158
|
+
self,
|
|
159
|
+
query: str,
|
|
160
|
+
context_limit: int = 5,
|
|
161
|
+
history: list[KnowledgeChatHistoryItem] | None = None,
|
|
162
|
+
) -> KnowledgeAgentRun:
|
|
163
|
+
run = KnowledgeAgentRun(plan=self.make_plan(query, context_limit, history))
|
|
164
|
+
for step in run.plan.steps:
|
|
165
|
+
if step == "knowledge_llm":
|
|
166
|
+
run.answer = self._answer(run)
|
|
167
|
+
break
|
|
168
|
+
self._tool_handlers[step](run)
|
|
169
|
+
return run
|
|
170
|
+
|
|
171
|
+
def stream(
|
|
172
|
+
self,
|
|
173
|
+
query: str,
|
|
174
|
+
context_limit: int = 5,
|
|
175
|
+
history: list[KnowledgeChatHistoryItem] | None = None,
|
|
176
|
+
should_cancel: Callable[[], bool] | None = None,
|
|
177
|
+
) -> Iterator[tuple[str, dict[str, object]]]:
|
|
178
|
+
run = KnowledgeAgentRun(plan=self.make_plan(query, context_limit, history))
|
|
179
|
+
should_stop = should_cancel or (lambda: False)
|
|
180
|
+
yield _tool_event(
|
|
181
|
+
"agent_plan",
|
|
182
|
+
"Agent 计划",
|
|
183
|
+
"completed",
|
|
184
|
+
"已整理本轮问答链路:理解上下文、检索知识库、拼装证据、生成回答。",
|
|
185
|
+
{"step_count": len(run.plan.steps)},
|
|
186
|
+
)
|
|
187
|
+
|
|
188
|
+
for step in run.plan.steps:
|
|
189
|
+
if should_stop():
|
|
190
|
+
return
|
|
191
|
+
if step == "knowledge_llm":
|
|
192
|
+
yield from self._stream_answer(run, should_cancel=should_stop)
|
|
193
|
+
return
|
|
194
|
+
yield from self._stream_tool(run, step)
|
|
195
|
+
if step == "semantic_search" and not run.chunks:
|
|
196
|
+
yield ("text_delta", {"delta": EMPTY_KNOWLEDGE_ANSWER})
|
|
197
|
+
yield ("sources", {"sources": []})
|
|
198
|
+
yield ("done", {"query": run.plan.query, "answer": EMPTY_KNOWLEDGE_ANSWER, "sources": []})
|
|
199
|
+
return
|
|
200
|
+
|
|
201
|
+
def _stream_tool(self, run: KnowledgeAgentRun, tool_name: str) -> Iterator[tuple[str, dict[str, object]]]:
|
|
202
|
+
if tool_name == "conversation_context" and not run.plan.history:
|
|
203
|
+
return
|
|
204
|
+
yield self._tool_started(run, tool_name)
|
|
205
|
+
self._tool_handlers[tool_name](run)
|
|
206
|
+
yield self._tool_completed(run, tool_name)
|
|
207
|
+
|
|
208
|
+
def _prepare_context(self, run: KnowledgeAgentRun) -> None:
|
|
209
|
+
return None
|
|
210
|
+
|
|
211
|
+
def _run_semantic_search(self, run: KnowledgeAgentRun) -> None:
|
|
212
|
+
run.chunks = self._index_service.search_chunks(run.plan.search_query, limit=run.plan.context_limit)
|
|
213
|
+
|
|
214
|
+
def _build_answer_context(self, run: KnowledgeAgentRun) -> None:
|
|
215
|
+
if not run.chunks:
|
|
216
|
+
return
|
|
217
|
+
|
|
218
|
+
context_blocks: list[str] = []
|
|
219
|
+
sources: list[KnowledgeSourceRef] = []
|
|
220
|
+
seen_sources: set[tuple[str, str | None]] = set()
|
|
221
|
+
for item in run.chunks:
|
|
222
|
+
video_id = str(item["video_id"])
|
|
223
|
+
metadata = item["metadata"] if isinstance(item["metadata"], dict) else {}
|
|
224
|
+
asset = self._repository.get_video_asset(video_id)
|
|
225
|
+
video_title = asset.title if asset is not None else str(metadata.get("title") or "未知视频")
|
|
226
|
+
page_title = str(metadata.get("page_title") or metadata.get("display_title") or "").strip()
|
|
227
|
+
if page_title == video_title:
|
|
228
|
+
page_title = ""
|
|
229
|
+
title = page_title or video_title
|
|
230
|
+
page_number_raw = metadata.get("page_number")
|
|
231
|
+
page_number = int(page_number_raw) if isinstance(page_number_raw, (int, float)) and int(page_number_raw) > 0 else None
|
|
232
|
+
anchor_seconds = (
|
|
233
|
+
float(metadata["anchor_seconds"])
|
|
234
|
+
if metadata.get("anchor_seconds") not in {None, "", -1, -1.0}
|
|
235
|
+
else None
|
|
236
|
+
)
|
|
237
|
+
timestamp = format_anchor_seconds(anchor_seconds)
|
|
238
|
+
context_blocks.append(
|
|
239
|
+
"\n".join(
|
|
240
|
+
line
|
|
241
|
+
for line in [
|
|
242
|
+
f"[视频:{title}]",
|
|
243
|
+
f"[总标题:{video_title}]" if page_title else "",
|
|
244
|
+
f"[时间:{timestamp or '未标注'}]",
|
|
245
|
+
str(item["document"]).strip(),
|
|
246
|
+
]
|
|
247
|
+
if line
|
|
248
|
+
)
|
|
249
|
+
)
|
|
250
|
+
key = (video_id, timestamp)
|
|
251
|
+
if key not in seen_sources:
|
|
252
|
+
seen_sources.add(key)
|
|
253
|
+
sources.append(
|
|
254
|
+
KnowledgeSourceRef(
|
|
255
|
+
video_id=video_id,
|
|
256
|
+
title=title,
|
|
257
|
+
relevance_score=float(item["relevance_score"]),
|
|
258
|
+
timestamp=timestamp,
|
|
259
|
+
video_title=video_title,
|
|
260
|
+
page_title=page_title or None,
|
|
261
|
+
page_number=page_number,
|
|
262
|
+
)
|
|
263
|
+
)
|
|
264
|
+
|
|
265
|
+
run.context_blocks = context_blocks
|
|
266
|
+
run.sources = sources
|
|
267
|
+
|
|
268
|
+
def _answer(self, run: KnowledgeAgentRun) -> str:
|
|
269
|
+
if not run.chunks:
|
|
270
|
+
return EMPTY_KNOWLEDGE_ANSWER
|
|
271
|
+
answer, _body = chat_knowledge_llm(
|
|
272
|
+
self._settings,
|
|
273
|
+
system_prompt=KNOWLEDGE_QA_SYSTEM_PROMPT,
|
|
274
|
+
user_prompt=_build_knowledge_user_prompt(run.plan.query, run.context_blocks, run.plan.history),
|
|
275
|
+
max_tokens=1100,
|
|
276
|
+
temperature=0.28,
|
|
277
|
+
)
|
|
278
|
+
return answer.strip()
|
|
279
|
+
|
|
280
|
+
def _stream_answer(
|
|
281
|
+
self,
|
|
282
|
+
run: KnowledgeAgentRun,
|
|
283
|
+
should_cancel: Callable[[], bool] | None = None,
|
|
284
|
+
) -> Iterator[tuple[str, dict[str, object]]]:
|
|
285
|
+
should_stop = should_cancel or (lambda: False)
|
|
286
|
+
if not run.chunks:
|
|
287
|
+
yield ("text_delta", {"delta": EMPTY_KNOWLEDGE_ANSWER})
|
|
288
|
+
yield ("sources", {"sources": []})
|
|
289
|
+
yield ("done", {"query": run.plan.query, "answer": EMPTY_KNOWLEDGE_ANSWER, "sources": []})
|
|
290
|
+
return
|
|
291
|
+
|
|
292
|
+
yield _tool_event("knowledge_llm", "知识库 LLM", "running", "正在根据证据片段与会话上下文生成回答。")
|
|
293
|
+
answer_parts: list[str] = []
|
|
294
|
+
reasoning_character_count = 0
|
|
295
|
+
last_reasoning_notice_at = 0.0
|
|
296
|
+
try:
|
|
297
|
+
for event in stream_knowledge_llm(
|
|
298
|
+
self._settings,
|
|
299
|
+
system_prompt=KNOWLEDGE_QA_SYSTEM_PROMPT,
|
|
300
|
+
user_prompt=_build_knowledge_user_prompt(run.plan.query, run.context_blocks, run.plan.history),
|
|
301
|
+
max_tokens=1100,
|
|
302
|
+
temperature=0.28,
|
|
303
|
+
should_cancel=should_stop,
|
|
304
|
+
):
|
|
305
|
+
if should_stop():
|
|
306
|
+
return
|
|
307
|
+
if isinstance(event, str):
|
|
308
|
+
delta = event
|
|
309
|
+
kind = "content"
|
|
310
|
+
else:
|
|
311
|
+
delta = event.delta
|
|
312
|
+
kind = event.kind
|
|
313
|
+
if kind == "reasoning":
|
|
314
|
+
reasoning_character_count += len(delta)
|
|
315
|
+
yield ("reasoning_delta", {"delta": delta})
|
|
316
|
+
now = time.monotonic()
|
|
317
|
+
if reasoning_character_count and (last_reasoning_notice_at == 0.0 or now - last_reasoning_notice_at > 2.0):
|
|
318
|
+
last_reasoning_notice_at = now
|
|
319
|
+
yield _tool_event(
|
|
320
|
+
"knowledge_llm",
|
|
321
|
+
"知识库 LLM",
|
|
322
|
+
"running",
|
|
323
|
+
"已收到模型推理流,正在等待最终回答正文。",
|
|
324
|
+
{"reasoning_character_count": reasoning_character_count},
|
|
325
|
+
)
|
|
326
|
+
continue
|
|
327
|
+
answer_parts.append(delta)
|
|
328
|
+
yield ("text_delta", {"delta": delta})
|
|
329
|
+
except HTTPException as exc:
|
|
330
|
+
if exc.status_code in {502, 503, 504}:
|
|
331
|
+
message = str(exc.detail)
|
|
332
|
+
yield _tool_event(
|
|
333
|
+
"knowledge_llm",
|
|
334
|
+
"知识库 LLM",
|
|
335
|
+
"error",
|
|
336
|
+
message,
|
|
337
|
+
{"status_code": exc.status_code},
|
|
338
|
+
)
|
|
339
|
+
yield ("error", {"message": message, "status_code": exc.status_code})
|
|
340
|
+
return
|
|
341
|
+
yield _tool_event(
|
|
342
|
+
"knowledge_llm",
|
|
343
|
+
"知识库 LLM",
|
|
344
|
+
"running",
|
|
345
|
+
"流式输出没有顺利返回,正在切换为普通问答调用。",
|
|
346
|
+
{"fallback": "non_streaming", "status_code": exc.status_code},
|
|
347
|
+
)
|
|
348
|
+
try:
|
|
349
|
+
run.answer = self._answer(run)
|
|
350
|
+
except HTTPException as fallback_exc:
|
|
351
|
+
message = str(fallback_exc.detail)
|
|
352
|
+
yield _tool_event(
|
|
353
|
+
"knowledge_llm",
|
|
354
|
+
"知识库 LLM",
|
|
355
|
+
"error",
|
|
356
|
+
message,
|
|
357
|
+
{"status_code": fallback_exc.status_code},
|
|
358
|
+
)
|
|
359
|
+
yield ("error", {"message": message, "status_code": fallback_exc.status_code})
|
|
360
|
+
return
|
|
361
|
+
yield ("text_delta", {"delta": run.answer})
|
|
362
|
+
|
|
363
|
+
if not answer_parts and not run.answer:
|
|
364
|
+
yield _tool_event(
|
|
365
|
+
"knowledge_llm",
|
|
366
|
+
"知识库 LLM",
|
|
367
|
+
"running",
|
|
368
|
+
"流式接口已结束但没有正文,正在切换为普通问答调用。",
|
|
369
|
+
{"fallback": "non_streaming"},
|
|
370
|
+
)
|
|
371
|
+
try:
|
|
372
|
+
run.answer = self._answer(run)
|
|
373
|
+
except HTTPException as exc:
|
|
374
|
+
message = str(exc.detail)
|
|
375
|
+
yield _tool_event(
|
|
376
|
+
"knowledge_llm",
|
|
377
|
+
"知识库 LLM",
|
|
378
|
+
"error",
|
|
379
|
+
message,
|
|
380
|
+
{"status_code": exc.status_code},
|
|
381
|
+
)
|
|
382
|
+
yield ("error", {"message": message, "status_code": exc.status_code})
|
|
383
|
+
return
|
|
384
|
+
yield ("text_delta", {"delta": run.answer})
|
|
385
|
+
|
|
386
|
+
if not run.answer:
|
|
387
|
+
run.answer = "".join(answer_parts).strip() or EMPTY_KNOWLEDGE_ANSWER
|
|
388
|
+
yield _tool_event(
|
|
389
|
+
"knowledge_llm",
|
|
390
|
+
"知识库 LLM",
|
|
391
|
+
"completed",
|
|
392
|
+
"回答生成完成。",
|
|
393
|
+
{"character_count": len(run.answer)},
|
|
394
|
+
)
|
|
395
|
+
yield ("sources", {"sources": [source.model_dump(mode="json") for source in run.sources]})
|
|
396
|
+
yield (
|
|
397
|
+
"done",
|
|
398
|
+
{
|
|
399
|
+
"query": run.plan.query,
|
|
400
|
+
"answer": run.answer,
|
|
401
|
+
"sources": [source.model_dump(mode="json") for source in run.sources],
|
|
402
|
+
},
|
|
403
|
+
)
|
|
404
|
+
|
|
405
|
+
def _tool_started(self, run: KnowledgeAgentRun, tool_name: str) -> tuple[str, dict[str, object]]:
|
|
406
|
+
details = {
|
|
407
|
+
"conversation_context": f"正在整理最近 {len(run.plan.history)} 条会话上下文。",
|
|
408
|
+
"semantic_search": "正在检索与你问题最相关的知识片段。",
|
|
409
|
+
"context_builder": "正在整理片段、时间点和来源引用。",
|
|
410
|
+
}
|
|
411
|
+
labels = {
|
|
412
|
+
"conversation_context": "上下文整理",
|
|
413
|
+
"semantic_search": "语义检索",
|
|
414
|
+
"context_builder": "上下文拼装",
|
|
415
|
+
}
|
|
416
|
+
return _tool_event(tool_name, labels[tool_name], "running", details[tool_name])
|
|
417
|
+
|
|
418
|
+
def _tool_completed(self, run: KnowledgeAgentRun, tool_name: str) -> tuple[str, dict[str, object]]:
|
|
419
|
+
if tool_name == "conversation_context":
|
|
420
|
+
return _tool_event(
|
|
421
|
+
tool_name,
|
|
422
|
+
"上下文整理",
|
|
423
|
+
"completed",
|
|
424
|
+
f"已带入最近 {len(run.plan.history)} 条会话上下文,用于理解追问与指代。",
|
|
425
|
+
{"turn_count": len(run.plan.history)},
|
|
426
|
+
)
|
|
427
|
+
if tool_name == "semantic_search":
|
|
428
|
+
matched_videos = len({str(item["video_id"]) for item in run.chunks})
|
|
429
|
+
detail = (
|
|
430
|
+
f"命中 {len(run.chunks)} 个片段,来自 {matched_videos} 个视频。"
|
|
431
|
+
if run.chunks
|
|
432
|
+
else "没有检索到相关知识片段。"
|
|
433
|
+
)
|
|
434
|
+
return _tool_event(
|
|
435
|
+
tool_name,
|
|
436
|
+
"语义检索",
|
|
437
|
+
"completed",
|
|
438
|
+
detail,
|
|
439
|
+
{"chunk_count": len(run.chunks), "video_count": matched_videos},
|
|
440
|
+
)
|
|
441
|
+
|
|
442
|
+
return _tool_event(
|
|
443
|
+
tool_name,
|
|
444
|
+
"上下文拼装",
|
|
445
|
+
"completed",
|
|
446
|
+
"已生成回答上下文,并保留来源视频与时间点。",
|
|
447
|
+
{
|
|
448
|
+
"sources": [
|
|
449
|
+
{
|
|
450
|
+
"title": source.title,
|
|
451
|
+
"timestamp": source.timestamp,
|
|
452
|
+
"score": round(source.relevance_score, 3),
|
|
453
|
+
}
|
|
454
|
+
for source in run.sources[:4]
|
|
455
|
+
],
|
|
456
|
+
},
|
|
457
|
+
)
|
|
458
|
+
|
|
459
|
+
|
|
460
|
+
class RagService:
|
|
461
|
+
def __init__(
|
|
462
|
+
self,
|
|
463
|
+
repository: SqliteTaskRepository,
|
|
464
|
+
index_service: KnowledgeIndexService,
|
|
465
|
+
tag_service: TagService,
|
|
466
|
+
settings: ServiceSettings,
|
|
467
|
+
) -> None:
|
|
468
|
+
self._tag_service = tag_service
|
|
469
|
+
self._agent = KnowledgeAgent(repository, index_service, settings)
|
|
470
|
+
|
|
471
|
+
def ask(
|
|
472
|
+
self,
|
|
473
|
+
query: str,
|
|
474
|
+
context_limit: int = 5,
|
|
475
|
+
history: list[KnowledgeChatHistoryItem] | None = None,
|
|
476
|
+
) -> KnowledgeAskResponse:
|
|
477
|
+
run = self._agent.execute(query, context_limit=context_limit, history=history)
|
|
478
|
+
return KnowledgeAskResponse(query=run.plan.query, answer=run.answer, sources=run.sources)
|
|
479
|
+
|
|
480
|
+
def ask_stream(
|
|
481
|
+
self,
|
|
482
|
+
query: str,
|
|
483
|
+
context_limit: int = 5,
|
|
484
|
+
history: list[KnowledgeChatHistoryItem] | None = None,
|
|
485
|
+
should_cancel: Callable[[], bool] | None = None,
|
|
486
|
+
) -> Iterator[tuple[str, dict[str, object]]]:
|
|
487
|
+
yield from self._agent.stream(
|
|
488
|
+
query,
|
|
489
|
+
context_limit=context_limit,
|
|
490
|
+
history=history,
|
|
491
|
+
should_cancel=should_cancel,
|
|
492
|
+
)
|