修复网页对话并接入 SearXNG 搜索
This commit is contained in:
+126
-19
@@ -26,6 +26,7 @@ from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import re
|
||||
import time
|
||||
import uuid
|
||||
from dataclasses import dataclass, field
|
||||
@@ -36,16 +37,27 @@ import numpy as np
|
||||
|
||||
from .config import AppConfig
|
||||
from .identity.base import IdentityDecision, IdentityProvider
|
||||
from .llm.base import LLMProvider, ToolCall
|
||||
from .llm.base import ChunkKind, LLMProvider, ToolCall
|
||||
from .memory.base import (
|
||||
ConfirmStatus, Memory, MemoryKind, MemoryStore, Scope, Turn,
|
||||
)
|
||||
from .prompts import build_system_prompt, trim_history
|
||||
from .search import (
|
||||
WEB_SEARCH_PROMPT,
|
||||
WEB_SEARCH_TOOL,
|
||||
SearchError,
|
||||
SearXNGSearchClient,
|
||||
)
|
||||
from .voice.stt import StreamingSTT
|
||||
from .voice.tts import LocalTTS, split_sentences
|
||||
from .voice.tts import LocalTTS, split_sentences, text_for_speech
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
_SOURCE_SECTION_RE = re.compile(
|
||||
r"(?:^|\n)\s*(?:参考来源|资料来源|来源)\s*[::]",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
|
||||
|
||||
class EventKind(str, Enum):
|
||||
IDENTITY = "identity" # 本轮的身份裁决结果
|
||||
@@ -84,12 +96,15 @@ class TurnMetrics:
|
||||
memories_used: int = 0
|
||||
reply_chars: int = 0
|
||||
speaks: int = 0
|
||||
search_ms: float = 0.0
|
||||
searches: int = 0
|
||||
|
||||
def summary(self) -> str:
|
||||
return (f"STT {self.stt_ms:.0f}ms | 身份 {self.identity_ms:.0f}ms | "
|
||||
f"检索 {self.retrieve_ms:.0f}ms | LLM首token {self.llm_first_content_ms:.0f}ms | "
|
||||
f"首音 {self.first_audio_ms:.0f}ms | 总 {self.total_ms:.0f}ms | "
|
||||
f"{self.llm_backend} | 记忆{self.memories_used}条 | {self.speaks}段音频")
|
||||
f"{self.llm_backend} | 记忆{self.memories_used}条 | "
|
||||
f"搜索{self.searches}次/{self.search_ms:.0f}ms | {self.speaks}段音频")
|
||||
|
||||
|
||||
class ConversationEngine:
|
||||
@@ -101,6 +116,7 @@ class ConversationEngine:
|
||||
identity: IdentityProvider,
|
||||
tts: LocalTTS,
|
||||
stt: StreamingSTT | None = None,
|
||||
search: SearXNGSearchClient | None = None,
|
||||
):
|
||||
self.cfg = cfg
|
||||
self.llm = llm
|
||||
@@ -108,6 +124,9 @@ class ConversationEngine:
|
||||
self.identity = identity
|
||||
self.tts = tts
|
||||
self.stt = stt
|
||||
self.search = search or (
|
||||
SearXNGSearchClient(cfg.search) if cfg.search.enabled else None
|
||||
)
|
||||
#: 会话历史。切身份时会清空——方案 §8 要求切换身份
|
||||
#: 清理上一人的私有检索上下文。
|
||||
self._history: list[dict] = []
|
||||
@@ -177,7 +196,11 @@ class ConversationEngine:
|
||||
yield Event(kind=EventKind.MEMORY_USED, memories=memories)
|
||||
|
||||
# ---- 拼 system(每轮重建,单条)
|
||||
system = build_system_prompt(identity, memories)
|
||||
system = build_system_prompt(
|
||||
identity,
|
||||
memories,
|
||||
extra=WEB_SEARCH_PROMPT if self.search is not None else "",
|
||||
)
|
||||
history = trim_history(self._history,
|
||||
max_turns=self.cfg.context.max_turns)
|
||||
messages = [{"role": "system", "content": system}] + history + \
|
||||
@@ -186,15 +209,22 @@ class ConversationEngine:
|
||||
# ---- LLM 流式 + 逐句 TTS
|
||||
reply_parts: list[str] = []
|
||||
buffer = ""
|
||||
speech_done = False
|
||||
speaks = 0
|
||||
async for ev in self._stream_and_speak(messages, metrics):
|
||||
if ev.kind == EventKind.RESPONSE_DELTA:
|
||||
reply_parts.append(ev.text)
|
||||
buffer += ev.text
|
||||
# 增量文本必须**原样透传**给调用方(显示、字幕、日志都要用)。
|
||||
# 这里漏掉 yield 的后果是:音频正常出来了,但上层拿到的是空回复——
|
||||
# 因为文本只进了内部累加器,从未离开这个生成器。
|
||||
yield ev
|
||||
if speech_done:
|
||||
continue
|
||||
buffer += ev.text
|
||||
source_start = _SOURCE_SECTION_RE.search(buffer)
|
||||
if source_start:
|
||||
buffer = buffer[:source_start.start()]
|
||||
speech_done = True
|
||||
# 攒到句末就送去合成——这是提前出声的唯一手段
|
||||
segs = split_sentences(buffer)
|
||||
if len(segs) > 1:
|
||||
@@ -278,21 +308,75 @@ class ConversationEngine:
|
||||
|
||||
async def _stream_and_speak(self, messages: list[dict],
|
||||
metrics: TurnMetrics) -> AsyncIterator[Event]:
|
||||
"""只负责 LLM 流式,不做 TTS。TTS 由调用方按句驱动。"""
|
||||
"""执行 LLM/搜索工具闭环;最终文本仍然流式交给调用方做 TTS。"""
|
||||
t0 = time.perf_counter()
|
||||
try:
|
||||
async for chunk in self.llm.stream(messages):
|
||||
if chunk.kind.value == "content" and chunk.text:
|
||||
if not metrics.llm_first_content_ms:
|
||||
metrics.llm_first_content_ms = (time.perf_counter() - t0) * 1000
|
||||
yield Event(kind=EventKind.RESPONSE_DELTA, text=chunk.text)
|
||||
elif chunk.kind.value == "tool_call" and chunk.tool_name:
|
||||
# 骨架阶段只上报,不执行。工具执行要配合
|
||||
# 方案 §9 的"执行 ID + 状态确认 + 不盲目重试",
|
||||
# 留到接米家时再做。
|
||||
yield Event(kind=EventKind.TOOL_CALL, tool_call=ToolCall(
|
||||
id=chunk.tool_id, name=chunk.tool_name,
|
||||
arguments=chunk.tool_arguments))
|
||||
# 最多允许连续两轮搜索;第三次请求不再暴露工具,强制形成回答,
|
||||
# 防止模型在搜索词之间无限循环。
|
||||
for round_index in range(3):
|
||||
tool_defs = (
|
||||
[WEB_SEARCH_TOOL]
|
||||
if self.search is not None and round_index < 2
|
||||
else None
|
||||
)
|
||||
tool_acc: dict[int, dict[str, str]] = {}
|
||||
assistant_parts: list[str] = []
|
||||
|
||||
async for chunk in self.llm.stream(messages, tools=tool_defs):
|
||||
if chunk.kind == ChunkKind.CONTENT and chunk.text:
|
||||
assistant_parts.append(chunk.text)
|
||||
if not metrics.llm_first_content_ms:
|
||||
metrics.llm_first_content_ms = (
|
||||
time.perf_counter() - t0
|
||||
) * 1000
|
||||
yield Event(kind=EventKind.RESPONSE_DELTA, text=chunk.text)
|
||||
elif chunk.kind == ChunkKind.TOOL_CALL:
|
||||
slot = tool_acc.setdefault(
|
||||
chunk.tool_index,
|
||||
{"id": "", "name": "", "arguments": ""},
|
||||
)
|
||||
# base.py 传来的是截至当前分片的完整累计值,直接覆盖,
|
||||
# 不能再次拼接,否则参数会重复。
|
||||
slot["id"] = chunk.tool_id or slot["id"]
|
||||
slot["name"] = chunk.tool_name or slot["name"]
|
||||
slot["arguments"] = chunk.tool_arguments
|
||||
|
||||
metrics.llm_backend = getattr(
|
||||
self.llm, "last_backend", self.llm.name)
|
||||
if not tool_acc:
|
||||
break
|
||||
|
||||
calls: list[ToolCall] = []
|
||||
api_calls: list[dict] = []
|
||||
for index, raw in sorted(tool_acc.items()):
|
||||
call = ToolCall(
|
||||
id=raw["id"] or f"call_{uuid.uuid4().hex[:12]}",
|
||||
name=raw["name"],
|
||||
arguments=raw["arguments"] or "{}",
|
||||
)
|
||||
calls.append(call)
|
||||
api_calls.append({
|
||||
"id": call.id,
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": call.name,
|
||||
"arguments": call.arguments,
|
||||
},
|
||||
})
|
||||
yield Event(kind=EventKind.TOOL_CALL, tool_call=call)
|
||||
|
||||
messages.append({
|
||||
"role": "assistant",
|
||||
"content": "".join(assistant_parts) or None,
|
||||
"tool_calls": api_calls,
|
||||
})
|
||||
for call in calls:
|
||||
result = await self._execute_tool(call, metrics)
|
||||
messages.append({
|
||||
"role": "tool",
|
||||
"tool_call_id": call.id,
|
||||
"content": result,
|
||||
})
|
||||
except asyncio.CancelledError:
|
||||
log.info("本轮被取消(打断)")
|
||||
raise
|
||||
@@ -301,9 +385,32 @@ class ConversationEngine:
|
||||
yield Event(kind=EventKind.ERROR, error=f"{type(e).__name__}: {e}")
|
||||
metrics.llm_backend = getattr(self.llm, "last_backend", self.llm.name)
|
||||
|
||||
async def _execute_tool(self, call: ToolCall, metrics: TurnMetrics) -> str:
|
||||
"""执行只读搜索工具;错误作为工具结果回灌,不让整轮对话中断。"""
|
||||
if call.name != "web_search" or self.search is None:
|
||||
return "工具不可用。请不要声称已经执行。"
|
||||
query = call.parsed_arguments().get("query")
|
||||
if not isinstance(query, str) or not query.strip():
|
||||
return "搜索失败:缺少有效的 query 参数。"
|
||||
|
||||
started = time.perf_counter()
|
||||
metrics.searches += 1
|
||||
try:
|
||||
results = await self.search.search(query)
|
||||
log.info("联网搜索 %r:返回 %d 条", query[:80], len(results))
|
||||
return self.search.for_model(query, results)
|
||||
except SearchError as exc:
|
||||
log.warning("联网搜索失败:%s", exc)
|
||||
return f"搜索失败:{exc}。请明确告知用户暂时无法取得实时资料。"
|
||||
except Exception as exc: # noqa: BLE001 — 工具失败不能拖垮对话
|
||||
log.exception("联网搜索异常:%s", exc)
|
||||
return "搜索服务发生异常。请明确告知用户暂时无法取得实时资料。"
|
||||
finally:
|
||||
metrics.search_ms += (time.perf_counter() - started) * 1000
|
||||
|
||||
async def _speak(self, text: str, metrics: TurnMetrics) -> AsyncIterator[Event]:
|
||||
"""合成一段并产出可播放音频。"""
|
||||
text = text.strip()
|
||||
text = text_for_speech(text)
|
||||
if not text:
|
||||
return
|
||||
t0 = time.perf_counter()
|
||||
|
||||
Reference in New Issue
Block a user