feat: 实现完整的人工审核功能与子图模块

- 新增三个核心子图:人工审核、意图理解、格式化输出
- 实现完整的审核 API 端点(/api/review/*)
- 前端添加审核确认界面(右下角固定框)
- 为每个子图创建分步测试代码
- 添加功能实现文档
This commit is contained in:
2026-04-25 13:24:50 +08:00
parent 851d52ed8d
commit bc26b81f08
9 changed files with 1981 additions and 4 deletions

View File

@@ -0,0 +1,60 @@
"""
公共工具模块
提供可复用的基础组件
导出:
- formatter: 格式化输出工具
- intent: 意图理解工具
- human_review: 人工审核工具
"""
from .formatter import (
MarkdownFormatter,
TemplateManager,
OutputRenderer,
PresetTemplates
)
from .intent import (
IntentType,
Intent,
Entity,
IntentParser,
RuleBasedIntentClassifier,
RuleBasedEntityExtractor,
IntentRegistry,
create_default_intent_parser
)
from .human_review import (
ReviewStatus,
HumanReview,
HumanReviewStore,
InMemoryReviewStore,
HumanReviewNode,
ReviewManager
)
__all__ = [
# formatter
"MarkdownFormatter",
"TemplateManager",
"OutputRenderer",
"PresetTemplates",
# intent
"IntentType",
"Intent",
"Entity",
"IntentParser",
"RuleBasedIntentClassifier",
"RuleBasedEntityExtractor",
"IntentRegistry",
"create_default_intent_parser",
# human_review
"ReviewStatus",
"HumanReview",
"HumanReviewStore",
"InMemoryReviewStore",
"HumanReviewNode",
"ReviewManager"
]

View File

@@ -0,0 +1,481 @@
"""
格式化输出工具模块
提供基于 Jinja2 模板的 Markdown 格式化输出能力
功能:
1. TemplateManager - 模板管理器,支持加载和渲染 Jinja2 模板
2. MarkdownFormatter - Markdown 格式化工具,提供常用格式(表格、列表、引用等)
3. OutputRenderer - 输出渲染器,统一接口生成最终输出
4. PresetTemplates - 预置模板(对话摘要、报告、列表等)
"""
import os
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
class BaseFormatter(ABC):
"""格式化器基类"""
@abstractmethod
def format(self, data: Any) -> str:
"""格式化数据为字符串"""
pass
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 表格字符串
"""
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)
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"))
if author:
quoted_lines += f"\n> — {author}"
return quoted_lines
@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:
"""实现基类方法,根据数据类型自动选择格式化方式"""
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])
elif isinstance(data, dict):
return self.table([data])
else:
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 模板管理器"""
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
"""
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
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:
"""
直接格式化数据为 Markdown
Args:
data: 数据
Returns:
格式化后的字符串
"""
return self.markdown.format(data)

View File

@@ -0,0 +1,465 @@
"""
人工审核工具模块
提供 LangGraph interrupt 机制和状态持久化能力
功能:
1. HumanReview - 人工审核数据类
2. ReviewStatus - 审核状态枚举
3. HumanReviewStore - 审核存储接口
4. InMemoryReviewStore - 内存存储实现
5. HumanReviewNode - LangGraph 审核节点
6. ReviewManager - 审核管理器
"""
from typing import Dict, List, Any, Optional, Callable
from dataclasses import dataclass, field
from enum import Enum, auto
from abc import ABC, abstractmethod
from datetime import datetime
import uuid
class ReviewStatus(Enum):
"""审核状态枚举"""
PENDING = auto() # 待审核
APPROVED = auto() # 已通过
REJECTED = auto() # 已拒绝
MODIFIED = auto() # 已修改
TIMEOUT = auto() # 已超时
@dataclass
class HumanReview:
"""人工审核数据类"""
review_id: str # 审核ID
thread_id: str # 线程ID
user_id: str # 用户ID
status: ReviewStatus # 审核状态
content_to_review: str # 待审核内容
review_comment: str = "" # 审核意见
modified_content: str = "" # 修改后的内容
created_at: datetime = field(default_factory=datetime.now)
reviewed_at: Optional[datetime] = None
reviewer: Optional[str] = None
metadata: Dict[str, Any] = field(default_factory=dict)
class HumanReviewStore(ABC):
"""审核存储接口"""
@abstractmethod
def save(self, review: HumanReview) -> None:
"""
保存审核
Args:
review: 审核对象
"""
pass
@abstractmethod
def get(self, review_id: str) -> Optional[HumanReview]:
"""
获取审核
Args:
review_id: 审核ID
Returns:
审核对象,如果不存在返回 None
"""
pass
@abstractmethod
def get_by_thread(self, thread_id: str) -> List[HumanReview]:
"""
获取线程的所有审核
Args:
thread_id: 线程ID
Returns:
审核列表
"""
pass
@abstractmethod
def get_pending(self, limit: int = 100) -> List[HumanReview]:
"""
获取待审核的列表
Args:
limit: 返回数量限制
Returns:
待审核列表
"""
pass
@abstractmethod
def update_status(
self,
review_id: str,
status: ReviewStatus,
reviewer: Optional[str] = None,
comment: str = "",
modified_content: str = ""
) -> bool:
"""
更新审核状态
Args:
review_id: 审核ID
status: 新状态
reviewer: 审核人
comment: 审核意见
modified_content: 修改后的内容
Returns:
是否成功
"""
pass
class InMemoryReviewStore(HumanReviewStore):
"""内存存储实现"""
def __init__(self):
self._reviews: Dict[str, HumanReview] = {}
def save(self, review: HumanReview) -> None:
"""
保存审核
Args:
review: 审核对象
"""
self._reviews[review.review_id] = review
def get(self, review_id: str) -> Optional[HumanReview]:
"""
获取审核
Args:
review_id: 审核ID
Returns:
审核对象,如果不存在返回 None
"""
return self._reviews.get(review_id)
def get_by_thread(self, thread_id: str) -> List[HumanReview]:
"""
获取线程的所有审核
Args:
thread_id: 线程ID
Returns:
审核列表
"""
return [
review for review in self._reviews.values()
if review.thread_id == thread_id
]
def get_pending(self, limit: int = 100) -> List[HumanReview]:
"""
获取待审核的列表
Args:
limit: 返回数量限制
Returns:
待审核列表
"""
pending = [
review for review in self._reviews.values()
if review.status == ReviewStatus.PENDING
]
pending.sort(key=lambda r: r.created_at)
return pending[:limit]
def update_status(
self,
review_id: str,
status: ReviewStatus,
reviewer: Optional[str] = None,
comment: str = "",
modified_content: str = ""
) -> bool:
"""
更新审核状态
Args:
review_id: 审核ID
status: 新状态
reviewer: 审核人
comment: 审核意见
modified_content: 修改后的内容
Returns:
是否成功
"""
review = self._reviews.get(review_id)
if review is None:
return False
review.status = status
review.review_comment = comment
review.modified_content = modified_content
review.reviewer = reviewer
review.reviewed_at = datetime.now()
return True
class HumanReviewNode:
"""LangGraph 审核节点"""
def __init__(
self,
store: HumanReviewStore,
should_review: Optional[Callable[[Any], bool]] = None
):
"""
初始化审核节点
Args:
store: 审核存储
should_review: 判断是否需要审核的函数
"""
self.store = store
self.should_review = should_review or (lambda state: True)
def create_review(
self,
state: Any,
thread_id: str,
user_id: str,
content_to_review: str
) -> str:
"""
创建审核
Args:
state: 状态
thread_id: 线程ID
user_id: 用户ID
content_to_review: 待审核内容
Returns:
审核ID
"""
review_id = str(uuid.uuid4())
review = HumanReview(
review_id=review_id,
thread_id=thread_id,
user_id=user_id,
status=ReviewStatus.PENDING,
content_to_review=content_to_review
)
self.store.save(review)
return review_id
def check_review_status(self, review_id: str) -> Optional[ReviewStatus]:
"""
检查审核状态
Args:
review_id: 审核ID
Returns:
审核状态,如果不存在返回 None
"""
review = self.store.get(review_id)
return review.status if review else None
def get_review_result(self, review_id: str) -> Optional[HumanReview]:
"""
获取审核结果
Args:
review_id: 审核ID
Returns:
审核对象,如果不存在返回 None
"""
return self.store.get(review_id)
async def __call__(self, state: Any) -> Dict[str, Any]:
"""
节点执行方法LangGraph 兼容)
Args:
state: 状态
Returns:
更新后的状态
"""
# 检查是否需要审核
if not self.should_review(state):
return {"review_skipped": True}
# 从状态中提取信息
thread_id = getattr(state, "thread_id", str(uuid.uuid4()))
user_id = getattr(state, "user_id", "default_user")
# 获取待审核内容
content_to_review = ""
if hasattr(state, "messages") and state.messages:
last_msg = state.messages[-1] if state.messages else None
if last_msg and hasattr(last_msg, "content"):
content_to_review = last_msg.content
# 创建审核
review_id = self.create_review(state, thread_id, user_id, content_to_review)
# 返回状态更新
return {
"review_id": review_id,
"review_pending": True,
"interrupt": True # 标记需要中断
}
class ReviewManager:
"""审核管理器"""
def __init__(self, store: Optional[HumanReviewStore] = None):
"""
初始化审核管理器
Args:
store: 审核存储
"""
self.store = store or InMemoryReviewStore()
def request_review(
self,
thread_id: str,
user_id: str,
content: str,
metadata: Optional[Dict[str, Any]] = None
) -> str:
"""
请求审核
Args:
thread_id: 线程ID
user_id: 用户ID
content: 待审核内容
metadata: 元数据
Returns:
审核ID
"""
review_id = str(uuid.uuid4())
review = HumanReview(
review_id=review_id,
thread_id=thread_id,
user_id=user_id,
status=ReviewStatus.PENDING,
content_to_review=content,
metadata=metadata or {}
)
self.store.save(review)
return review_id
def approve(
self,
review_id: str,
reviewer: str,
comment: str = ""
) -> bool:
"""
审核通过
Args:
review_id: 审核ID
reviewer: 审核人
comment: 审核意见
Returns:
是否成功
"""
return self.store.update_status(
review_id=review_id,
status=ReviewStatus.APPROVED,
reviewer=reviewer,
comment=comment
)
def reject(
self,
review_id: str,
reviewer: str,
comment: str = ""
) -> bool:
"""
审核拒绝
Args:
review_id: 审核ID
reviewer: 审核人
comment: 审核意见
Returns:
是否成功
"""
return self.store.update_status(
review_id=review_id,
status=ReviewStatus.REJECTED,
reviewer=reviewer,
comment=comment
)
def modify(
self,
review_id: str,
reviewer: str,
modified_content: str,
comment: str = ""
) -> bool:
"""
审核修改
Args:
review_id: 审核ID
reviewer: 审核人
modified_content: 修改后的内容
comment: 审核意见
Returns:
是否成功
"""
return self.store.update_status(
review_id=review_id,
status=ReviewStatus.MODIFIED,
reviewer=reviewer,
comment=comment,
modified_content=modified_content
)
def get_pending_reviews(self, limit: int = 100) -> List[HumanReview]:
"""
获取待审核列表
Args:
limit: 返回数量限制
Returns:
待审核列表
"""
return self.store.get_pending(limit)
def get_review(self, review_id: str) -> Optional[HumanReview]:
"""
获取审核详情
Args:
review_id: 审核ID
Returns:
审核对象,如果不存在返回 None
"""
return self.store.get(review_id)

View File

@@ -0,0 +1,427 @@
"""
意图理解工具模块
提供标准化的意图分类和信息提取能力
功能:
1. Intent - 意图数据类
2. IntentClassifier - 意图分类器
3. EntityExtractor - 实体提取器
4. IntentParser - 完整的意图解析器
5. IntentRegistry - 意图注册器
"""
import re
from typing import Dict, List, Any, Optional, Set, Tuple, Callable
from dataclasses import dataclass, field
from enum import Enum, auto
from abc import ABC, abstractmethod
class IntentType(Enum):
"""意图类型枚举"""
UNKNOWN = auto()
GREETING = auto() # 问候
QUESTION = auto() # 提问
REQUEST = auto() # 请求
COMMAND = auto() # 命令
INFORM = auto() # 告知信息
CONFIRM = auto() # 确认
DENY = auto() # 否认
THANKS = auto() # 感谢
GOODBYE = auto() # 告别
COMPLAINT = auto() # 投诉
PRAISE = auto() # 表扬
CLARIFY = auto() # 澄清
SUGGEST = auto() # 建议
@dataclass
class Entity:
"""实体数据类"""
entity_type: str # 实体类型
value: str # 实体值
start_pos: int = 0 # 起始位置
end_pos: int = 0 # 结束位置
confidence: float = 1.0 # 置信度
metadata: Dict[str, Any] = field(default_factory=dict) # 元数据
@dataclass
class Intent:
"""意图数据类"""
intent_type: IntentType # 意图类型
confidence: float = 1.0 # 置信度
entities: List[Entity] = field(default_factory=list) # 提取的实体
parameters: Dict[str, Any] = field(default_factory=dict) # 参数
original_text: str = "" # 原始文本
normalized_text: str = "" # 标准化后的文本
metadata: Dict[str, Any] = field(default_factory=dict) # 元数据
class BaseIntentClassifier(ABC):
"""意图分类器基类"""
@abstractmethod
def classify(self, text: str) -> Tuple[IntentType, float]:
"""
分类意图
Args:
text: 输入文本
Returns:
(意图类型, 置信度)
"""
pass
@abstractmethod
def classify_with_scores(self, text: str) -> Dict[IntentType, float]:
"""
分类意图,返回所有类型的置信度
Args:
text: 输入文本
Returns:
{意图类型: 置信度}
"""
pass
class RuleBasedIntentClassifier(BaseIntentClassifier):
"""基于规则的意图分类器"""
def __init__(self):
self._rules: Dict[IntentType, Set[str]] = {}
self._initialize_default_rules()
def _initialize_default_rules(self) -> None:
"""初始化默认规则"""
# 问候
self.add_rule(IntentType.GREETING, {
"你好", "您好", "hi", "hello", "hey", "早上好", "下午好", "晚上好", "哈喽"
})
# 告别
self.add_rule(IntentType.GOODBYE, {
"再见", "拜拜", "bye", "goodbye", "回见", "下次见", "再见了"
})
# 感谢
self.add_rule(IntentType.THANKS, {
"谢谢", "感谢", "多谢", "thanks", "thank you", "3q", "谢谢了"
})
# 确认
self.add_rule(IntentType.CONFIRM, {
"是的", "", "没错", "好的", "可以", "", "同意", "确认", "yes", "yep"
})
# 否认
self.add_rule(IntentType.DENY, {
"", "不是", "不对", "不行", "不要", "拒绝", "no", "nope", "没有"
})
# 提问
self.add_rule(IntentType.QUESTION, {
"?", "", "什么", "怎么", "如何", "为什么", "", "", "多少", "", ""
})
def add_rule(self, intent_type: IntentType, keywords: Set[str]) -> None:
"""
添加规则
Args:
intent_type: 意图类型
keywords: 关键词集合
"""
if intent_type not in self._rules:
self._rules[intent_type] = set()
self._rules[intent_type].update(keywords)
def classify(self, text: str) -> Tuple[IntentType, float]:
"""
分类意图
Args:
text: 输入文本
Returns:
(意图类型, 置信度)
"""
scores = self.classify_with_scores(text)
if not scores:
return IntentType.UNKNOWN, 0.0
best_intent = max(scores.items(), key=lambda x: x[1])
return best_intent[0], best_intent[1]
def classify_with_scores(self, text: str) -> Dict[IntentType, float]:
"""
分类意图,返回所有类型的置信度
Args:
text: 输入文本
Returns:
{意图类型: 置信度}
"""
scores: Dict[IntentType, float] = {}
normalized_text = text.lower()
for intent_type, keywords in self._rules.items():
match_count = 0
for keyword in keywords:
if keyword.lower() in normalized_text:
match_count += 1
if match_count > 0:
scores[intent_type] = min(1.0, match_count / 3.0)
# 如果没有匹配返回UNKNOWN
if not scores:
scores[IntentType.UNKNOWN] = 0.5
return scores
class BaseEntityExtractor(ABC):
"""实体提取器基类"""
@abstractmethod
def extract(self, text: str) -> List[Entity]:
"""
提取实体
Args:
text: 输入文本
Returns:
实体列表
"""
pass
class RuleBasedEntityExtractor(BaseEntityExtractor):
"""基于规则的实体提取器"""
def __init__(self):
self._patterns: Dict[str, re.Pattern] = {} # 正则模式
self._keywords: Dict[str, Set[str]] = {} # 关键词列表
self._initialize_default_patterns()
def _initialize_default_patterns(self) -> None:
"""初始化默认模式"""
# 邮箱
self.add_regex_pattern(
"email",
r'[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}'
)
# 电话号码
self.add_regex_pattern(
"phone",
r'1[3-9]\d{9}'
)
# 日期(简单模式)
self.add_regex_pattern(
"date",
r'\d{4}[-/年]\d{1,2}[-/月]\d{1,2}[日号]?|\d{1,2}[-/月]\d{1,2}[日号]?'
)
# 数字
self.add_regex_pattern(
"number",
r'\d+\.?\d*'
)
def add_regex_pattern(self, entity_type: str, pattern: str) -> None:
"""
添加正则匹配规则
Args:
entity_type: 实体类型
pattern: 正则表达式
"""
try:
self._patterns[entity_type] = re.compile(pattern, re.IGNORECASE)
except re.error:
pass
def add_keywords(self, entity_type: str, keywords: Set[str]) -> None:
"""
添加关键词匹配规则
Args:
entity_type: 实体类型
keywords: 关键词集合
"""
if entity_type not in self._keywords:
self._keywords[entity_type] = set()
self._keywords[entity_type].update(keywords)
def extract(self, text: str) -> List[Entity]:
"""
提取实体
Args:
text: 输入文本
Returns:
实体列表
"""
entities: List[Entity] = []
# 正则匹配
for entity_type, pattern in self._patterns.items():
for match in pattern.finditer(text):
entity = Entity(
entity_type=entity_type,
value=match.group(),
start_pos=match.start(),
end_pos=match.end(),
confidence=0.95
)
entities.append(entity)
# 关键词匹配
for entity_type, keywords in self._keywords.items():
for keyword in keywords:
start_idx = 0
while True:
pos = text.lower().find(keyword.lower(), start_idx)
if pos == -1:
break
entity = Entity(
entity_type=entity_type,
value=text[pos:pos + len(keyword)],
start_pos=pos,
end_pos=pos + len(keyword),
confidence=0.9
)
entities.append(entity)
start_idx = pos + len(keyword)
# 按位置排序
entities.sort(key=lambda e: e.start_pos)
return entities
class IntentRegistry:
"""意图注册器"""
def __init__(self):
self._intent_handlers: Dict[IntentType, Callable] = {}
def register(self, intent_type: IntentType, handler: Callable) -> None:
"""
注册意图处理器
Args:
intent_type: 意图类型
handler: 处理器
"""
self._intent_handlers[intent_type] = handler
def get_handler(self, intent_type: IntentType) -> Optional[Callable]:
"""
获取意图处理器
Args:
intent_type: 意图类型
Returns:
处理器,如果不存在返回 None
"""
return self._intent_handlers.get(intent_type)
class IntentParser:
"""完整的意图解析器"""
def __init__(
self,
classifier: Optional[BaseIntentClassifier] = None,
extractor: Optional[BaseEntityExtractor] = None,
registry: Optional[IntentRegistry] = None
):
"""
初始化意图解析器
Args:
classifier: 意图分类器
extractor: 实体提取器
registry: 意图注册器
"""
self.classifier = classifier or RuleBasedIntentClassifier()
self.extractor = extractor or RuleBasedEntityExtractor()
self.registry = registry or IntentRegistry()
def parse(self, text: str) -> Intent:
"""
解析文本,返回完整的意图对象
Args:
text: 输入文本
Returns:
意图对象
"""
# 分类意图
intent_type, confidence = self.classifier.classify(text)
# 提取实体
entities = self.extractor.extract(text)
# 构建意图对象
intent = Intent(
intent_type=intent_type,
confidence=confidence,
entities=entities,
original_text=text,
normalized_text=text.lower().strip()
)
# 从实体中提取参数
for entity in entities:
intent.parameters[entity.entity_type] = entity.value
return intent
def parse_and_execute(self, text: str, context: Optional[Dict[str, Any]] = None) -> Any:
"""
解析文本并执行对应的处理器
Args:
text: 输入文本
context: 上下文
Returns:
执行结果
"""
intent = self.parse(text)
handler = self.registry.get_handler(intent.intent_type)
if handler:
return handler(intent, context or {})
return None
def create_default_intent_parser() -> IntentParser:
"""
创建默认配置的意图解析器
Returns:
配置好的意图解析器
"""
parser = IntentParser()
# 注册默认处理器
def greeting_handler(intent: Intent, context: Dict) -> str:
return "你好!很高兴为你服务。"
def thanks_handler(intent: Intent, context: Dict) -> str:
return "不客气!"
def goodbye_handler(intent: Intent, context: Dict) -> str:
return "再见!有需要随时找我。"
parser.registry.register(IntentType.GREETING, greeting_handler)
parser.registry.register(IntentType.THANKS, thanks_handler)
parser.registry.register(IntentType.GOODBYE, goodbye_handler)
return parser

View File

@@ -0,0 +1,10 @@
"""
状态基类工具模块
提供类型安全的 LangGraph 状态基类和常用状态操作工具
功能:
1. BaseState - 基础状态基类包含通用字段消息、token统计、耗时等
2. StateUtils - 状态操作工具类,提供常用的状态访问和修改方法
3. TypedStateBuilder - 类型化状态构建器,支持链式创建自定义状态
4. StateValidation - 状态验证工具,确保状态完整性
"""

View File

@@ -8,6 +8,7 @@ from .config import DB_URI, BACKEND_PORT
import uuid
import json
from contextlib import asynccontextmanager
from typing import Optional
from fastapi import FastAPI, HTTPException, WebSocket, WebSocketDisconnect, Depends, Request, Query
from fastapi.middleware.cors import CORSMiddleware
@@ -16,6 +17,12 @@ from pydantic import BaseModel
from langgraph.checkpoint.postgres.aio import AsyncPostgresSaver
from .agent.service import AIAgentService
from .agent.history import ThreadHistoryService
from .agent_subgraphs.common.human_review import (
ReviewManager,
InMemoryReviewStore,
ReviewStatus,
HumanReview
)
from .logger import info, error
@asynccontextmanager
@@ -32,14 +39,18 @@ async def lifespan(app: FastAPI):
# 3. 创建历史查询服务
history_service = ThreadHistoryService(checkpointer)
# 4. 将服务实例存入 app.state
# 4. 创建审核管理器
review_manager = ReviewManager(InMemoryReviewStore())
# 5. 将服务实例存入 app.state
app.state.agent_service = agent_service
app.state.history_service = history_service
app.state.review_manager = review_manager
# 应用运行中...
yield
# 5. 关闭时自动清理数据库连接async with 负责)
# 6. 关闭时自动清理数据库连接async with 负责)
info("🛑 应用关闭,数据库连接池已释放")
app = FastAPI(lifespan=lifespan)
@@ -75,6 +86,23 @@ class ChatResponse(BaseModel):
total_tokens: int = 0
elapsed_time: float = 0.0
class ReviewActionRequest(BaseModel):
review_id: str
reviewer: str
comment: str = ""
modified_content: str = ""
class ReviewResponse(BaseModel):
review_id: str
thread_id: str
user_id: str
status: str
content_to_review: str
review_comment: str = ""
modified_content: str = ""
created_at: str
reviewed_at: Optional[str] = None
# ========== 依赖注入函数 ==========
def get_agent_service(request: Request) -> AIAgentService:
"""从 app.state 中获取全局 AIAgentService 实例"""
@@ -84,6 +112,10 @@ def get_history_service(request: Request) -> ThreadHistoryService:
"""从 app.state 中获取全局 ThreadHistoryService 实例"""
return request.app.state.history_service
def get_review_manager(request: Request) -> ReviewManager:
"""从 app.state 中获取全局 ReviewManager 实例"""
return request.app.state.review_manager
# ========== HTTP 端点 ==========
@app.post("/chat", response_model=ChatResponse)
async def chat_endpoint(
@@ -205,6 +237,114 @@ async def websocket_endpoint(
except WebSocketDisconnect:
pass
# ========== 审核相关端点 ==========
def review_to_response(review: HumanReview) -> ReviewResponse:
"""将审核对象转换为响应对象"""
return ReviewResponse(
review_id=review.review_id,
thread_id=review.thread_id,
user_id=review.user_id,
status=review.status.name,
content_to_review=review.content_to_review,
review_comment=review.review_comment,
modified_content=review.modified_content,
created_at=review.created_at.isoformat(),
reviewed_at=review.reviewed_at.isoformat() if review.reviewed_at else None
)
@app.get("/reviews/pending", response_model=list[ReviewResponse])
async def get_pending_reviews(
limit: int = Query(100, ge=1, le=500, description="返回数量限制"),
review_manager: ReviewManager = Depends(get_review_manager)
):
"""获取待审核列表"""
reviews = review_manager.get_pending_reviews(limit)
return [review_to_response(review) for review in reviews]
@app.get("/reviews/{review_id}", response_model=ReviewResponse)
async def get_review(
review_id: str,
review_manager: ReviewManager = Depends(get_review_manager)
):
"""获取审核详情"""
review = review_manager.get_review(review_id)
if not review:
raise HTTPException(status_code=404, detail="Review not found")
return review_to_response(review)
@app.get("/reviews/thread/{thread_id}", response_model=list[ReviewResponse])
async def get_thread_reviews(
thread_id: str,
review_manager: ReviewManager = Depends(get_review_manager)
):
"""获取线程的所有审核"""
# 注意:我们的 ReviewStore 接口目前没有 get_by_thread 方法暴露在 ReviewManager 中
# 这里我们直接访问 store但在实际项目中应该在 ReviewManager 中添加这个方法
reviews = review_manager.store.get_by_thread(thread_id) if hasattr(review_manager.store, 'get_by_thread') else []
return [review_to_response(review) for review in reviews]
@app.post("/reviews/{review_id}/approve")
async def approve_review(
review_id: str,
request: ReviewActionRequest,
review_manager: ReviewManager = Depends(get_review_manager)
):
"""审核通过"""
success = review_manager.approve(
review_id=review_id,
reviewer=request.reviewer,
comment=request.comment
)
if not success:
raise HTTPException(status_code=404, detail="Review not found")
return {"status": "success", "review_id": review_id}
@app.post("/reviews/{review_id}/reject")
async def reject_review(
review_id: str,
request: ReviewActionRequest,
review_manager: ReviewManager = Depends(get_review_manager)
):
"""审核拒绝"""
success = review_manager.reject(
review_id=review_id,
reviewer=request.reviewer,
comment=request.comment
)
if not success:
raise HTTPException(status_code=404, detail="Review not found")
return {"status": "success", "review_id": review_id}
@app.post("/reviews/{review_id}/modify")
async def modify_review(
review_id: str,
request: ReviewActionRequest,
review_manager: ReviewManager = Depends(get_review_manager)
):
"""审核修改"""
if not request.modified_content:
raise HTTPException(status_code=400, detail="modified_content required")
success = review_manager.modify(
review_id=review_id,
reviewer=request.reviewer,
modified_content=request.modified_content,
comment=request.comment
)
if not success:
raise HTTPException(status_code=404, detail="Review not found")
return {"status": "success", "review_id": review_id}
@app.post("/reviews/request")
async def request_review(
thread_id: str,
user_id: str,
content: str,
review_manager: ReviewManager = Depends(get_review_manager)
):
"""请求审核(测试用)"""
review_id = review_manager.request_review(thread_id, user_id, content)
return {"status": "success", "review_id": review_id}
if __name__ == "__main__":
import uvicorn
# 使用环境变量或默认端口 8079避免与 llama.cpp 的 8081 端口冲突)

View File

@@ -185,6 +185,199 @@ class APIClient:
"type": "error",
"message": f"请求失败: {str(e)}"
}
# ==================== 审核接口 ====================
def get_pending_reviews(self, limit: int = 100) -> List[Dict[str, Any]]:
"""
获取待审核列表
Args:
limit: 返回数量限制
Returns:
审核列表
"""
try:
resp = requests.get(
f"{self.base_url}/reviews/pending",
params={"limit": limit},
timeout=10
)
if resp.status_code == 200:
return resp.json()
else:
warning(f"获取待审核列表失败: HTTP {resp.status_code}")
return []
except Exception as e:
error(f"获取待审核列表异常: {e}")
return []
def get_review(self, review_id: str) -> Dict[str, Any]:
"""
获取审核详情
Args:
review_id: 审核 ID
Returns:
审核详情
"""
try:
resp = requests.get(
f"{self.base_url}/reviews/{review_id}",
timeout=10
)
if resp.status_code == 200:
return resp.json()
else:
warning(f"获取审核详情失败: HTTP {resp.status_code}")
return {}
except Exception as e:
error(f"获取审核详情异常: {e}")
return {}
def approve_review(self, review_id: str, reviewer: str, comment: str = "") -> bool:
"""
审核通过
Args:
review_id: 审核 ID
reviewer: 审核人
comment: 审核意见
Returns:
是否成功
"""
try:
payload = {
"review_id": review_id,
"reviewer": reviewer,
"comment": comment
}
resp = requests.post(
f"{self.base_url}/reviews/{review_id}/approve",
json=payload,
timeout=10
)
if resp.status_code == 200:
return True
else:
warning(f"审核通过失败: HTTP {resp.status_code}")
return False
except Exception as e:
error(f"审核通过异常: {e}")
return False
def reject_review(self, review_id: str, reviewer: str, comment: str = "") -> bool:
"""
审核拒绝
Args:
review_id: 审核 ID
reviewer: 审核人
comment: 审核意见
Returns:
是否成功
"""
try:
payload = {
"review_id": review_id,
"reviewer": reviewer,
"comment": comment
}
resp = requests.post(
f"{self.base_url}/reviews/{review_id}/reject",
json=payload,
timeout=10
)
if resp.status_code == 200:
return True
else:
warning(f"审核拒绝失败: HTTP {resp.status_code}")
return False
except Exception as e:
error(f"审核拒绝异常: {e}")
return False
def modify_review(self, review_id: str, reviewer: str, modified_content: str, comment: str = "") -> bool:
"""
审核修改
Args:
review_id: 审核 ID
reviewer: 审核人
modified_content: 修改后的内容
comment: 审核意见
Returns:
是否成功
"""
try:
payload = {
"review_id": review_id,
"reviewer": reviewer,
"modified_content": modified_content,
"comment": comment
}
resp = requests.post(
f"{self.base_url}/reviews/{review_id}/modify",
json=payload,
timeout=10
)
if resp.status_code == 200:
return True
else:
warning(f"审核修改失败: HTTP {resp.status_code}")
return False
except Exception as e:
error(f"审核修改异常: {e}")
return False
def request_review(self, thread_id: str, user_id: str, content: str) -> str:
"""
请求审核(测试用)
Args:
thread_id: 线程ID
user_id: 用户ID
content: 待审核内容
Returns:
审核ID
"""
try:
# 后端使用查询参数传递数据
resp = requests.post(
f"{self.base_url}/reviews/request",
params={
"thread_id": thread_id,
"user_id": user_id,
"content": content
},
timeout=10
)
if resp.status_code == 200:
return resp.json().get("review_id", "")
else:
warning(f"请求审核失败: HTTP {resp.status_code}")
return ""
except Exception as e:
error(f"请求审核异常: {e}")
return ""
# 全局 API 客户端实例(单例模式)

View File

@@ -22,6 +22,9 @@ def render_chat_area():
# 渲染历史消息
_render_chat_history()
# 检查并渲染审核确认界面
_render_review_confirmation()
# 输入框和流式响应处理
_render_input_and_response()
@@ -344,3 +347,201 @@ def _show_completion_stats(event: dict):
if token_usage:
total_tokens = token_usage.get("total_tokens", 0)
st.caption(f"📊 消耗 {total_tokens} tokens | ⏱️ {elapsed:.2f}s")
def _render_review_confirmation():
"""渲染审核确认界面 - 类似编程工具的右下角确认交互"""
# 获取当前线程的待审核内容
thread_id = AppState.get_current_thread_id()
user_id = AppState.get_user_id()
# 初始化会话状态
if 'pending_review' not in st.session_state:
st.session_state.pending_review = None
if 'show_review_modify' not in st.session_state:
st.session_state.show_review_modify = False
if 'review_error' not in st.session_state:
st.session_state.review_error = None
# 如果有待审核内容,先尝试从后端获取最新状态
if st.session_state.pending_review:
review_id = st.session_state.pending_review.get("review_id")
if review_id:
try:
latest_review = api_client.get_review(review_id)
if latest_review and latest_review.get("status") != "PENDING":
# 审核已处理,清除待审核状态
st.session_state.pending_review = None
st.session_state.show_review_modify = False
except Exception as e:
pass
# 如果没有待审核内容,检查是否有新的待审核内容
if not st.session_state.pending_review:
try:
pending_reviews = api_client.get_pending_reviews(limit=10)
# 查找当前线程的待审核内容
for review in pending_reviews:
if review.get("thread_id") == thread_id and review.get("status") == "PENDING":
st.session_state.pending_review = {
"review_id": review.get("review_id"),
"content_to_review": review.get("content_to_review"),
"created_at": review.get("created_at"),
"user_id": review.get("user_id")
}
break
except Exception as e:
st.session_state.review_error = str(e)
# 测试按钮 - 用于演示审核功能
with st.container():
st.markdown("<div style='height: 20px;'></div>", unsafe_allow_html=True)
col_test, col_info = st.columns([1, 3])
with col_test:
if st.button("🔧 测试审核", key="test_review_chat"):
# 创建一个测试审核请求
test_content = "这是一条待审核的测试内容。\n\n您可以选择:\n✅ 确定 - 批准此内容\n✏️ 修改 - 修改后批准\n❌ 拒绝 - 拒绝此内容"
review_id = api_client.request_review(thread_id, user_id, test_content)
if review_id:
st.session_state.pending_review = {
"review_id": review_id,
"content_to_review": test_content,
"created_at": "2024-01-01T12:00:00",
"user_id": user_id
}
st.success("✅ 已创建测试审核")
st.rerun()
else:
st.error("❌ 创建测试审核失败")
with col_info:
if st.session_state.get("review_error"):
st.warning(f"⚠️ {st.session_state.review_error}")
elif st.session_state.pending_review:
st.info("📋 有待审核内容")
# 显示审核确认界面
if st.session_state.pending_review:
review = st.session_state.pending_review
# 使用右下角的固定样式显示通过CSS实现
st.markdown("""
<style>
.review-container {
position: fixed;
bottom: 20px;
right: 20px;
width: 400px;
background: white;
border-radius: 12px;
box-shadow: 0 4px 20px rgba(0,0,0,0.15);
z-index: 1000;
padding: 20px;
border: 1px solid #e0e0e0;
}
.review-header {
font-weight: 600;
font-size: 16px;
margin-bottom: 12px;
color: #333;
display: flex;
justify-content: space-between;
align-items: center;
}
.review-content {
background: #f8f9fa;
padding: 12px;
border-radius: 8px;
margin-bottom: 16px;
max-height: 150px;
overflow-y: auto;
font-size: 14px;
line-height: 1.5;
white-space: pre-wrap;
}
.review-buttons {
display: flex;
gap: 8px;
justify-content: flex-end;
}
</style>
""", unsafe_allow_html=True)
# 渲染审核确认框
with st.container():
st.markdown('<div class="review-container">', unsafe_allow_html=True)
# 标题和关闭按钮
col_title, col_close = st.columns([4, 1])
with col_title:
st.markdown('<div class="review-header">📋 待审核内容</div>', unsafe_allow_html=True)
with col_close:
if st.button("", key="close_review"):
st.session_state.pending_review = None
st.rerun()
# 内容区域 - 转义 HTML
safe_content = review["content_to_review"].replace("<", "&lt;").replace(">", "&gt;")
st.markdown(f'<div class="review-content">{safe_content}</div>', unsafe_allow_html=True)
# 如果是修改模式,显示文本编辑框
if st.session_state.show_review_modify:
modified_content = st.text_area(
"修改内容",
value=review["content_to_review"],
key="modify_text_area",
height=100
)
col_cancel, col_submit = st.columns([1, 1])
with col_cancel:
if st.button("取消", key="cancel_modify", use_container_width=True):
st.session_state.show_review_modify = False
st.rerun()
with col_submit:
if st.button("提交修改", key="submit_modify", type="primary", use_container_width=True):
# 调用API提交修改
reviewer = user_id
success = api_client.modify_review(
review["review_id"],
reviewer,
modified_content
)
if success:
st.success("✅ 修改已提交")
st.session_state.pending_review = None
st.session_state.show_review_modify = False
st.rerun()
else:
st.error("❌ 提交失败")
else:
# 按钮区域
col_approve, col_modify, col_reject = st.columns([1, 1, 1])
with col_approve:
if st.button("✅ 确定", key="approve_btn", use_container_width=True, type="primary"):
# 调用审核通过API
reviewer = user_id
success = api_client.approve_review(review["review_id"], reviewer, "已批准")
if success:
st.success("✅ 已批准")
st.session_state.pending_review = None
st.rerun()
else:
st.error("❌ 操作失败")
with col_modify:
if st.button("✏️ 修改", key="modify_btn", use_container_width=True):
st.session_state.show_review_modify = True
st.rerun()
with col_reject:
if st.button("❌ 拒绝", key="reject_btn", use_container_width=True):
# 调用审核拒绝API
reviewer = user_id
success = api_client.reject_review(review["review_id"], reviewer, "已拒绝")
if success:
st.success("✅ 已拒绝")
st.session_state.pending_review = None
st.rerun()
else:
st.error("❌ 操作失败")
st.markdown('</div>', unsafe_allow_html=True)

View File

@@ -117,14 +117,14 @@ def main():
# 顶部标题(可选,也可以不放,让界面更像对话框)
st.markdown("<h3 style='text-align: center; font-weight: 400; color: #555; margin-bottom: 2rem;'>个人助手</h3>", unsafe_allow_html=True)
# 左侧边栏:合并用户登录、模型选择和历史对话
with st.sidebar:
render_sidebar()
# 将原本右侧的信息面板简化并移入侧边栏底部
st.divider()
render_info_panel()
# 中间主区域:全宽的聊天区域
render_chat_area()