优化输出
All checks were successful
构建并部署 AI Agent 服务 / deploy (push) Successful in 6m6s

This commit is contained in:
2026-05-09 01:51:18 +08:00
parent b30f7b00a7
commit 4c119073bc
18 changed files with 973 additions and 2941 deletions

2362
README.md

File diff suppressed because it is too large Load Diff

View File

@@ -3,6 +3,7 @@ FastAPI 后端 - 支持动态模型切换,使用 PostgreSQL 持久化记忆
采用依赖注入模式,优雅管理资源生命周期
"""
import asyncio
import warnings
# 抑制 WebSocket 弃用警告websockets 库升级导致uvicorn 尚未跟进)
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.models import ContactRepository, DictionaryRepository, NewsRepository
from backend.app.logger import info, error
from backend.app.core import get_formatter
@asynccontextmanager
async def lifespan(app: FastAPI):
@@ -189,30 +191,35 @@ async def chat_endpoint(
)
except Exception as e:
error(f"同步响应异常: {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", "不可用"])
# 1. 自我介绍
intro_text = "你好!我是 AI 智能助手,我可以帮你处理各种问题,包括查询通讯录、词典翻译、新闻分析、知识库检索、联网搜索等。\n\n"
# 2. 错误信息(红色突出)
error_display = f"**⚠️ 当前遇到问题**\n\n```diff\n- {error_message}\n```\n\n"
# 3. 模型切换提示(如果是超时/不可用错误)
switch_hint = ""
formatter = get_formatter()
if is_timeout_error:
switch_hint = "💡 **提示**:当前模型可能响应超时或不可用,请尝试手动切换到其他模型(如 DeepSeek、智谱AI等\n\n"
# 4. 组合完整兜底回复
fallback_text = intro_text + error_display + switch_hint
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=["请稍后重试", "如果问题持续存在,请联系管理员"],
)
actual_model = request.model if request.model in agent_service.graphs else next(iter(agent_service.graphs.keys()))
return ChatResponse(
reply=fallback_text,
reply=error_reply,
thread_id=thread_id,
model_used=actual_model,
input_tokens=0,
@@ -273,33 +280,36 @@ async def chat_stream_endpoint(
yield "data: [DONE]\n\n"
except Exception as e:
error(f"流式响应异常: {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", "不可用"])
# 1. 自我介绍
intro_text = "你好!我是 AI 智能助手,我可以帮你处理各种问题,包括查询通讯录、词典翻译、新闻分析、知识库检索、联网搜索等。\n\n"
# 2. 错误信息(红色突出)
error_display = f"**⚠️ 当前遇到问题**\n\n```diff\n- {error_message}\n```\n\n"
# 3. 模型切换提示(如果是超时/不可用错误)
switch_hint = ""
formatter = get_formatter()
if is_timeout_error:
switch_hint = "💡 **提示**:当前模型可能响应超时或不可用,请尝试手动切换到其他模型(如 DeepSeek、智谱AI等\n\n"
# 4. 组合完整兜底回复
fallback_text = intro_text + error_display + switch_hint
# 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"
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=["请稍后重试"],
)
# 以 llm_token 方式发送错误回复,模拟打字机效果
for char in error_reply:
yield f"data: {json.dumps({'type': 'llm_token', 'node': 'error', 'token': char}, ensure_ascii=False)}\n\n"
import asyncio
await asyncio.sleep(0.01)
# 6. 发送错误事件
yield f"data: {json.dumps({'type': 'error', 'message': error_message}, ensure_ascii=False)}\n\n"
yield "data: [DONE]\n\n"

View File

@@ -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 .human_review import (
ReviewManager,
@@ -23,6 +24,10 @@ from .visualization import (
__all__ = [
"MarkdownFormatter",
"OutputRenderer",
"get_formatter",
"StreamFinalizer",
"create_finalizer",
"BaseState",
"ReviewManager",
"InMemoryReviewStore",

View File

@@ -1,482 +1,238 @@
"""
格式化输出工具模块
提供基于 Jinja2 模板的 Markdown 格式化输出能力
功能
1. TemplateManager - 模板管理器,支持加载和渲染 Jinja2 模板
2. MarkdownFormatter - Markdown 格式化工具,提供常用格式(表格、列表、引用等)
3. OutputRenderer - 输出渲染器,统一接口生成最终输出
4. PresetTemplates - 预置模板(对话摘要、报告、列表等)
提供统一的 Markdown 格式化能力
1. MarkdownFormatter - 静态方法生成 Markdown 元素
2. OutputRenderer - 模板渲染 + 全局单例
3. get_formatter() - 获取全局单例
"""
import os
from typing import Dict, List, Any, Optional
from pathlib import Path
from typing import Dict, List, Any, Optional, Union
from dataclasses import dataclass
from abc import ABC, abstractmethod
# 尝试导入 Jinja2如果没有则提供基础实现
try:
from jinja2 import Template as JinjaTemplate, Environment, BaseLoader
HAS_JINJA2 = True
except ImportError:
HAS_JINJA2 = False
from backend.app.logger import info, warning
from backend.app.templates import TEMPLATES_DIR
class BaseFormatter(ABC):
"""格式化器基类"""
@abstractmethod
def format(self, data: Any) -> str:
"""格式化数据为字符串"""
pass
# ========== Markdown 格式化器 ==========
class MarkdownFormatter:
"""Markdown 格式化工具(静态方法)"""
class MarkdownFormatter(BaseFormatter):
"""Markdown 格式化工具"""
@staticmethod
def table(data: List[Dict[str, Any]], headers: Optional[List[str]] = None) -> str:
"""
生成 Markdown 表格
Args:
data: 数据列表,每个元素是一个字典
headers: 表头列表,如果为 None 则使用字典的键
Returns:
Markdown 表格字符串
"""
"""生成 Markdown 表格"""
if not data:
return ""
if headers is None:
headers = list(data[0].keys()) if data else []
if not headers:
return ""
lines = []
# 表头行
header_line = "| " + " | ".join(str(h) for h in headers) + " |"
lines.append(header_line)
# 分隔线
separator_line = "| " + " | ".join("---" for _ in headers) + " |"
lines.append(separator_line)
# 数据行
for row in data:
row_values = [str(row.get(h, "")) for h in headers]
row_line = "| " + " | ".join(row_values) + " |"
lines.append(row_line)
lines.append("| " + " | ".join(row_values) + " |")
return "\n".join(lines)
@staticmethod
def bullet_list(items: List[str], indent: int = 0) -> str:
"""
生成无序列表
Args:
items: 列表项
indent: 缩进层级
Returns:
Markdown 无序列表字符串
"""
"""生成无序列表"""
indent_str = " " * indent
return "\n".join(f"{indent_str}- {item}" for item in items)
@staticmethod
def numbered_list(items: List[str], start: int = 1, indent: int = 0) -> str:
"""
生成有序列表
Args:
items: 列表项
start: 起始编号
indent: 缩进层级
Returns:
Markdown 有序列表字符串
"""
"""生成有序列表"""
indent_str = " " * indent
return "\n".join(f"{indent_str}{i}. {item}" for i, item in enumerate(items, start=start))
@staticmethod
def quote(text: str, author: Optional[str] = None) -> str:
"""
生成引用块
Args:
text: 引用文本
author: 作者(可选)
Returns:
Markdown 引用块字符串
"""
quoted_lines = "\n".join(f"> {line}" for line in text.split("\n"))
"""生成引用块"""
quoted = "\n".join(f"> {line}" for line in text.split("\n"))
if author:
quoted_lines += f"\n> — {author}"
return quoted_lines
quoted += f"\n> — {author}"
return quoted
@staticmethod
def code(code: str, language: str = "") -> str:
"""
生成代码块
Args:
code: 代码内容
language: 语言标识符
Returns:
Markdown 代码块字符串
"""
"""生成代码块"""
return f"```{language}\n{code}\n```"
@staticmethod
def heading(text: str, level: int = 1) -> str:
"""
生成标题
Args:
text: 标题文本
level: 标题级别1-6
Returns:
Markdown 标题字符串
"""
"""生成标题"""
level = max(1, min(6, level))
return f"{'#' * level} {text}"
@staticmethod
def link(text: str, url: str) -> str:
"""
生成链接
Args:
text: 链接文本
url: 链接地址
Returns:
Markdown 链接字符串
"""
"""生成链接"""
return f"[{text}]({url})"
@staticmethod
def bold(text: str) -> str:
"""生成粗体"""
return f"**{text}**"
@staticmethod
def italic(text: str) -> str:
"""生成斜体"""
return f"*{text}*"
@staticmethod
def divider() -> str:
"""生成分割线"""
return "---"
def format(self, data: Any) -> str:
"""实现基类方法,根据数据类型自动选择格式化方式"""
@staticmethod
def format(data: Any) -> str:
"""根据数据类型自动选择格式化方式"""
if isinstance(data, list):
if len(data) > 0 and isinstance(data[0], dict):
return self.table(data)
else:
return self.bullet_list([str(item) for item in data])
if data and isinstance(data[0], dict):
return MarkdownFormatter.table(data)
return MarkdownFormatter.bullet_list([str(item) for item in data])
elif isinstance(data, dict):
return self.table([data])
else:
return str(data)
return MarkdownFormatter.table([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:
"""Jinja2 模板管理器"""
"""模板管理器,支持从文件加载 .md 或 .jinja 模板"""
def __init__(self, template_dir: Optional[Path] = None):
"""
初始化模板管理器
Args:
template_dir: 模板目录路径
"""
self._templates: Dict[str, Template] = {}
self.template_dir = template_dir
self._env: Optional[Environment] = None
if HAS_JINJA2:
self._env = Environment(loader=DictLoader({}))
def _refresh_env(self) -> None:
"""刷新 Jinja2 环境"""
if HAS_JINJA2 and self._env is not None:
template_dict = {name: t.content for name, t in self._templates.items()}
self._env = Environment(loader=DictLoader(template_dict))
def add_template(self, name: str, content: str, description: str = "") -> None:
"""
添加模板
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
"""
self.template_dir = template_dir or TEMPLATES_DIR
self._templates: Dict[str, str] = {}
self._load_templates()
def _load_templates(self) -> None:
"""从模板目录加载所有 .md 和 .jinja 文件"""
if not self.template_dir.exists():
warning(f"[Formatter] 模板目录不存在: {self.template_dir}")
return
for tmpl_file in self.template_dir.glob("*.md"):
name = tmpl_file.stem
self._templates[name] = tmpl_file.read_text(encoding="utf-8")
info(f"[Formatter] 加载模板: {name}")
for tmpl_file in self.template_dir.glob("*.jinja"):
name = tmpl_file.stem
self._templates[name] = tmpl_file.read_text(encoding="utf-8")
info(f"[Formatter] 加载模板: {name}")
def get(self, name: str) -> Optional[str]:
"""获取模板内容"""
return self._templates.get(name)
def render(self, template_name: str, context: Dict[str, Any]) -> str:
"""
渲染模板
Args:
template_name: 模板名称
context: 渲染上下文
Returns:
渲染后的字符串
"""
template = self.get_template(template_name)
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
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():
result = result.replace(f"{{{{{key}}}}}", str(value))
result = result.replace(f"{{{{ {key} }}}}", str(value))
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 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):
"""
输出渲染器 - 统一接口渲染模板
使用全局单例,通过 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:
template_manager: 模板管理器
template_name: 模板名称(不含扩展名)
**context: 渲染上下文
"""
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)
return self._templates.render(template_name, context)
def render_plain(self, data: Any) -> str:
"""
直接格式化数据为 Markdown
Args:
data: 数据
Returns:
格式化后的字符串
"""
return self.markdown.format(data)
"""直接格式化数据为 Markdown"""
return self.md.format(data)
def render_error(self, error_type: str, error_message: str = "",
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)
# ========== 全局单例 ==========
_formatter: Optional[OutputRenderer] = None
def get_formatter() -> OutputRenderer:
"""获取全局 OutputRenderer 单例"""
global _formatter
if _formatter is None:
_formatter = OutputRenderer()
return _formatter

View File

@@ -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
)

View 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()

View File

@@ -158,12 +158,13 @@ class WebSearchTool:
return results
def format_search_results(self, results: List[SearchResult]) -> str:
def format_search_results(self, results: List[SearchResult], query: str = "") -> str:
"""
格式化搜索结果(带引用溯源
格式化搜索结果(使用模板渲染
Args:
results: 搜索结果列表
query: 搜索关键词
Returns:
格式化后的 Markdown 文本
@@ -171,22 +172,27 @@ class WebSearchTool:
if not results:
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}")
lines.append(f"- 🔗 来源:[{result.url}]({result.url})")
lines.append(f"- 📝 摘要:{result.snippet}")
lines.append(f"- 📅 时间:{result.timestamp.strftime('%Y-%m-%d %H:%M:%S')}")
lines.append("")
# 转换为字典列表供模板使用
result_dicts = []
for r in results:
result_dicts.append({
"title": r.title,
"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("---")
lines.append("💡 **引用溯源说明**")
lines.append("- 以上搜索结果均标注了来源链接")
lines.append("- 使用方括号数字标识引用(如 [1]、[2]")
lines.append("- 可通过链接追溯原始信息")
return "\n".join(lines)
return formatter.render(
"web_search_result",
query=query,
result_count=len(results),
results=result_dicts,
citation_note="💡 **引用溯源说明**:以上搜索结果均标注了来源链接,可通过链接追溯原始信息。"
)
# 单例实例
@@ -214,4 +220,4 @@ def web_search(query: str, max_results: int = 5) -> str:
"""
tool = get_web_search_tool()
results = tool.search(query, max_results)
return tool.format_search_results(results)
return tool.format_search_results(results, query=query)

View File

@@ -0,0 +1,15 @@
"""
中间件模块
"""
from .response_formatter import (
ResponseFormatterMiddleware,
format_error_response,
format_success_response,
)
__all__ = [
"ResponseFormatterMiddleware",
"format_error_response",
"format_success_response",
]

View 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)

View File

@@ -7,10 +7,9 @@ Contact Subgraph Nodes - Using Common Tools
from typing import Dict, Any
from datetime import datetime
# 公共工具
from backend.app.core import MarkdownFormatter
from backend.app.core import get_formatter
from .state import ContactState
from .state import ContactState, ContactAction, Contact
from .api_client import ContactAPIClient
@@ -121,11 +120,12 @@ def create_contact_nodes(contact_api: ContactAPIClient):
async def format_result(state: ContactState) -> ContactState:
"""
格式化结果节点(使用公共工具
格式化结果节点(使用全局 Formatter
"""
state.current_phase = "formatting"
md = MarkdownFormatter()
fmt = get_formatter()
md = fmt.md
output_lines = []
output_lines.append("┌───────────────────────────────────┐")

View File

@@ -7,10 +7,7 @@ from typing import Dict, Any, List
from datetime import datetime
import random
# 公共工具
from backend.app.core import (
MarkdownFormatter
)
from backend.app.core import get_formatter
from .state import (
DictionaryState,
@@ -172,12 +169,12 @@ def add_to_word_book(state: DictionaryState) -> DictionaryState:
def format_result(state: DictionaryState) -> DictionaryState:
"""
格式化结果节点(使用公共工具
生成友好的 Markdown 输出
格式化结果节点(使用全局 Formatter
"""
state.current_phase = "formatting"
md = MarkdownFormatter()
fmt = get_formatter()
md = fmt.md
output_lines = []
# 标题

View File

@@ -6,8 +6,7 @@ News Analysis Subgraph Nodes - Using Common Tools
from typing import Dict, Any
from datetime import datetime
# 公共工具
from backend.app.core import MarkdownFormatter
from backend.app.core import get_formatter
from .state import (
NewsAnalysisState,
@@ -104,11 +103,12 @@ def generate_report(state: NewsAnalysisState) -> NewsAnalysisState:
def format_result(state: NewsAnalysisState) -> NewsAnalysisState:
"""
格式化结果节点(使用公共工具
格式化结果节点(使用全局 Formatter
"""
state.current_phase = "formatting"
md = MarkdownFormatter()
fmt = get_formatter()
md = fmt.md
output_lines = []
output_lines.append("┌───────────────────────────────────┐")

View File

@@ -0,0 +1,9 @@
"""
模板目录 - 存放可编辑的输出模板
"""
from pathlib import Path
TEMPLATES_DIR = Path(__file__).parent
__all__ = ["TEMPLATES_DIR"]

View File

@@ -0,0 +1,26 @@
# 对话摘要
**时间**: {{ timestamp }}
{% if participants %}
**参与者**: {{ participants }}
{% endif %}
---
## 📋 对话要点
{{ bullet_list(points) }}
---
## 📝 总结
{{ summary }}
---
{% if next_steps %}
## ➡️ 下一步
{{ bullet_list(next_steps) }}
{% endif %}

View 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 %}
*如果问题持续存在,请联系管理员或稍后重试*

View 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 %}

View 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 %}

View 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 %}