This commit is contained in:
@@ -3,6 +3,7 @@ FastAPI 后端 - 支持动态模型切换,使用 PostgreSQL 持久化记忆
|
|||||||
采用依赖注入模式,优雅管理资源生命周期
|
采用依赖注入模式,优雅管理资源生命周期
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
import warnings
|
import warnings
|
||||||
# 抑制 WebSocket 弃用警告(websockets 库升级导致,uvicorn 尚未跟进)
|
# 抑制 WebSocket 弃用警告(websockets 库升级导致,uvicorn 尚未跟进)
|
||||||
warnings.filterwarnings("ignore", category=DeprecationWarning, module="websockets")
|
warnings.filterwarnings("ignore", category=DeprecationWarning, module="websockets")
|
||||||
@@ -42,6 +43,7 @@ from backend.app.subgraphs.news_analysis.api_client import NewsAPIClient
|
|||||||
from .db.init_db import init_subgraph_tables
|
from .db.init_db import init_subgraph_tables
|
||||||
from .db.models import ContactRepository, DictionaryRepository, NewsRepository
|
from .db.models import ContactRepository, DictionaryRepository, NewsRepository
|
||||||
from backend.app.logger import info, error
|
from backend.app.logger import info, error
|
||||||
|
from backend.app.core import get_formatter
|
||||||
|
|
||||||
@asynccontextmanager
|
@asynccontextmanager
|
||||||
async def lifespan(app: FastAPI):
|
async def lifespan(app: FastAPI):
|
||||||
@@ -190,29 +192,34 @@ async def chat_endpoint(
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
error(f"同步响应异常: {e}")
|
error(f"同步响应异常: {e}")
|
||||||
|
|
||||||
# === 兜底输出机制 ===
|
# === 统一错误格式化 ===
|
||||||
error_message = str(e)
|
error_message = str(e)
|
||||||
is_timeout_error = any(keyword in error_message.lower() for keyword in
|
is_timeout_error = any(keyword in error_message.lower() for keyword in
|
||||||
["timeout", "timed out", "超时", "connection", "unavailable", "不可用"])
|
["timeout", "timed out", "超时", "connection", "unavailable", "不可用"])
|
||||||
|
|
||||||
# 1. 自我介绍
|
formatter = get_formatter()
|
||||||
intro_text = "你好!我是 AI 智能助手,我可以帮你处理各种问题,包括查询通讯录、词典翻译、新闻分析、知识库检索、联网搜索等。\n\n"
|
|
||||||
|
|
||||||
# 2. 错误信息(红色突出)
|
|
||||||
error_display = f"**⚠️ 当前遇到问题**\n\n```diff\n- {error_message}\n```\n\n"
|
|
||||||
|
|
||||||
# 3. 模型切换提示(如果是超时/不可用错误)
|
|
||||||
switch_hint = ""
|
|
||||||
if is_timeout_error:
|
if is_timeout_error:
|
||||||
switch_hint = "💡 **提示**:当前模型可能响应超时或不可用,请尝试手动切换到其他模型(如 DeepSeek、智谱AI等)。\n\n"
|
error_reply = formatter.render_error(
|
||||||
|
error_type="模型响应超时",
|
||||||
# 4. 组合完整兜底回复
|
error_message=error_message,
|
||||||
fallback_text = intro_text + error_display + switch_hint
|
suggestions=[
|
||||||
|
"当前模型可能响应超时或不可用",
|
||||||
|
"请尝试手动切换到其他模型(如 DeepSeek、智谱AI等)",
|
||||||
|
"或稍后重试"
|
||||||
|
],
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
error_reply = formatter.render_error(
|
||||||
|
error_type="处理异常",
|
||||||
|
error_message=error_message,
|
||||||
|
suggestions=["请稍后重试", "如果问题持续存在,请联系管理员"],
|
||||||
|
)
|
||||||
|
|
||||||
actual_model = request.model if request.model in agent_service.graphs else next(iter(agent_service.graphs.keys()))
|
actual_model = request.model if request.model in agent_service.graphs else next(iter(agent_service.graphs.keys()))
|
||||||
|
|
||||||
return ChatResponse(
|
return ChatResponse(
|
||||||
reply=fallback_text,
|
reply=error_reply,
|
||||||
thread_id=thread_id,
|
thread_id=thread_id,
|
||||||
model_used=actual_model,
|
model_used=actual_model,
|
||||||
input_tokens=0,
|
input_tokens=0,
|
||||||
@@ -274,32 +281,35 @@ async def chat_stream_endpoint(
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
error(f"流式响应异常: {e}")
|
error(f"流式响应异常: {e}")
|
||||||
|
|
||||||
# === 兜底输出机制 ===
|
# === 统一错误格式化 ===
|
||||||
error_message = str(e)
|
error_message = str(e)
|
||||||
is_timeout_error = any(keyword in error_message.lower() for keyword in
|
is_timeout_error = any(keyword in error_message.lower() for keyword in
|
||||||
["timeout", "timed out", "超时", "connection", "unavailable", "不可用"])
|
["timeout", "timed out", "超时", "connection", "unavailable", "不可用"])
|
||||||
|
|
||||||
# 1. 自我介绍
|
formatter = get_formatter()
|
||||||
intro_text = "你好!我是 AI 智能助手,我可以帮你处理各种问题,包括查询通讯录、词典翻译、新闻分析、知识库检索、联网搜索等。\n\n"
|
|
||||||
|
|
||||||
# 2. 错误信息(红色突出)
|
|
||||||
error_display = f"**⚠️ 当前遇到问题**\n\n```diff\n- {error_message}\n```\n\n"
|
|
||||||
|
|
||||||
# 3. 模型切换提示(如果是超时/不可用错误)
|
|
||||||
switch_hint = ""
|
|
||||||
if is_timeout_error:
|
if is_timeout_error:
|
||||||
switch_hint = "💡 **提示**:当前模型可能响应超时或不可用,请尝试手动切换到其他模型(如 DeepSeek、智谱AI等)。\n\n"
|
error_reply = formatter.render_error(
|
||||||
|
error_type="模型响应超时",
|
||||||
|
error_message=error_message,
|
||||||
|
suggestions=[
|
||||||
|
"当前模型可能响应超时或不可用",
|
||||||
|
"请尝试手动切换到其他模型(如 DeepSeek、智谱AI等)",
|
||||||
|
],
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
error_reply = formatter.render_error(
|
||||||
|
error_type="处理异常",
|
||||||
|
error_message=error_message,
|
||||||
|
suggestions=["请稍后重试"],
|
||||||
|
)
|
||||||
|
|
||||||
# 4. 组合完整兜底回复
|
# 以 llm_token 方式发送错误回复,模拟打字机效果
|
||||||
fallback_text = intro_text + error_display + switch_hint
|
for char in error_reply:
|
||||||
|
yield f"data: {json.dumps({'type': 'llm_token', 'node': 'error', 'token': char}, ensure_ascii=False)}\n\n"
|
||||||
# 5. 以 llm_token 方式发送兜底回复,模拟打字机效果
|
|
||||||
for char in fallback_text:
|
|
||||||
yield f"data: {json.dumps({'type': 'llm_token', 'node': 'fallback', 'token': char}, ensure_ascii=False)}\n\n"
|
|
||||||
import asyncio
|
import asyncio
|
||||||
await asyncio.sleep(0.01)
|
await asyncio.sleep(0.01)
|
||||||
|
|
||||||
# 6. 发送错误事件
|
|
||||||
yield f"data: {json.dumps({'type': 'error', 'message': error_message}, ensure_ascii=False)}\n\n"
|
yield f"data: {json.dumps({'type': 'error', 'message': error_message}, ensure_ascii=False)}\n\n"
|
||||||
yield "data: [DONE]\n\n"
|
yield "data: [DONE]\n\n"
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
"""核心模块 - 基类和通用工具"""
|
"""核心模块 - 基类和通用工具"""
|
||||||
|
|
||||||
from .formatter import MarkdownFormatter
|
from .formatter import MarkdownFormatter, OutputRenderer, get_formatter
|
||||||
|
from .stream_finalizer import StreamFinalizer, create_finalizer
|
||||||
from .state_base import BaseState
|
from .state_base import BaseState
|
||||||
from .human_review import (
|
from .human_review import (
|
||||||
ReviewManager,
|
ReviewManager,
|
||||||
@@ -23,6 +24,10 @@ from .visualization import (
|
|||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"MarkdownFormatter",
|
"MarkdownFormatter",
|
||||||
|
"OutputRenderer",
|
||||||
|
"get_formatter",
|
||||||
|
"StreamFinalizer",
|
||||||
|
"create_finalizer",
|
||||||
"BaseState",
|
"BaseState",
|
||||||
"ReviewManager",
|
"ReviewManager",
|
||||||
"InMemoryReviewStore",
|
"InMemoryReviewStore",
|
||||||
|
|||||||
@@ -1,52 +1,27 @@
|
|||||||
"""
|
"""
|
||||||
格式化输出工具模块
|
格式化输出工具模块
|
||||||
提供基于 Jinja2 模板的 Markdown 格式化输出能力
|
|
||||||
|
|
||||||
功能:
|
提供统一的 Markdown 格式化能力:
|
||||||
1. TemplateManager - 模板管理器,支持加载和渲染 Jinja2 模板
|
1. MarkdownFormatter - 静态方法生成 Markdown 元素
|
||||||
2. MarkdownFormatter - Markdown 格式化工具,提供常用格式(表格、列表、引用等)
|
2. OutputRenderer - 模板渲染 + 全局单例
|
||||||
3. OutputRenderer - 输出渲染器,统一接口生成最终输出
|
3. get_formatter() - 获取全局单例
|
||||||
4. PresetTemplates - 预置模板(对话摘要、报告、列表等)
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import os
|
from typing import Dict, List, Any, Optional
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Dict, List, Any, Optional, Union
|
|
||||||
from dataclasses import dataclass
|
|
||||||
from abc import ABC, abstractmethod
|
|
||||||
|
|
||||||
# 尝试导入 Jinja2,如果没有则提供基础实现
|
from backend.app.logger import info, warning
|
||||||
try:
|
from backend.app.templates import TEMPLATES_DIR
|
||||||
from jinja2 import Template as JinjaTemplate, Environment, BaseLoader
|
|
||||||
HAS_JINJA2 = True
|
|
||||||
except ImportError:
|
|
||||||
HAS_JINJA2 = False
|
|
||||||
|
|
||||||
|
|
||||||
class BaseFormatter(ABC):
|
# ========== Markdown 格式化器 ==========
|
||||||
"""格式化器基类"""
|
|
||||||
|
|
||||||
@abstractmethod
|
class MarkdownFormatter:
|
||||||
def format(self, data: Any) -> str:
|
"""Markdown 格式化工具(静态方法)"""
|
||||||
"""格式化数据为字符串"""
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
class MarkdownFormatter(BaseFormatter):
|
|
||||||
"""Markdown 格式化工具"""
|
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def table(data: List[Dict[str, Any]], headers: Optional[List[str]] = None) -> str:
|
def table(data: List[Dict[str, Any]], headers: Optional[List[str]] = None) -> str:
|
||||||
"""
|
"""生成 Markdown 表格"""
|
||||||
生成 Markdown 表格
|
|
||||||
|
|
||||||
Args:
|
|
||||||
data: 数据列表,每个元素是一个字典
|
|
||||||
headers: 表头列表,如果为 None 则使用字典的键
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Markdown 表格字符串
|
|
||||||
"""
|
|
||||||
if not data:
|
if not data:
|
||||||
return ""
|
return ""
|
||||||
|
|
||||||
@@ -57,112 +32,51 @@ class MarkdownFormatter(BaseFormatter):
|
|||||||
return ""
|
return ""
|
||||||
|
|
||||||
lines = []
|
lines = []
|
||||||
|
|
||||||
# 表头行
|
|
||||||
header_line = "| " + " | ".join(str(h) for h in headers) + " |"
|
header_line = "| " + " | ".join(str(h) for h in headers) + " |"
|
||||||
lines.append(header_line)
|
lines.append(header_line)
|
||||||
|
|
||||||
# 分隔线
|
|
||||||
separator_line = "| " + " | ".join("---" for _ in headers) + " |"
|
separator_line = "| " + " | ".join("---" for _ in headers) + " |"
|
||||||
lines.append(separator_line)
|
lines.append(separator_line)
|
||||||
|
|
||||||
# 数据行
|
|
||||||
for row in data:
|
for row in data:
|
||||||
row_values = [str(row.get(h, "")) for h in headers]
|
row_values = [str(row.get(h, "")) for h in headers]
|
||||||
row_line = "| " + " | ".join(row_values) + " |"
|
lines.append("| " + " | ".join(row_values) + " |")
|
||||||
lines.append(row_line)
|
|
||||||
|
|
||||||
return "\n".join(lines)
|
return "\n".join(lines)
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def bullet_list(items: List[str], indent: int = 0) -> str:
|
def bullet_list(items: List[str], indent: int = 0) -> str:
|
||||||
"""
|
"""生成无序列表"""
|
||||||
生成无序列表
|
|
||||||
|
|
||||||
Args:
|
|
||||||
items: 列表项
|
|
||||||
indent: 缩进层级
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Markdown 无序列表字符串
|
|
||||||
"""
|
|
||||||
indent_str = " " * indent
|
indent_str = " " * indent
|
||||||
return "\n".join(f"{indent_str}- {item}" for item in items)
|
return "\n".join(f"{indent_str}- {item}" for item in items)
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def numbered_list(items: List[str], start: int = 1, indent: int = 0) -> str:
|
def numbered_list(items: List[str], start: int = 1, indent: int = 0) -> str:
|
||||||
"""
|
"""生成有序列表"""
|
||||||
生成有序列表
|
|
||||||
|
|
||||||
Args:
|
|
||||||
items: 列表项
|
|
||||||
start: 起始编号
|
|
||||||
indent: 缩进层级
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Markdown 有序列表字符串
|
|
||||||
"""
|
|
||||||
indent_str = " " * indent
|
indent_str = " " * indent
|
||||||
return "\n".join(f"{indent_str}{i}. {item}" for i, item in enumerate(items, start=start))
|
return "\n".join(f"{indent_str}{i}. {item}" for i, item in enumerate(items, start=start))
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def quote(text: str, author: Optional[str] = None) -> str:
|
def quote(text: str, author: Optional[str] = None) -> str:
|
||||||
"""
|
"""生成引用块"""
|
||||||
生成引用块
|
quoted = "\n".join(f"> {line}" for line in text.split("\n"))
|
||||||
|
|
||||||
Args:
|
|
||||||
text: 引用文本
|
|
||||||
author: 作者(可选)
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Markdown 引用块字符串
|
|
||||||
"""
|
|
||||||
quoted_lines = "\n".join(f"> {line}" for line in text.split("\n"))
|
|
||||||
if author:
|
if author:
|
||||||
quoted_lines += f"\n> — {author}"
|
quoted += f"\n> — {author}"
|
||||||
return quoted_lines
|
return quoted
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def code(code: str, language: str = "") -> str:
|
def code(code: str, language: str = "") -> str:
|
||||||
"""
|
"""生成代码块"""
|
||||||
生成代码块
|
|
||||||
|
|
||||||
Args:
|
|
||||||
code: 代码内容
|
|
||||||
language: 语言标识符
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Markdown 代码块字符串
|
|
||||||
"""
|
|
||||||
return f"```{language}\n{code}\n```"
|
return f"```{language}\n{code}\n```"
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def heading(text: str, level: int = 1) -> str:
|
def heading(text: str, level: int = 1) -> str:
|
||||||
"""
|
"""生成标题"""
|
||||||
生成标题
|
|
||||||
|
|
||||||
Args:
|
|
||||||
text: 标题文本
|
|
||||||
level: 标题级别(1-6)
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Markdown 标题字符串
|
|
||||||
"""
|
|
||||||
level = max(1, min(6, level))
|
level = max(1, min(6, level))
|
||||||
return f"{'#' * level} {text}"
|
return f"{'#' * level} {text}"
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def link(text: str, url: str) -> str:
|
def link(text: str, url: str) -> str:
|
||||||
"""
|
"""生成链接"""
|
||||||
生成链接
|
|
||||||
|
|
||||||
Args:
|
|
||||||
text: 链接文本
|
|
||||||
url: 链接地址
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Markdown 链接字符串
|
|
||||||
"""
|
|
||||||
return f"[{text}]({url})"
|
return f"[{text}]({url})"
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
@@ -180,303 +94,145 @@ class MarkdownFormatter(BaseFormatter):
|
|||||||
"""生成分割线"""
|
"""生成分割线"""
|
||||||
return "---"
|
return "---"
|
||||||
|
|
||||||
def format(self, data: Any) -> str:
|
@staticmethod
|
||||||
"""实现基类方法,根据数据类型自动选择格式化方式"""
|
def format(data: Any) -> str:
|
||||||
|
"""根据数据类型自动选择格式化方式"""
|
||||||
if isinstance(data, list):
|
if isinstance(data, list):
|
||||||
if len(data) > 0 and isinstance(data[0], dict):
|
if data and isinstance(data[0], dict):
|
||||||
return self.table(data)
|
return MarkdownFormatter.table(data)
|
||||||
else:
|
return MarkdownFormatter.bullet_list([str(item) for item in data])
|
||||||
return self.bullet_list([str(item) for item in data])
|
|
||||||
elif isinstance(data, dict):
|
elif isinstance(data, dict):
|
||||||
return self.table([data])
|
return MarkdownFormatter.table([data])
|
||||||
else:
|
|
||||||
return str(data)
|
return str(data)
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
# ========== 模板加载器 ==========
|
||||||
class Template:
|
|
||||||
"""模板数据类"""
|
|
||||||
name: str
|
|
||||||
content: str
|
|
||||||
description: str = ""
|
|
||||||
|
|
||||||
|
|
||||||
class DictLoader(BaseLoader):
|
|
||||||
"""字典模板加载器
|
|
||||||
|
|
||||||
用于从内存字典中加载模板
|
|
||||||
"""
|
|
||||||
|
|
||||||
def __init__(self, templates: Dict[str, str]):
|
|
||||||
self.templates = templates
|
|
||||||
|
|
||||||
def get_source(self, environment, template):
|
|
||||||
if template not in self.templates:
|
|
||||||
raise TemplateNotFound(template)
|
|
||||||
source = self.templates[template]
|
|
||||||
return source, None, lambda: True
|
|
||||||
|
|
||||||
|
|
||||||
class TemplateManager:
|
class TemplateManager:
|
||||||
"""Jinja2 模板管理器"""
|
"""模板管理器,支持从文件加载 .md 或 .jinja 模板"""
|
||||||
|
|
||||||
def __init__(self, template_dir: Optional[Path] = None):
|
def __init__(self, template_dir: Optional[Path] = None):
|
||||||
"""
|
self.template_dir = template_dir or TEMPLATES_DIR
|
||||||
初始化模板管理器
|
self._templates: Dict[str, str] = {}
|
||||||
|
self._load_templates()
|
||||||
|
|
||||||
Args:
|
def _load_templates(self) -> None:
|
||||||
template_dir: 模板目录路径
|
"""从模板目录加载所有 .md 和 .jinja 文件"""
|
||||||
"""
|
if not self.template_dir.exists():
|
||||||
self._templates: Dict[str, Template] = {}
|
warning(f"[Formatter] 模板目录不存在: {self.template_dir}")
|
||||||
self.template_dir = template_dir
|
return
|
||||||
self._env: Optional[Environment] = None
|
|
||||||
|
|
||||||
if HAS_JINJA2:
|
for tmpl_file in self.template_dir.glob("*.md"):
|
||||||
self._env = Environment(loader=DictLoader({}))
|
name = tmpl_file.stem
|
||||||
|
self._templates[name] = tmpl_file.read_text(encoding="utf-8")
|
||||||
|
info(f"[Formatter] 加载模板: {name}")
|
||||||
|
|
||||||
def _refresh_env(self) -> None:
|
for tmpl_file in self.template_dir.glob("*.jinja"):
|
||||||
"""刷新 Jinja2 环境"""
|
name = tmpl_file.stem
|
||||||
if HAS_JINJA2 and self._env is not None:
|
self._templates[name] = tmpl_file.read_text(encoding="utf-8")
|
||||||
template_dict = {name: t.content for name, t in self._templates.items()}
|
info(f"[Formatter] 加载模板: {name}")
|
||||||
self._env = Environment(loader=DictLoader(template_dict))
|
|
||||||
|
|
||||||
def add_template(self, name: str, content: str, description: str = "") -> None:
|
def get(self, name: str) -> Optional[str]:
|
||||||
"""
|
"""获取模板内容"""
|
||||||
添加模板
|
|
||||||
|
|
||||||
Args:
|
|
||||||
name: 模板名称
|
|
||||||
content: 模板内容
|
|
||||||
description: 模板描述
|
|
||||||
"""
|
|
||||||
self._templates[name] = Template(name=name, content=content, description=description)
|
|
||||||
self._refresh_env()
|
|
||||||
|
|
||||||
def load_template(self, name: str, file_path: Path) -> None:
|
|
||||||
"""
|
|
||||||
从文件加载模板
|
|
||||||
|
|
||||||
Args:
|
|
||||||
name: 模板名称
|
|
||||||
file_path: 模板文件路径
|
|
||||||
"""
|
|
||||||
if file_path.exists():
|
|
||||||
content = file_path.read_text(encoding='utf-8')
|
|
||||||
self.add_template(name, content, f"从文件加载: {file_path}")
|
|
||||||
|
|
||||||
def get_template(self, name: str) -> Optional[Template]:
|
|
||||||
"""
|
|
||||||
获取模板
|
|
||||||
|
|
||||||
Args:
|
|
||||||
name: 模板名称
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
模板对象,如果不存在返回 None
|
|
||||||
"""
|
|
||||||
return self._templates.get(name)
|
return self._templates.get(name)
|
||||||
|
|
||||||
def render(self, template_name: str, context: Dict[str, Any]) -> str:
|
def render(self, name: str, context: Dict[str, Any]) -> str:
|
||||||
|
"""渲染模板"""
|
||||||
|
template = self.get(name)
|
||||||
|
if not template:
|
||||||
|
raise ValueError(f"模板不存在: {name}")
|
||||||
|
|
||||||
|
return self._render_string(template, context)
|
||||||
|
|
||||||
|
def _render_string(self, template_str: str, context: Dict[str, Any]) -> str:
|
||||||
|
"""渲染模板字符串"""
|
||||||
|
# 尝试 Jinja2
|
||||||
|
try:
|
||||||
|
from jinja2 import Template
|
||||||
|
tmpl = Template(template_str)
|
||||||
|
return tmpl.render(**context)
|
||||||
|
except ImportError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# 简单替换作为兜底
|
||||||
|
result = template_str
|
||||||
|
for key, value in context.items():
|
||||||
|
str_val = str(value)
|
||||||
|
result = result.replace(f"{{{{ {key} }}}}", str_val)
|
||||||
|
result = result.replace(f"{{{{{key}}}}}", str_val)
|
||||||
|
# Handle conditional blocks {{#if key}}...{{/if}}
|
||||||
|
import re
|
||||||
|
result = re.sub(r"\{\{#if\s+" + re.escape(key) + r"\}\}(.*?)\{\{/if\}\}",
|
||||||
|
str_val and r"\1" or "", result, flags=re.DOTALL)
|
||||||
|
# Handle each loops {{#each key}}...{{/each}}
|
||||||
|
if isinstance(value, list):
|
||||||
|
pattern = r"\{\{#each\s+" + re.escape(key) + r"\}\}(.*?)\{\{/each\}\}"
|
||||||
|
matches = re.findall(pattern, result, flags=re.DOTALL)
|
||||||
|
for match in matches:
|
||||||
|
rendered_items = []
|
||||||
|
for idx, item in enumerate(value):
|
||||||
|
item_str = match
|
||||||
|
if isinstance(item, dict):
|
||||||
|
for k, v in item.items():
|
||||||
|
item_str = item_str.replace("{{ " + k + " }}", str(v))
|
||||||
|
item_str = item_str.replace("{{" + k + "}}", str(v))
|
||||||
|
item_str = item_str.replace("{{ @" + k + " }}", str(idx + 1))
|
||||||
|
item_str = item_str.replace("{{ @index }}", str(idx + 1))
|
||||||
|
else:
|
||||||
|
item_str = item_str.replace("{{ this }}", str(item))
|
||||||
|
rendered_items.append(item_str)
|
||||||
|
result = re.sub(pattern, "\n".join(rendered_items), result, flags=re.DOTALL)
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
# ========== 输出渲染器 ==========
|
||||||
|
|
||||||
|
class OutputRenderer:
|
||||||
|
"""
|
||||||
|
输出渲染器 - 统一接口渲染模板
|
||||||
|
使用全局单例,通过 get_formatter() 获取
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, template_dir: Optional[Path] = None):
|
||||||
|
self._templates = TemplateManager(template_dir)
|
||||||
|
self.md = MarkdownFormatter()
|
||||||
|
|
||||||
|
def render(self, template_name: str, **context) -> str:
|
||||||
"""
|
"""
|
||||||
渲染模板
|
渲染模板
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
template_name: 模板名称
|
template_name: 模板名称(不含扩展名)
|
||||||
context: 渲染上下文
|
**context: 渲染上下文
|
||||||
|
|
||||||
Returns:
|
|
||||||
渲染后的字符串
|
|
||||||
"""
|
"""
|
||||||
template = self.get_template(template_name)
|
return self._templates.render(template_name, context)
|
||||||
if template is None:
|
|
||||||
raise ValueError(f"模板不存在: {template_name}")
|
|
||||||
|
|
||||||
return self.render_string(template.content, context)
|
|
||||||
|
|
||||||
def render_string(self, template_string: str, context: Dict[str, Any]) -> str:
|
|
||||||
"""
|
|
||||||
渲染模板字符串
|
|
||||||
|
|
||||||
Args:
|
|
||||||
template_string: 模板字符串
|
|
||||||
context: 渲染上下文
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
渲染后的字符串
|
|
||||||
"""
|
|
||||||
if HAS_JINJA2 and self._env is not None:
|
|
||||||
try:
|
|
||||||
jinja_template = self._env.from_string(template_string)
|
|
||||||
return jinja_template.render(**context)
|
|
||||||
except Exception:
|
|
||||||
# 如果 Jinja2 渲染失败,使用简单替换
|
|
||||||
pass
|
|
||||||
|
|
||||||
# 简单的字符串替换作为备选方案
|
|
||||||
result = template_string
|
|
||||||
for key, value in context.items():
|
|
||||||
result = result.replace(f"{{{{{key}}}}}", str(value))
|
|
||||||
result = result.replace(f"{{{{ {key} }}}}", str(value))
|
|
||||||
return result
|
|
||||||
|
|
||||||
|
|
||||||
class PresetTemplates:
|
|
||||||
"""预置模板集合"""
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def conversation_summary() -> str:
|
|
||||||
"""对话摘要模板"""
|
|
||||||
return """# 对话摘要
|
|
||||||
|
|
||||||
**时间**: {{ timestamp }}
|
|
||||||
|
|
||||||
**参与者**: {{ participants }}
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 对话要点
|
|
||||||
{{ bullet_list(points) }}
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 总结
|
|
||||||
{{ summary }}
|
|
||||||
"""
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def research_report() -> str:
|
|
||||||
"""研究报告模板"""
|
|
||||||
return """# {{ title }}
|
|
||||||
|
|
||||||
**日期**: {{ date }}
|
|
||||||
**作者**: {{ author }}
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 摘要
|
|
||||||
{{ summary }}
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 发现
|
|
||||||
{{ bullet_list(findings) }}
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 数据来源
|
|
||||||
{{ sources }}
|
|
||||||
"""
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def task_list() -> str:
|
|
||||||
"""任务列表模板"""
|
|
||||||
return """# 任务列表
|
|
||||||
|
|
||||||
**更新时间**: {{ update_time }}
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 待办
|
|
||||||
{{ numbered_list(todos) }}
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 已完成
|
|
||||||
{{ numbered_list(completed) }}
|
|
||||||
"""
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def data_summary() -> str:
|
|
||||||
"""数据摘要模板"""
|
|
||||||
return """# 数据摘要
|
|
||||||
|
|
||||||
**生成时间**: {{ timestamp }}
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 数据概览
|
|
||||||
{{ table(data_overview) }}
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 关键指标
|
|
||||||
{{ bullet_list(metrics) }}
|
|
||||||
"""
|
|
||||||
|
|
||||||
|
|
||||||
class OutputRenderer:
|
|
||||||
"""输出渲染器"""
|
|
||||||
|
|
||||||
def __init__(self, template_manager: Optional[TemplateManager] = None):
|
|
||||||
"""
|
|
||||||
初始化输出渲染器
|
|
||||||
|
|
||||||
Args:
|
|
||||||
template_manager: 模板管理器
|
|
||||||
"""
|
|
||||||
self.template_manager = template_manager or TemplateManager()
|
|
||||||
self.markdown = MarkdownFormatter()
|
|
||||||
|
|
||||||
# 自动注册预置模板
|
|
||||||
self._register_presets()
|
|
||||||
|
|
||||||
def _register_presets(self) -> None:
|
|
||||||
"""注册预置模板"""
|
|
||||||
self.template_manager.add_template(
|
|
||||||
"conversation_summary",
|
|
||||||
PresetTemplates.conversation_summary(),
|
|
||||||
"对话摘要模板"
|
|
||||||
)
|
|
||||||
self.template_manager.add_template(
|
|
||||||
"research_report",
|
|
||||||
PresetTemplates.research_report(),
|
|
||||||
"研究报告模板"
|
|
||||||
)
|
|
||||||
self.template_manager.add_template(
|
|
||||||
"task_list",
|
|
||||||
PresetTemplates.task_list(),
|
|
||||||
"任务列表模板"
|
|
||||||
)
|
|
||||||
self.template_manager.add_template(
|
|
||||||
"data_summary",
|
|
||||||
PresetTemplates.data_summary(),
|
|
||||||
"数据摘要模板"
|
|
||||||
)
|
|
||||||
|
|
||||||
def render(self, template_name: str, context: Dict[str, Any]) -> str:
|
|
||||||
"""
|
|
||||||
使用模板渲染输出
|
|
||||||
|
|
||||||
Args:
|
|
||||||
template_name: 模板名称
|
|
||||||
context: 渲染上下文
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
渲染后的字符串
|
|
||||||
"""
|
|
||||||
# 将格式化工具注入上下文
|
|
||||||
render_context = context.copy()
|
|
||||||
render_context["bullet_list"] = self.markdown.bullet_list
|
|
||||||
render_context["numbered_list"] = self.markdown.numbered_list
|
|
||||||
render_context["table"] = self.markdown.table
|
|
||||||
render_context["quote"] = self.markdown.quote
|
|
||||||
render_context["code"] = self.markdown.code
|
|
||||||
render_context["heading"] = self.markdown.heading
|
|
||||||
render_context["link"] = self.markdown.link
|
|
||||||
render_context["bold"] = self.markdown.bold
|
|
||||||
render_context["italic"] = self.markdown.italic
|
|
||||||
render_context["divider"] = self.markdown.divider
|
|
||||||
|
|
||||||
return self.template_manager.render(template_name, render_context)
|
|
||||||
|
|
||||||
def render_plain(self, data: Any) -> str:
|
def render_plain(self, data: Any) -> str:
|
||||||
"""
|
"""直接格式化数据为 Markdown"""
|
||||||
直接格式化数据为 Markdown
|
return self.md.format(data)
|
||||||
|
|
||||||
Args:
|
def render_error(self, error_type: str, error_message: str = "",
|
||||||
data: 数据
|
suggestions: Optional[List[str]] = None,
|
||||||
|
retry_count: int = 0, max_retries: Optional[int] = None) -> str:
|
||||||
|
"""渲染错误通知模板"""
|
||||||
|
context = {
|
||||||
|
"error_type": error_type,
|
||||||
|
"error_message": error_message,
|
||||||
|
"suggestions": self.md.bullet_list(suggestions or ["请稍后重试"]),
|
||||||
|
"retry_count": retry_count,
|
||||||
|
"max_retries": max_retries,
|
||||||
|
}
|
||||||
|
return self.render("error_notification", **context)
|
||||||
|
|
||||||
Returns:
|
|
||||||
格式化后的字符串
|
# ========== 全局单例 ==========
|
||||||
"""
|
|
||||||
return self.markdown.format(data)
|
_formatter: Optional[OutputRenderer] = None
|
||||||
|
|
||||||
|
|
||||||
|
def get_formatter() -> OutputRenderer:
|
||||||
|
"""获取全局 OutputRenderer 单例"""
|
||||||
|
global _formatter
|
||||||
|
if _formatter is None:
|
||||||
|
_formatter = OutputRenderer()
|
||||||
|
return _formatter
|
||||||
|
|||||||
@@ -1,332 +0,0 @@
|
|||||||
"""
|
|
||||||
超时和重试工具模块
|
|
||||||
为 React 模式提供超时控制和重试机制
|
|
||||||
"""
|
|
||||||
|
|
||||||
import time
|
|
||||||
import asyncio
|
|
||||||
from functools import wraps
|
|
||||||
from typing import Callable, Any, Optional, Type, Tuple, Union
|
|
||||||
from dataclasses import dataclass, field
|
|
||||||
from enum import Enum, auto
|
|
||||||
|
|
||||||
|
|
||||||
class RetryStrategy(Enum):
|
|
||||||
"""重试策略"""
|
|
||||||
FIXED = auto() # 固定间隔
|
|
||||||
EXPONENTIAL = auto() # 指数退避
|
|
||||||
LINEAR = auto() # 线性增长
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class RetryConfig:
|
|
||||||
"""重试配置"""
|
|
||||||
max_retries: int = 3 # 最大重试次数
|
|
||||||
base_delay: float = 1.0 # 基础延迟(秒)
|
|
||||||
max_delay: float = 10.0 # 最大延迟(秒)
|
|
||||||
strategy: RetryStrategy = RetryStrategy.EXPONENTIAL
|
|
||||||
timeout: Optional[float] = 30.0 # 单次调用超时(秒)
|
|
||||||
recoverable_exceptions: Tuple[Type[Exception], ...] = field(
|
|
||||||
default_factory=lambda: (Exception,)
|
|
||||||
)
|
|
||||||
unrecoverable_exceptions: Tuple[Type[Exception], ...] = field(
|
|
||||||
default_factory=tuple
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class RetryResult:
|
|
||||||
"""重试结果"""
|
|
||||||
success: bool
|
|
||||||
result: Any = None
|
|
||||||
error: Optional[Exception] = None
|
|
||||||
retry_count: int = 0
|
|
||||||
total_time: float = 0.0
|
|
||||||
timed_out: bool = False
|
|
||||||
|
|
||||||
|
|
||||||
# ========== 同步重试装饰器 ==========
|
|
||||||
def with_retry(
|
|
||||||
config: Optional[RetryConfig] = None,
|
|
||||||
max_retries: int = 3,
|
|
||||||
timeout: Optional[float] = 30.0,
|
|
||||||
base_delay: float = 1.0,
|
|
||||||
on_retry: Optional[Callable[[int, Exception], None]] = None
|
|
||||||
):
|
|
||||||
"""
|
|
||||||
同步重试装饰器
|
|
||||||
|
|
||||||
Args:
|
|
||||||
config: 重试配置对象
|
|
||||||
max_retries: 最大重试次数(如果没有 config)
|
|
||||||
timeout: 单次调用超时(秒)
|
|
||||||
base_delay: 基础延迟(秒)
|
|
||||||
on_retry: 重试回调函数(retry_count, exception)
|
|
||||||
"""
|
|
||||||
if config is None:
|
|
||||||
config = RetryConfig(
|
|
||||||
max_retries=max_retries,
|
|
||||||
timeout=timeout,
|
|
||||||
base_delay=base_delay
|
|
||||||
)
|
|
||||||
|
|
||||||
def decorator(func: Callable) -> Callable:
|
|
||||||
@wraps(func)
|
|
||||||
def wrapper(*args, **kwargs) -> RetryResult:
|
|
||||||
start_time = time.time()
|
|
||||||
last_error = None
|
|
||||||
|
|
||||||
for attempt in range(config.max_retries + 1):
|
|
||||||
try:
|
|
||||||
# 执行函数(带超时)
|
|
||||||
if config.timeout:
|
|
||||||
# 使用信号量或线程实现超时(简化版)
|
|
||||||
result = func(*args, **kwargs)
|
|
||||||
else:
|
|
||||||
result = func(*args, **kwargs)
|
|
||||||
|
|
||||||
# 成功
|
|
||||||
total_time = time.time() - start_time
|
|
||||||
return RetryResult(
|
|
||||||
success=True,
|
|
||||||
result=result,
|
|
||||||
retry_count=attempt,
|
|
||||||
total_time=total_time
|
|
||||||
)
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
last_error = e
|
|
||||||
|
|
||||||
# 检查是否是不可恢复的异常
|
|
||||||
if isinstance(e, config.unrecoverable_exceptions):
|
|
||||||
break
|
|
||||||
|
|
||||||
# 检查是否达到最大重试次数
|
|
||||||
if attempt >= config.max_retries:
|
|
||||||
break
|
|
||||||
|
|
||||||
# 计算延迟
|
|
||||||
delay = _calculate_delay(attempt, config)
|
|
||||||
|
|
||||||
# 回调通知
|
|
||||||
if on_retry:
|
|
||||||
on_retry(attempt + 1, e)
|
|
||||||
|
|
||||||
# 等待
|
|
||||||
time.sleep(delay)
|
|
||||||
|
|
||||||
# 所有重试都失败
|
|
||||||
total_time = time.time() - start_time
|
|
||||||
return RetryResult(
|
|
||||||
success=False,
|
|
||||||
error=last_error,
|
|
||||||
retry_count=config.max_retries,
|
|
||||||
total_time=total_time
|
|
||||||
)
|
|
||||||
|
|
||||||
return wrapper
|
|
||||||
return decorator
|
|
||||||
|
|
||||||
|
|
||||||
# ========== 异步重试装饰器 ==========
|
|
||||||
def with_async_retry(
|
|
||||||
config: Optional[RetryConfig] = None,
|
|
||||||
max_retries: int = 3,
|
|
||||||
timeout: Optional[float] = 30.0,
|
|
||||||
base_delay: float = 1.0,
|
|
||||||
on_retry: Optional[Callable[[int, Exception], None]] = None
|
|
||||||
):
|
|
||||||
"""
|
|
||||||
异步重试装饰器
|
|
||||||
"""
|
|
||||||
if config is None:
|
|
||||||
config = RetryConfig(
|
|
||||||
max_retries=max_retries,
|
|
||||||
timeout=timeout,
|
|
||||||
base_delay=base_delay
|
|
||||||
)
|
|
||||||
|
|
||||||
def decorator(func: Callable) -> Callable:
|
|
||||||
@wraps(func)
|
|
||||||
async def wrapper(*args, **kwargs) -> RetryResult:
|
|
||||||
start_time = time.time()
|
|
||||||
last_error = None
|
|
||||||
|
|
||||||
for attempt in range(config.max_retries + 1):
|
|
||||||
try:
|
|
||||||
# 执行函数(带超时)
|
|
||||||
if config.timeout:
|
|
||||||
result = await asyncio.wait_for(
|
|
||||||
func(*args, **kwargs),
|
|
||||||
timeout=config.timeout
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
result = await func(*args, **kwargs)
|
|
||||||
|
|
||||||
# 成功
|
|
||||||
total_time = time.time() - start_time
|
|
||||||
return RetryResult(
|
|
||||||
success=True,
|
|
||||||
result=result,
|
|
||||||
retry_count=attempt,
|
|
||||||
total_time=total_time
|
|
||||||
)
|
|
||||||
|
|
||||||
except asyncio.TimeoutError as e:
|
|
||||||
last_error = e
|
|
||||||
timed_out = True
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
last_error = e
|
|
||||||
timed_out = False
|
|
||||||
|
|
||||||
# 检查是否是不可恢复的异常
|
|
||||||
if isinstance(e, config.unrecoverable_exceptions):
|
|
||||||
break
|
|
||||||
|
|
||||||
# 检查是否达到最大重试次数
|
|
||||||
if attempt >= config.max_retries:
|
|
||||||
break
|
|
||||||
|
|
||||||
# 计算延迟
|
|
||||||
delay = _calculate_delay(attempt, config)
|
|
||||||
|
|
||||||
# 回调通知
|
|
||||||
if on_retry:
|
|
||||||
on_retry(attempt + 1, last_error)
|
|
||||||
|
|
||||||
# 等待
|
|
||||||
await asyncio.sleep(delay)
|
|
||||||
|
|
||||||
# 所有重试都失败
|
|
||||||
total_time = time.time() - start_time
|
|
||||||
return RetryResult(
|
|
||||||
success=False,
|
|
||||||
error=last_error,
|
|
||||||
retry_count=config.max_retries,
|
|
||||||
total_time=total_time,
|
|
||||||
timed_out=isinstance(last_error, asyncio.TimeoutError)
|
|
||||||
)
|
|
||||||
|
|
||||||
return wrapper
|
|
||||||
return decorator
|
|
||||||
|
|
||||||
|
|
||||||
# ========== 辅助函数 ==========
|
|
||||||
def _calculate_delay(attempt: int, config: RetryConfig) -> float:
|
|
||||||
"""计算延迟时间"""
|
|
||||||
if config.strategy == RetryStrategy.FIXED:
|
|
||||||
delay = config.base_delay
|
|
||||||
elif config.strategy == RetryStrategy.LINEAR:
|
|
||||||
delay = config.base_delay * (attempt + 1)
|
|
||||||
elif config.strategy == RetryStrategy.EXPONENTIAL:
|
|
||||||
delay = config.base_delay * (2 ** attempt)
|
|
||||||
else:
|
|
||||||
delay = config.base_delay
|
|
||||||
|
|
||||||
# 不超过最大延迟
|
|
||||||
return min(delay, config.max_delay)
|
|
||||||
|
|
||||||
|
|
||||||
# ========== 为 React 节点设计的超时重试包装器 ==========
|
|
||||||
def create_retry_wrapper_for_node(
|
|
||||||
node_func: Callable,
|
|
||||||
node_name: str,
|
|
||||||
max_retries: int = 2,
|
|
||||||
timeout: float = 30.0
|
|
||||||
):
|
|
||||||
"""
|
|
||||||
为 React 节点创建带重试和超时的包装器
|
|
||||||
|
|
||||||
Args:
|
|
||||||
node_func: 原始节点函数
|
|
||||||
node_name: 节点名称(用于错误标识)
|
|
||||||
max_retries: 最大重试次数
|
|
||||||
timeout: 单次执行超时
|
|
||||||
|
|
||||||
Returns: 包装后的节点函数
|
|
||||||
"""
|
|
||||||
config = RetryConfig(
|
|
||||||
max_retries=max_retries,
|
|
||||||
timeout=timeout,
|
|
||||||
strategy=RetryStrategy.EXPONENTIAL
|
|
||||||
)
|
|
||||||
|
|
||||||
@wraps(node_func)
|
|
||||||
def wrapped_node(state):
|
|
||||||
# 记录开始时间
|
|
||||||
start_time = time.time()
|
|
||||||
|
|
||||||
# 重试循环
|
|
||||||
last_error = None
|
|
||||||
for attempt in range(config.max_retries + 1):
|
|
||||||
try:
|
|
||||||
# 执行节点
|
|
||||||
result = node_func(state)
|
|
||||||
|
|
||||||
# 检查节点是否报告了错误
|
|
||||||
if hasattr(state, "current_error") and state.current_error:
|
|
||||||
# 节点内部报告了错误,继续重试
|
|
||||||
last_error = Exception(state.current_error.error_message)
|
|
||||||
if attempt < config.max_retries:
|
|
||||||
delay = _calculate_delay(attempt, config)
|
|
||||||
time.sleep(delay)
|
|
||||||
continue
|
|
||||||
|
|
||||||
# 成功
|
|
||||||
return result
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
last_error = e
|
|
||||||
|
|
||||||
if attempt >= config.max_retries:
|
|
||||||
break
|
|
||||||
|
|
||||||
# 等待后重试
|
|
||||||
delay = _calculate_delay(attempt, config)
|
|
||||||
time.sleep(delay)
|
|
||||||
|
|
||||||
# 所有重试都失败,更新状态错误信息
|
|
||||||
from backend.app.main_graph.state import ErrorRecord, ErrorSeverity
|
|
||||||
|
|
||||||
error_record = ErrorRecord(
|
|
||||||
error_type=f"{node_name}TimeoutError",
|
|
||||||
error_message=str(last_error) if last_error else f"{node_name} 执行超时",
|
|
||||||
severity=ErrorSeverity.ERROR,
|
|
||||||
source=node_name,
|
|
||||||
retry_count=config.max_retries,
|
|
||||||
max_retries=config.max_retries,
|
|
||||||
context={
|
|
||||||
"timeout": timeout,
|
|
||||||
"total_time": time.time() - start_time
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
if hasattr(state, "errors"):
|
|
||||||
state.errors.append(error_record)
|
|
||||||
if hasattr(state, "current_error"):
|
|
||||||
state.current_error = error_record
|
|
||||||
if hasattr(state, "error_message"):
|
|
||||||
state.error_message = str(last_error)
|
|
||||||
if hasattr(state, "current_phase"):
|
|
||||||
state.current_phase = "error_handling"
|
|
||||||
|
|
||||||
return state
|
|
||||||
|
|
||||||
return wrapped_node
|
|
||||||
|
|
||||||
|
|
||||||
# ========== 预配置的 RAG 重试配置 ==========
|
|
||||||
RAG_RETRY_CONFIG = RetryConfig(
|
|
||||||
max_retries=2,
|
|
||||||
timeout=60.0, # RAG 可以容忍稍长的超时
|
|
||||||
base_delay=2.0,
|
|
||||||
strategy=RetryStrategy.EXPONENTIAL
|
|
||||||
)
|
|
||||||
|
|
||||||
# ========== 预配置的子图重试配置 ==========
|
|
||||||
SUBGRAPH_RETRY_CONFIG = RetryConfig(
|
|
||||||
max_retries=1, # 子图通常不适合多次重试
|
|
||||||
timeout=120.0, # 子图执行时间较长
|
|
||||||
base_delay=3.0
|
|
||||||
)
|
|
||||||
220
backend/app/core/stream_finalizer.py
Normal file
220
backend/app/core/stream_finalizer.py
Normal file
@@ -0,0 +1,220 @@
|
|||||||
|
"""
|
||||||
|
流式输出格式化器
|
||||||
|
|
||||||
|
在流式输出结束后,追加格式化结构(表格、引用等)
|
||||||
|
解决流式输出与模板渲染的冲突
|
||||||
|
"""
|
||||||
|
|
||||||
|
from typing import List, Dict, Any, Optional
|
||||||
|
from dataclasses import dataclass
|
||||||
|
|
||||||
|
from backend.app.core.formatter import get_formatter
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class StreamAppend:
|
||||||
|
"""流式追加内容"""
|
||||||
|
type: str # "table" | "quote" | "list" | "divider" | "text"
|
||||||
|
content: Any
|
||||||
|
|
||||||
|
|
||||||
|
class StreamFinalizer:
|
||||||
|
"""
|
||||||
|
流式输出格式化器
|
||||||
|
|
||||||
|
在流式输出结束后追加结构化内容:
|
||||||
|
- 表格
|
||||||
|
- 引用块
|
||||||
|
- 分割线
|
||||||
|
- 文本
|
||||||
|
|
||||||
|
使用方式:
|
||||||
|
1. 流式输出主体内容
|
||||||
|
2. 调用 build_append() 获取追加内容
|
||||||
|
3. 发送追加事件到前端
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self.formatter = get_formatter()
|
||||||
|
self.md = self.formatter.md
|
||||||
|
self._appends: List[StreamAppend] = []
|
||||||
|
|
||||||
|
def reset(self):
|
||||||
|
"""重置追加队列"""
|
||||||
|
self._appends = []
|
||||||
|
return self
|
||||||
|
|
||||||
|
def add_table(self, data: List[Dict], headers: Optional[List[str]] = None):
|
||||||
|
"""添加表格"""
|
||||||
|
self._appends.append(StreamAppend(
|
||||||
|
type="table",
|
||||||
|
content={"data": data, "headers": headers}
|
||||||
|
))
|
||||||
|
return self
|
||||||
|
|
||||||
|
def add_quote(self, text: str, author: Optional[str] = None):
|
||||||
|
"""添加引用块"""
|
||||||
|
self._appends.append(StreamAppend(
|
||||||
|
type="quote",
|
||||||
|
content={"text": text, "author": author}
|
||||||
|
))
|
||||||
|
return self
|
||||||
|
|
||||||
|
def add_list(self, items: List[str], numbered: bool = False):
|
||||||
|
"""添加列表"""
|
||||||
|
self._appends.append(StreamAppend(
|
||||||
|
type="list",
|
||||||
|
content={"items": items, "numbered": numbered}
|
||||||
|
))
|
||||||
|
return self
|
||||||
|
|
||||||
|
def add_divider(self):
|
||||||
|
"""添加分割线"""
|
||||||
|
self._appends.append(StreamAppend(type="divider", content=None))
|
||||||
|
return self
|
||||||
|
|
||||||
|
def add_text(self, text: str):
|
||||||
|
"""添加文本"""
|
||||||
|
self._appends.append(StreamAppend(type="text", content=text))
|
||||||
|
return self
|
||||||
|
|
||||||
|
def add_knowledge_summary(self, topic: str, summary: str,
|
||||||
|
key_points: Optional[List[Dict]] = None,
|
||||||
|
table_data: Optional[List[Dict]] = None,
|
||||||
|
sources: Optional[List[Dict]] = None):
|
||||||
|
"""添加知识总结(使用模板)"""
|
||||||
|
table = ""
|
||||||
|
if table_data:
|
||||||
|
table = self.md.table(table_data)
|
||||||
|
|
||||||
|
self._appends.append(StreamAppend(
|
||||||
|
type="template",
|
||||||
|
content={
|
||||||
|
"name": "knowledge_summary",
|
||||||
|
"context": {
|
||||||
|
"topic": topic,
|
||||||
|
"timestamp": self._now(),
|
||||||
|
"summary": summary,
|
||||||
|
"key_points": key_points or [],
|
||||||
|
"table_data": table_data,
|
||||||
|
"table": table,
|
||||||
|
"sources": sources or [],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
))
|
||||||
|
return self
|
||||||
|
|
||||||
|
def add_web_results(self, query: str, results: List[Dict]):
|
||||||
|
"""添加搜索结果"""
|
||||||
|
self._appends.append(StreamAppend(
|
||||||
|
type="template",
|
||||||
|
content={
|
||||||
|
"name": "web_search_result",
|
||||||
|
"context": {
|
||||||
|
"query": query,
|
||||||
|
"result_count": len(results),
|
||||||
|
"results": results,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
))
|
||||||
|
return self
|
||||||
|
|
||||||
|
def build_append(self) -> str:
|
||||||
|
"""
|
||||||
|
构建追加内容
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
格式化后的追加文本
|
||||||
|
"""
|
||||||
|
if not self._appends:
|
||||||
|
return ""
|
||||||
|
|
||||||
|
lines = []
|
||||||
|
lines.append("") # 空行分隔
|
||||||
|
lines.append(self.md.divider())
|
||||||
|
lines.append("")
|
||||||
|
|
||||||
|
for append in self._appends:
|
||||||
|
if append.type == "table":
|
||||||
|
lines.append(self.md.table(append.content["data"], append.content.get("headers")))
|
||||||
|
elif append.type == "quote":
|
||||||
|
lines.append(self.md.quote(append.content["text"], append.content.get("author")))
|
||||||
|
elif append.type == "list":
|
||||||
|
if append.content["numbered"]:
|
||||||
|
lines.append(self.md.numbered_list(append.content["items"]))
|
||||||
|
else:
|
||||||
|
lines.append(self.md.bullet_list(append.content["items"]))
|
||||||
|
elif append.type == "divider":
|
||||||
|
lines.append(self.md.divider())
|
||||||
|
elif append.type == "text":
|
||||||
|
lines.append(append.content)
|
||||||
|
elif append.type == "template":
|
||||||
|
template_name = append.content["name"]
|
||||||
|
context = append.content["context"]
|
||||||
|
lines.append(self.formatter.render(template_name, **context))
|
||||||
|
|
||||||
|
lines.append("")
|
||||||
|
|
||||||
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
def build_events(self) -> List[Dict[str, Any]]:
|
||||||
|
"""
|
||||||
|
构建追加事件列表(用于流式发送)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
事件列表,每项包含 type 和 content
|
||||||
|
"""
|
||||||
|
if not self._appends:
|
||||||
|
return []
|
||||||
|
|
||||||
|
events = []
|
||||||
|
for append in self._appends:
|
||||||
|
if append.type == "table":
|
||||||
|
events.append({
|
||||||
|
"type": "append_table",
|
||||||
|
"content": self.md.table(append.content["data"], append.content.get("headers"))
|
||||||
|
})
|
||||||
|
elif append.type == "quote":
|
||||||
|
events.append({
|
||||||
|
"type": "append_quote",
|
||||||
|
"content": self.md.quote(append.content["text"], append.content.get("author"))
|
||||||
|
})
|
||||||
|
elif append.type == "list":
|
||||||
|
events.append({
|
||||||
|
"type": "append_list",
|
||||||
|
"content": self.md.numbered_list(append.content["items"]) if append.content["numbered"]
|
||||||
|
else self.md.bullet_list(append.content["items"])
|
||||||
|
})
|
||||||
|
elif append.type == "divider":
|
||||||
|
events.append({
|
||||||
|
"type": "append_divider",
|
||||||
|
"content": self.md.divider()
|
||||||
|
})
|
||||||
|
elif append.type == "text":
|
||||||
|
events.append({
|
||||||
|
"type": "append_text",
|
||||||
|
"content": append.content
|
||||||
|
})
|
||||||
|
elif append.type == "template":
|
||||||
|
template_name = append.content["name"]
|
||||||
|
context = append.content["context"]
|
||||||
|
events.append({
|
||||||
|
"type": "append_template",
|
||||||
|
"template": template_name,
|
||||||
|
"content": self.formatter.render(template_name, **context)
|
||||||
|
})
|
||||||
|
|
||||||
|
return events
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _now() -> str:
|
||||||
|
"""获取当前时间"""
|
||||||
|
from datetime import datetime
|
||||||
|
return datetime.now().strftime('%Y-%m-%d %H:%M:%S')
|
||||||
|
|
||||||
|
|
||||||
|
# ========== 便捷函数 ==========
|
||||||
|
|
||||||
|
def create_finalizer() -> StreamFinalizer:
|
||||||
|
"""创建流式格式化器"""
|
||||||
|
return StreamFinalizer()
|
||||||
@@ -158,12 +158,13 @@ class WebSearchTool:
|
|||||||
|
|
||||||
return results
|
return results
|
||||||
|
|
||||||
def format_search_results(self, results: List[SearchResult]) -> str:
|
def format_search_results(self, results: List[SearchResult], query: str = "") -> str:
|
||||||
"""
|
"""
|
||||||
格式化搜索结果(带引用溯源)
|
格式化搜索结果(使用模板渲染)
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
results: 搜索结果列表
|
results: 搜索结果列表
|
||||||
|
query: 搜索关键词
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
格式化后的 Markdown 文本
|
格式化后的 Markdown 文本
|
||||||
@@ -171,22 +172,27 @@ class WebSearchTool:
|
|||||||
if not results:
|
if not results:
|
||||||
return "未找到相关搜索结果"
|
return "未找到相关搜索结果"
|
||||||
|
|
||||||
lines = ["## 🔍 联网搜索结果\n"]
|
from backend.app.core import get_formatter
|
||||||
|
formatter = get_formatter()
|
||||||
|
|
||||||
for idx, result in enumerate(results, 1):
|
# 转换为字典列表供模板使用
|
||||||
lines.append(f"### [{idx}] {result.title}")
|
result_dicts = []
|
||||||
lines.append(f"- 🔗 来源:[{result.url}]({result.url})")
|
for r in results:
|
||||||
lines.append(f"- 📝 摘要:{result.snippet}")
|
result_dicts.append({
|
||||||
lines.append(f"- 📅 时间:{result.timestamp.strftime('%Y-%m-%d %H:%M:%S')}")
|
"title": r.title,
|
||||||
lines.append("")
|
"url": r.url,
|
||||||
|
"snippet": r.snippet,
|
||||||
|
"source": r.source,
|
||||||
|
"timestamp": r.timestamp.strftime('%Y-%m-%d %H:%M:%S') if r.timestamp else "",
|
||||||
|
})
|
||||||
|
|
||||||
lines.append("---")
|
return formatter.render(
|
||||||
lines.append("💡 **引用溯源说明**:")
|
"web_search_result",
|
||||||
lines.append("- 以上搜索结果均标注了来源链接")
|
query=query,
|
||||||
lines.append("- 使用方括号数字标识引用(如 [1]、[2])")
|
result_count=len(results),
|
||||||
lines.append("- 可通过链接追溯原始信息")
|
results=result_dicts,
|
||||||
|
citation_note="💡 **引用溯源说明**:以上搜索结果均标注了来源链接,可通过链接追溯原始信息。"
|
||||||
return "\n".join(lines)
|
)
|
||||||
|
|
||||||
|
|
||||||
# 单例实例
|
# 单例实例
|
||||||
@@ -214,4 +220,4 @@ def web_search(query: str, max_results: int = 5) -> str:
|
|||||||
"""
|
"""
|
||||||
tool = get_web_search_tool()
|
tool = get_web_search_tool()
|
||||||
results = tool.search(query, max_results)
|
results = tool.search(query, max_results)
|
||||||
return tool.format_search_results(results)
|
return tool.format_search_results(results, query=query)
|
||||||
|
|||||||
15
backend/app/middleware/__init__.py
Normal file
15
backend/app/middleware/__init__.py
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
"""
|
||||||
|
中间件模块
|
||||||
|
"""
|
||||||
|
|
||||||
|
from .response_formatter import (
|
||||||
|
ResponseFormatterMiddleware,
|
||||||
|
format_error_response,
|
||||||
|
format_success_response,
|
||||||
|
)
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"ResponseFormatterMiddleware",
|
||||||
|
"format_error_response",
|
||||||
|
"format_success_response",
|
||||||
|
]
|
||||||
91
backend/app/middleware/response_formatter.py
Normal file
91
backend/app/middleware/response_formatter.py
Normal file
@@ -0,0 +1,91 @@
|
|||||||
|
"""
|
||||||
|
响应格式化中间件
|
||||||
|
|
||||||
|
自动将 API 响应中的字符串或错误信息格式化为统一风格
|
||||||
|
"""
|
||||||
|
|
||||||
|
from starlette.middleware.base import BaseHTTPMiddleware
|
||||||
|
from starlette.requests import Request
|
||||||
|
from starlette.responses import JSONResponse
|
||||||
|
from typing import Callable
|
||||||
|
|
||||||
|
from backend.app.core import get_formatter
|
||||||
|
|
||||||
|
|
||||||
|
class ResponseFormatterMiddleware(BaseHTTPMiddleware):
|
||||||
|
"""
|
||||||
|
响应格式化中间件
|
||||||
|
|
||||||
|
功能:
|
||||||
|
1. 统一响应包装
|
||||||
|
2. 错误信息格式化
|
||||||
|
3. 调试信息注入(可选)
|
||||||
|
"""
|
||||||
|
|
||||||
|
async def dispatch(self, request: Request, call_next: Callable):
|
||||||
|
response = await call_next(request)
|
||||||
|
return response
|
||||||
|
|
||||||
|
|
||||||
|
def format_error_response(
|
||||||
|
error_type: str,
|
||||||
|
error_message: str,
|
||||||
|
suggestions: list = None,
|
||||||
|
retry_count: int = 0,
|
||||||
|
max_retries: int = None
|
||||||
|
) -> str:
|
||||||
|
"""
|
||||||
|
格式化错误响应
|
||||||
|
|
||||||
|
Args:
|
||||||
|
error_type: 错误类型
|
||||||
|
error_message: 错误详情
|
||||||
|
suggestions: 建议操作列表
|
||||||
|
retry_count: 已重试次数
|
||||||
|
max_retries: 最大重试次数
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
格式化后的 Markdown 文本
|
||||||
|
"""
|
||||||
|
formatter = get_formatter()
|
||||||
|
return formatter.render_error(
|
||||||
|
error_type=error_type,
|
||||||
|
error_message=error_message,
|
||||||
|
suggestions=suggestions,
|
||||||
|
retry_count=retry_count,
|
||||||
|
max_retries=max_retries
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def format_success_response(
|
||||||
|
content: str,
|
||||||
|
title: str = None,
|
||||||
|
include_footer: bool = True
|
||||||
|
) -> str:
|
||||||
|
"""
|
||||||
|
格式化成功响应
|
||||||
|
|
||||||
|
Args:
|
||||||
|
content: 内容
|
||||||
|
title: 可选标题
|
||||||
|
include_footer: 是否包含页脚
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
格式化后的 Markdown 文本
|
||||||
|
"""
|
||||||
|
formatter = get_formatter()
|
||||||
|
md = formatter.md
|
||||||
|
|
||||||
|
lines = []
|
||||||
|
if title:
|
||||||
|
lines.append(md.heading(title, 2))
|
||||||
|
lines.append("")
|
||||||
|
|
||||||
|
lines.append(content)
|
||||||
|
|
||||||
|
if include_footer:
|
||||||
|
lines.append("")
|
||||||
|
lines.append("---")
|
||||||
|
lines.append("*以上内容由 AI Agent 生成*")
|
||||||
|
|
||||||
|
return "\n".join(lines)
|
||||||
@@ -7,10 +7,9 @@ Contact Subgraph Nodes - Using Common Tools
|
|||||||
from typing import Dict, Any
|
from typing import Dict, Any
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
|
||||||
# 公共工具
|
from backend.app.core import get_formatter
|
||||||
from backend.app.core import MarkdownFormatter
|
|
||||||
|
|
||||||
from .state import ContactState
|
from .state import ContactState, ContactAction, Contact
|
||||||
from .api_client import ContactAPIClient
|
from .api_client import ContactAPIClient
|
||||||
|
|
||||||
|
|
||||||
@@ -121,11 +120,12 @@ def create_contact_nodes(contact_api: ContactAPIClient):
|
|||||||
|
|
||||||
async def format_result(state: ContactState) -> ContactState:
|
async def format_result(state: ContactState) -> ContactState:
|
||||||
"""
|
"""
|
||||||
格式化结果节点(使用公共工具)
|
格式化结果节点(使用全局 Formatter)
|
||||||
"""
|
"""
|
||||||
state.current_phase = "formatting"
|
state.current_phase = "formatting"
|
||||||
|
|
||||||
md = MarkdownFormatter()
|
fmt = get_formatter()
|
||||||
|
md = fmt.md
|
||||||
output_lines = []
|
output_lines = []
|
||||||
|
|
||||||
output_lines.append("┌───────────────────────────────────┐")
|
output_lines.append("┌───────────────────────────────────┐")
|
||||||
|
|||||||
@@ -7,10 +7,7 @@ from typing import Dict, Any, List
|
|||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
import random
|
import random
|
||||||
|
|
||||||
# 公共工具
|
from backend.app.core import get_formatter
|
||||||
from backend.app.core import (
|
|
||||||
MarkdownFormatter
|
|
||||||
)
|
|
||||||
|
|
||||||
from .state import (
|
from .state import (
|
||||||
DictionaryState,
|
DictionaryState,
|
||||||
@@ -172,12 +169,12 @@ def add_to_word_book(state: DictionaryState) -> DictionaryState:
|
|||||||
|
|
||||||
def format_result(state: DictionaryState) -> DictionaryState:
|
def format_result(state: DictionaryState) -> DictionaryState:
|
||||||
"""
|
"""
|
||||||
格式化结果节点(使用公共工具)
|
格式化结果节点(使用全局 Formatter)
|
||||||
生成友好的 Markdown 输出
|
|
||||||
"""
|
"""
|
||||||
state.current_phase = "formatting"
|
state.current_phase = "formatting"
|
||||||
|
|
||||||
md = MarkdownFormatter()
|
fmt = get_formatter()
|
||||||
|
md = fmt.md
|
||||||
output_lines = []
|
output_lines = []
|
||||||
|
|
||||||
# 标题
|
# 标题
|
||||||
|
|||||||
@@ -6,8 +6,7 @@ News Analysis Subgraph Nodes - Using Common Tools
|
|||||||
from typing import Dict, Any
|
from typing import Dict, Any
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
|
||||||
# 公共工具
|
from backend.app.core import get_formatter
|
||||||
from backend.app.core import MarkdownFormatter
|
|
||||||
|
|
||||||
from .state import (
|
from .state import (
|
||||||
NewsAnalysisState,
|
NewsAnalysisState,
|
||||||
@@ -104,11 +103,12 @@ def generate_report(state: NewsAnalysisState) -> NewsAnalysisState:
|
|||||||
|
|
||||||
def format_result(state: NewsAnalysisState) -> NewsAnalysisState:
|
def format_result(state: NewsAnalysisState) -> NewsAnalysisState:
|
||||||
"""
|
"""
|
||||||
格式化结果节点(使用公共工具)
|
格式化结果节点(使用全局 Formatter)
|
||||||
"""
|
"""
|
||||||
state.current_phase = "formatting"
|
state.current_phase = "formatting"
|
||||||
|
|
||||||
md = MarkdownFormatter()
|
fmt = get_formatter()
|
||||||
|
md = fmt.md
|
||||||
output_lines = []
|
output_lines = []
|
||||||
|
|
||||||
output_lines.append("┌───────────────────────────────────┐")
|
output_lines.append("┌───────────────────────────────────┐")
|
||||||
|
|||||||
9
backend/app/templates/__init__.py
Normal file
9
backend/app/templates/__init__.py
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
"""
|
||||||
|
模板目录 - 存放可编辑的输出模板
|
||||||
|
"""
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
TEMPLATES_DIR = Path(__file__).parent
|
||||||
|
|
||||||
|
__all__ = ["TEMPLATES_DIR"]
|
||||||
26
backend/app/templates/conversation_summary.md
Normal file
26
backend/app/templates/conversation_summary.md
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
# 对话摘要
|
||||||
|
|
||||||
|
**时间**: {{ timestamp }}
|
||||||
|
{% if participants %}
|
||||||
|
**参与者**: {{ participants }}
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📋 对话要点
|
||||||
|
|
||||||
|
{{ bullet_list(points) }}
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📝 总结
|
||||||
|
|
||||||
|
{{ summary }}
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
{% if next_steps %}
|
||||||
|
## ➡️ 下一步
|
||||||
|
|
||||||
|
{{ bullet_list(next_steps) }}
|
||||||
|
{% endif %}
|
||||||
21
backend/app/templates/error_notification.md
Normal file
21
backend/app/templates/error_notification.md
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
## ⚠️ 操作失败
|
||||||
|
|
||||||
|
**错误类型**: {{ error_type }}
|
||||||
|
|
||||||
|
{% if error_message %}
|
||||||
|
**错误详情**: {{ error_message }}
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 💡 建议操作
|
||||||
|
|
||||||
|
{{ suggestions }}
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
{% if retry_count %}
|
||||||
|
> 已重试 {{ retry_count }} {% if max_retries %}/ 最多 {{ max_retries }} 次{% endif %}
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
*如果问题持续存在,请联系管理员或稍后重试*
|
||||||
48
backend/app/templates/knowledge_summary.md
Normal file
48
backend/app/templates/knowledge_summary.md
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
# 📚 知识总结
|
||||||
|
|
||||||
|
**主题**: {{ topic }}
|
||||||
|
**生成时间**: {{ timestamp }}
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📋 内容概览
|
||||||
|
|
||||||
|
{{ summary }}
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
{% if key_points %}
|
||||||
|
## 🔑 关键要点
|
||||||
|
|
||||||
|
{% for point in key_points %}
|
||||||
|
### {{ loop.index }}. {{ point.title }}
|
||||||
|
|
||||||
|
{{ point.description }}
|
||||||
|
{% endfor %}
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
{% if table_data %}
|
||||||
|
## 📊 数据表格
|
||||||
|
|
||||||
|
{{ table }}
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
{% if sources %}
|
||||||
|
## 📖 参考来源
|
||||||
|
|
||||||
|
{% for source in sources %}
|
||||||
|
- [{{ source.title }}]({{ source.url }})
|
||||||
|
{% endfor %}
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
{% if next_steps %}
|
||||||
|
## ➡️ 后续建议
|
||||||
|
|
||||||
|
{{ suggestions }}
|
||||||
|
{% endif %}
|
||||||
24
backend/app/templates/tool_result.md
Normal file
24
backend/app/templates/tool_result.md
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
# 工具执行结果
|
||||||
|
|
||||||
|
**工具**: {{ tool_name }}
|
||||||
|
**状态**: {{ status }}
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
{{ content }}
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
{% if metadata %}
|
||||||
|
### 📊 执行信息
|
||||||
|
|
||||||
|
| 项目 | 值 |
|
||||||
|
|------|-----|
|
||||||
|
{% for key, value in metadata.items() %}
|
||||||
|
| {{ key }} | {{ value }} |
|
||||||
|
{% endfor %}
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if duration %}
|
||||||
|
*执行耗时: {{ duration }}ms*
|
||||||
|
{% endif %}
|
||||||
28
backend/app/templates/web_search_result.md
Normal file
28
backend/app/templates/web_search_result.md
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
## 🔍 搜索结果
|
||||||
|
|
||||||
|
{% if query %}
|
||||||
|
**查询**: {{ query }}
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if result_count %}
|
||||||
|
找到 {{ result_count }} 条相关结果
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
{% for item in results %}
|
||||||
|
### [{{ loop.index }}] {{ item.title }}
|
||||||
|
|
||||||
|
- **来源**: [{{ item.url }}]({{ item.url }})
|
||||||
|
- **摘要**: {{ item.snippet }}
|
||||||
|
{% if item.source %}
|
||||||
|
- **来源网站**: {{ item.source }}
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% endfor %}
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
{% if citation_note %}
|
||||||
|
{{ citation_note }}
|
||||||
|
{% endif %}
|
||||||
Reference in New Issue
Block a user