feat: 2026-07-11 全量更新 - 代办集成+会议室预定+知识迭代修复+UI统一+Bug修复
== 已部署上线 (9项) == - 代办事项真实数据源集成 (企微审批API 8bug修复链) - H5/坐席端 Logo样式统一+绿色背景 - 视频引导页修复 (localStorage key v2) - 坐席端 v9 Vue版本修复 (ElMessage._context) - 截图按钮 v10 修复 (getDisplayMedia user gesture) - 扫码样式恢复+H5扫码登录跳转修复 - H5截图快捷键提示 == 代码完成待部署 (3项) == - 知识迭代3Bug修复 (#8 POST端点/#7 MERGE幂等/#6 过期检查) - 会议室预定-小鱼易联终端 (40文件, 40/40测试通过) - IT资产升级审批推送 (asset_service.py) == 需求文档 (2项) == - 坐席端AI辅助消息框-PRD (4项新功能确认) - 坐席端布局优化建议 v2.0 (7天计划) == 新增文档 == - 日报-2026-07-11.md - 知识迭代Bug修复报告-20260711.md - 会议室预定-部署指南.md - CHANGELOG.md 更新 == 测试 == - test_todo_integration.py: 40/40 - test_meetingroom.py: 40/40 - test_bugfix_ki_suggestions.py: 21/21
This commit is contained in:
@@ -125,14 +125,20 @@ from app.services.automation.exception_handler import ( # noqa: E402
|
||||
to_app_exception,
|
||||
)
|
||||
from app.services.automation.executor import ActionExecutor # noqa: E402
|
||||
from app.services.automation.information_item_service import InformationItemService # noqa: E402
|
||||
from app.services.automation.intent_router import IntentRouter # noqa: E402
|
||||
from app.services.automation.mapping_resolver import MappingResolver # noqa: E402
|
||||
from app.services.automation.progress_publisher import ( # noqa: E402
|
||||
publish_action_required,
|
||||
publish_error,
|
||||
publish_info_corrected,
|
||||
publish_info_supplemented,
|
||||
publish_paused,
|
||||
publish_progress,
|
||||
publish_resolved,
|
||||
publish_resumed,
|
||||
publish_takeover,
|
||||
publish_timeout_closed,
|
||||
register_ws,
|
||||
unregister_ws,
|
||||
)
|
||||
@@ -141,6 +147,10 @@ from app.services.automation.session_manager import ( # noqa: E402
|
||||
AutoSessionService,
|
||||
run_session_in_background,
|
||||
)
|
||||
from app.services.automation.context_compressor import ContextCompressor # noqa: E402
|
||||
from app.services.automation.correction_service import CorrectionService # noqa: E402
|
||||
from app.services.automation.snapshot_service import SnapshotService # noqa: E402
|
||||
from app.services.automation.timeout_cleaner import TimeoutCleaner # noqa: E402
|
||||
|
||||
__all__ = [
|
||||
"DEFAULT_SCENARIO_CONFIGS",
|
||||
@@ -153,16 +163,26 @@ __all__ = [
|
||||
"AutomationException",
|
||||
"to_app_exception",
|
||||
"ActionExecutor",
|
||||
"InformationItemService",
|
||||
"IntentRouter",
|
||||
"MappingResolver",
|
||||
"publish_action_required",
|
||||
"publish_error",
|
||||
"publish_info_corrected",
|
||||
"publish_info_supplemented",
|
||||
"publish_paused",
|
||||
"publish_progress",
|
||||
"publish_resolved",
|
||||
"publish_resumed",
|
||||
"publish_takeover",
|
||||
"publish_timeout_closed",
|
||||
"register_ws",
|
||||
"unregister_ws",
|
||||
"RollbackService",
|
||||
"AutoSessionService",
|
||||
"run_session_in_background",
|
||||
"TimeoutCleaner",
|
||||
"ContextCompressor",
|
||||
"CorrectionService",
|
||||
"SnapshotService",
|
||||
]
|
||||
|
||||
@@ -0,0 +1,341 @@
|
||||
"""
|
||||
上下文压缩引擎 — P2 核心组件。
|
||||
|
||||
当会话上下文超过 token 阈值时,自动压缩历史对话:
|
||||
1. 提取关键信息(信息项当前值、已执行动作、任务节点)
|
||||
2. 调用 LLM 对非关键历史生成摘要
|
||||
3. 组装压缩后上下文(结构化 Markdown)
|
||||
4. 渐进式压缩:单次不够则二次,最多3级,超出降级截断
|
||||
5. 记录压缩日志到 auto_context_compressions 表
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import time
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any, List, Optional
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.config import settings
|
||||
from app.models.automation import AutoAction, AutoSession, ContextCompression, InformationItem
|
||||
from app.utils.token_counter import TokenCounter
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# 压缩摘要 LLM prompt
|
||||
_SUMMARY_SYSTEM_PROMPT = (
|
||||
"你是对话摘要助手。请将以下IT服务台对话历史压缩为简洁摘要,"
|
||||
"保留:1)员工诉求 2)已收集的关键信息 3)已执行的排查步骤 4)决策点。"
|
||||
"输出不超过300字的中文摘要。"
|
||||
)
|
||||
|
||||
# 压缩后上下文模板
|
||||
_COMPRESSED_CONTEXT_TEMPLATE = """## 会话上下文摘要(系统压缩)
|
||||
|
||||
### 已收集信息项
|
||||
{info_items}
|
||||
|
||||
### 已执行动作
|
||||
{actions}
|
||||
|
||||
### 当前任务节点
|
||||
{task_node}
|
||||
|
||||
### 历史摘要
|
||||
{summary}
|
||||
|
||||
### 最近对话
|
||||
{recent_messages}
|
||||
"""
|
||||
|
||||
|
||||
class ContextCompressor:
|
||||
"""上下文压缩引擎。
|
||||
|
||||
在每次调用 LLM 前检查 token 数,超阈值时执行压缩。
|
||||
"""
|
||||
|
||||
def __init__(self, db: AsyncSession, dify_client: Any = None):
|
||||
self.db = db
|
||||
self.dify_client = dify_client
|
||||
# 从配置读取,带默认值兜底(pydantic-settings 属性名为小写)
|
||||
self.threshold = getattr(settings, "context_compress_threshold", 6000)
|
||||
self.timeout = getattr(settings, "context_compress_timeout", 30)
|
||||
self.max_level = getattr(settings, "context_max_compress_level", 3)
|
||||
self.keep_recent = getattr(settings, "context_keep_recent_turns", 4)
|
||||
|
||||
def count_tokens(self, messages: List[dict]) -> int:
|
||||
"""计算消息列表的 token 总数。"""
|
||||
return TokenCounter.count_messages_tokens(messages)
|
||||
|
||||
def should_compress(self, messages: List[dict]) -> bool:
|
||||
"""判断是否需要压缩。"""
|
||||
token_count = self.count_tokens(messages)
|
||||
return token_count > self.threshold
|
||||
|
||||
async def compress(
|
||||
self,
|
||||
session_id: str,
|
||||
messages: List[dict],
|
||||
info_items: List[InformationItem],
|
||||
actions: List[AutoAction],
|
||||
task_node: str = "",
|
||||
) -> dict:
|
||||
"""执行上下文压缩。
|
||||
|
||||
Returns:
|
||||
dict: {
|
||||
"compressed_messages": List[dict], # 压缩后的消息列表
|
||||
"tokens_before": int,
|
||||
"tokens_after": int,
|
||||
"compression_ratio": float,
|
||||
"compression_level": int,
|
||||
"summary": str,
|
||||
"duration_ms": int,
|
||||
}
|
||||
"""
|
||||
start_time = time.time()
|
||||
tokens_before = self.count_tokens(messages)
|
||||
|
||||
# Level 1 压缩
|
||||
compressed = await self._compress_level1(
|
||||
messages, info_items, actions, task_node
|
||||
)
|
||||
|
||||
compression_level = 1
|
||||
tokens_after = self.count_tokens(compressed)
|
||||
|
||||
# 渐进式压缩
|
||||
while tokens_after > self.threshold and compression_level < self.max_level:
|
||||
compression_level += 1
|
||||
keep = max(2, self.keep_recent - compression_level + 1) # 逐级减少保留轮数
|
||||
compressed = await self._compress_level_n(
|
||||
messages, info_items, actions, task_node, keep
|
||||
)
|
||||
tokens_after = self.count_tokens(compressed)
|
||||
|
||||
# 超过最大级别仍超限 → 截断降级
|
||||
if tokens_after > self.threshold:
|
||||
compressed = self._truncate_messages(
|
||||
compressed, info_items, actions, task_node
|
||||
)
|
||||
tokens_after = self.count_tokens(compressed)
|
||||
|
||||
duration_ms = int((time.time() - start_time) * 1000)
|
||||
compression_ratio = round(tokens_after / max(1, tokens_before), 2)
|
||||
|
||||
# 写入压缩日志
|
||||
await self._write_log(
|
||||
session_id=session_id,
|
||||
tokens_before=tokens_before,
|
||||
tokens_after=tokens_after,
|
||||
compression_ratio=compression_ratio,
|
||||
task_node=task_node,
|
||||
duration_ms=duration_ms,
|
||||
compression_level=compression_level,
|
||||
summary=compressed[0].get("content", "")[:500] if compressed else "",
|
||||
)
|
||||
|
||||
logger.info(
|
||||
f"上下文压缩完成: session={session_id} "
|
||||
f"{tokens_before}->{tokens_after} (ratio={compression_ratio}, "
|
||||
f"level={compression_level}, {duration_ms}ms)"
|
||||
)
|
||||
|
||||
return {
|
||||
"compressed_messages": compressed,
|
||||
"tokens_before": tokens_before,
|
||||
"tokens_after": tokens_after,
|
||||
"compression_ratio": compression_ratio,
|
||||
"compression_level": compression_level,
|
||||
"summary": compressed[0].get("content", "")[:500] if compressed else "",
|
||||
"duration_ms": duration_ms,
|
||||
}
|
||||
|
||||
async def _compress_level1(
|
||||
self,
|
||||
messages: List[dict],
|
||||
info_items: List[InformationItem],
|
||||
actions: List[AutoAction],
|
||||
task_node: str,
|
||||
) -> List[dict]:
|
||||
"""Level 1 压缩:LLM 摘要 + 保留最近4轮对话。"""
|
||||
# 分割消息:保留最近 N 轮,其余用于摘要
|
||||
recent_msgs = self._get_recent_messages(messages, self.keep_recent)
|
||||
old_msgs = messages[: len(messages) - len(recent_msgs)]
|
||||
|
||||
# 提取关键信息
|
||||
key_info = self._extract_key_info(info_items, actions, task_node)
|
||||
|
||||
# 调用 LLM 生成摘要
|
||||
summary = await self._summarize_history(old_msgs)
|
||||
|
||||
# 组装压缩后上下文
|
||||
context_text = _COMPRESSED_CONTEXT_TEMPLATE.format(
|
||||
info_items=key_info["info_items"],
|
||||
actions=key_info["actions"],
|
||||
task_node=task_node or "未指定",
|
||||
summary=summary,
|
||||
recent_messages=self._format_recent_messages(recent_msgs),
|
||||
)
|
||||
|
||||
# 返回压缩后的消息列表(1条系统消息 + 最近对话)
|
||||
return [{"role": "system", "content": context_text}] + recent_msgs
|
||||
|
||||
async def _compress_level_n(
|
||||
self,
|
||||
messages: List[dict],
|
||||
info_items: List[InformationItem],
|
||||
actions: List[AutoAction],
|
||||
task_node: str,
|
||||
keep_turns: int,
|
||||
) -> List[dict]:
|
||||
"""Level N 压缩:减少保留轮数。"""
|
||||
recent_msgs = self._get_recent_messages(messages, keep_turns)
|
||||
key_info = self._extract_key_info(info_items, actions, task_node)
|
||||
|
||||
context_text = _COMPRESSED_CONTEXT_TEMPLATE.format(
|
||||
info_items=key_info["info_items"],
|
||||
actions=key_info["actions"],
|
||||
task_node=task_node or "未指定",
|
||||
summary="(已多次压缩,仅保留关键信息)",
|
||||
recent_messages=self._format_recent_messages(recent_msgs),
|
||||
)
|
||||
|
||||
return [{"role": "system", "content": context_text}] + recent_msgs
|
||||
|
||||
def _truncate_messages(
|
||||
self,
|
||||
messages: List[dict],
|
||||
info_items: List[InformationItem],
|
||||
actions: List[AutoAction],
|
||||
task_node: str,
|
||||
) -> List[dict]:
|
||||
"""降级截断:只保留关键信息 + 最近1轮对话。"""
|
||||
key_info = self._extract_key_info(info_items, actions, task_node)
|
||||
recent = self._get_recent_messages(messages, 1)
|
||||
|
||||
context_text = _COMPRESSED_CONTEXT_TEMPLATE.format(
|
||||
info_items=key_info["info_items"],
|
||||
actions=key_info["actions"],
|
||||
task_node=task_node or "未指定",
|
||||
summary="(降级截断:原始对话过长,仅保留关键信息)",
|
||||
recent_messages=self._format_recent_messages(recent),
|
||||
)
|
||||
|
||||
return [{"role": "system", "content": context_text}] + recent
|
||||
|
||||
def _extract_key_info(
|
||||
self,
|
||||
info_items: List[InformationItem],
|
||||
actions: List[AutoAction],
|
||||
task_node: str,
|
||||
) -> dict:
|
||||
"""提取关键信息(信息项当前值、已执行动作、任务节点)。"""
|
||||
# 信息项
|
||||
items_str = "\n".join(
|
||||
f"- {item.name}: {item.value}(v{item.version})"
|
||||
for item in info_items
|
||||
if item.is_filled
|
||||
) or "- (暂无已收集信息项)"
|
||||
|
||||
# 已执行动作
|
||||
actions_str = "\n".join(
|
||||
f"- {'✅' if a.status == 'success' else '⏳'} {a.title}({a.action_type})"
|
||||
for a in actions
|
||||
) or "- (暂无已执行动作)"
|
||||
|
||||
return {"info_items": items_str, "actions": actions_str}
|
||||
|
||||
async def _summarize_history(self, messages: List[dict]) -> str:
|
||||
"""调用 LLM 对历史消息生成摘要。"""
|
||||
if not messages:
|
||||
return "(无历史对话需摘要)"
|
||||
|
||||
# 拼接历史消息文本
|
||||
history_text = "\n".join(
|
||||
f"[{m.get('role', 'unknown')}] {m.get('content', '')}"
|
||||
for m in messages
|
||||
)
|
||||
|
||||
if self.dify_client is None:
|
||||
# 无 LLM 客户端 → 简单截取前500字作为摘要
|
||||
logger.warning("无 DifyClient,降级为截取前500字摘要")
|
||||
return history_text[:500] + "..." if len(history_text) > 500 else history_text
|
||||
|
||||
try:
|
||||
# 调用 Dify 做摘要
|
||||
import asyncio
|
||||
result = await asyncio.wait_for(
|
||||
self._call_llm_summary(history_text),
|
||||
timeout=self.timeout,
|
||||
)
|
||||
return result
|
||||
except asyncio.TimeoutError:
|
||||
logger.warning(f"LLM 摘要超时({self.timeout}s),降级为截断")
|
||||
return history_text[:500] + "...(摘要超时截断)"
|
||||
except Exception as e:
|
||||
logger.warning(f"LLM 摘要失败: {e},降级为截断")
|
||||
return history_text[:500] + "...(摘要失败截断)"
|
||||
|
||||
async def _call_llm_summary(self, text: str) -> str:
|
||||
"""调用 LLM 生成摘要(复用 DifyClient)。"""
|
||||
# 构建摘要请求
|
||||
messages = [
|
||||
{"role": "system", "content": _SUMMARY_SYSTEM_PROMPT},
|
||||
{"role": "user", "content": f"请摘要以下对话历史:\n\n{text}"},
|
||||
]
|
||||
# 调用 DifyClient 的 chat 方法
|
||||
if hasattr(self.dify_client, "chat_completion"):
|
||||
result = await self.dify_client.chat_completion(messages)
|
||||
return result.get("content", "")
|
||||
elif hasattr(self.dify_client, "chat"):
|
||||
result = await self.dify_client.chat(messages)
|
||||
return result.get("answer", result.get("content", ""))
|
||||
else:
|
||||
return text[:500]
|
||||
|
||||
def _get_recent_messages(self, messages: List[dict], turns: int) -> List[dict]:
|
||||
"""获取最近 N 轮对话(1轮 = 1条user + 1条assistant)。"""
|
||||
# 一轮 = 2条消息,取最近 turns*2 条
|
||||
take = min(len(messages), turns * 2)
|
||||
return messages[-take:] if take > 0 else []
|
||||
|
||||
def _format_recent_messages(self, messages: List[dict]) -> str:
|
||||
"""格式化最近对话为文本。"""
|
||||
if not messages:
|
||||
return "(无最近对话)"
|
||||
return "\n".join(
|
||||
f"[{m.get('role', 'unknown')}] {m.get('content', '')[:200]}"
|
||||
for m in messages
|
||||
)
|
||||
|
||||
async def _write_log(
|
||||
self,
|
||||
session_id: str,
|
||||
tokens_before: int,
|
||||
tokens_after: int,
|
||||
compression_ratio: float,
|
||||
task_node: str,
|
||||
duration_ms: int,
|
||||
compression_level: int,
|
||||
summary: str,
|
||||
) -> None:
|
||||
"""写入压缩日志到数据库。"""
|
||||
try:
|
||||
log = ContextCompression(
|
||||
session_id=session_id,
|
||||
tokens_before=tokens_before,
|
||||
tokens_after=tokens_after,
|
||||
compression_ratio=compression_ratio,
|
||||
task_node=task_node,
|
||||
duration_ms=duration_ms,
|
||||
compression_level=compression_level,
|
||||
summary=summary,
|
||||
)
|
||||
self.db.add(log)
|
||||
await self.db.flush()
|
||||
except Exception as e:
|
||||
logger.error(f"写入压缩日志失败: {e}")
|
||||
@@ -0,0 +1,289 @@
|
||||
"""
|
||||
纠错服务 — P3 核心组件。
|
||||
|
||||
支持多轮纠错:批量更正、依赖检查、版本链查询、更正撤销。
|
||||
批量更正为单事务原子操作,失败整体回滚。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.models.automation import InformationItem, InformationSnapshot
|
||||
from app.services.automation.exception_handler import AutomationException
|
||||
from app.services.automation.snapshot_service import SnapshotService
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class BatchCorrectResult:
|
||||
"""批量更正结果。"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
corrected_items: List[InformationItem],
|
||||
snapshot_id: int,
|
||||
dependency_warnings: List[dict],
|
||||
):
|
||||
self.corrected_items = corrected_items
|
||||
self.snapshot_id = snapshot_id
|
||||
self.dependency_warnings = dependency_warnings
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
return {
|
||||
"corrected_items": [
|
||||
{
|
||||
"name": item.name,
|
||||
"value": item.value,
|
||||
"version": item.version,
|
||||
}
|
||||
for item in self.corrected_items
|
||||
],
|
||||
"snapshot_id": self.snapshot_id,
|
||||
"dependency_warnings": self.dependency_warnings,
|
||||
}
|
||||
|
||||
|
||||
class CorrectionService:
|
||||
"""多轮纠错服务。"""
|
||||
|
||||
def __init__(self, db: AsyncSession):
|
||||
self.db = db
|
||||
self.snapshot_service = SnapshotService(db)
|
||||
|
||||
async def batch_correct(
|
||||
self,
|
||||
session_id: str,
|
||||
corrections: List[dict],
|
||||
reason: Optional[str] = None,
|
||||
) -> BatchCorrectResult:
|
||||
"""批量更正信息项(单事务原子操作)。
|
||||
|
||||
Args:
|
||||
session_id: 会话ID
|
||||
corrections: 更正列表 [{field, new_value, old_value?}, ...]
|
||||
reason: 更正备注
|
||||
Returns:
|
||||
BatchCorrectResult: 更正结果
|
||||
Raises:
|
||||
AutomationException: 任何一项更正失败则整体回滚
|
||||
"""
|
||||
if not corrections:
|
||||
raise AutomationException(4009, "更正列表不能为空")
|
||||
|
||||
corrected_items = []
|
||||
correction_ids = []
|
||||
first_trigger_key = corrections[0].get("field", "")
|
||||
|
||||
try:
|
||||
# 1. 更正前创建快照
|
||||
snapshot = await self.snapshot_service.create_snapshot(
|
||||
session_id=session_id,
|
||||
trigger_item_key=first_trigger_key,
|
||||
correction_ids=correction_ids,
|
||||
)
|
||||
|
||||
# 2. 逐个更正
|
||||
for corr in corrections:
|
||||
field = corr["field"]
|
||||
new_value = corr["new_value"]
|
||||
old_value = corr.get("old_value")
|
||||
|
||||
item = await self._correct_single(
|
||||
session_id, field, new_value, old_value, reason
|
||||
)
|
||||
corrected_items.append(item)
|
||||
correction_ids.append(item.id)
|
||||
|
||||
# 3. 更新快照的 correction_ids
|
||||
snapshot.correction_ids = correction_ids
|
||||
await self.db.flush()
|
||||
|
||||
# 4. 检查依赖
|
||||
dependency_warnings = []
|
||||
for corr in corrections:
|
||||
warnings = await self.check_dependencies(
|
||||
session_id, corr["field"]
|
||||
)
|
||||
dependency_warnings.extend(warnings)
|
||||
|
||||
logger.info(
|
||||
f"批量更正完成: session={session_id} count={len(corrected_items)} "
|
||||
f"snapshot={snapshot.id}"
|
||||
)
|
||||
|
||||
return BatchCorrectResult(
|
||||
corrected_items=corrected_items,
|
||||
snapshot_id=snapshot.id,
|
||||
dependency_warnings=dependency_warnings,
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
# 事务回滚 — 整体失败
|
||||
logger.error(f"批量更正失败,回滚: session={session_id} error={e}")
|
||||
raise
|
||||
|
||||
async def _correct_single(
|
||||
self,
|
||||
session_id: str,
|
||||
name: str,
|
||||
new_value: str,
|
||||
old_value: Optional[str] = None,
|
||||
reason: Optional[str] = None,
|
||||
) -> InformationItem:
|
||||
"""更正单个信息项。"""
|
||||
item = await self._get_item(session_id, name)
|
||||
|
||||
if item is None:
|
||||
# 不存在则创建
|
||||
item = InformationItem(
|
||||
session_id=session_id,
|
||||
name=name,
|
||||
value=new_value,
|
||||
is_filled=True,
|
||||
version=1,
|
||||
update_history=[],
|
||||
derived_from=None,
|
||||
correction_reason=reason,
|
||||
)
|
||||
self.db.add(item)
|
||||
await self.db.flush()
|
||||
return item
|
||||
|
||||
if item.is_locked:
|
||||
raise AutomationException(
|
||||
4012, f"信息项「{name}」已锁定,不可更正"
|
||||
)
|
||||
|
||||
# 记录变更历史
|
||||
actual_old = old_value if old_value is not None else item.value
|
||||
history_entry = {
|
||||
"version": item.version,
|
||||
"old_value": actual_old,
|
||||
"new_value": new_value,
|
||||
"action": "correct",
|
||||
"reason": reason,
|
||||
"timestamp": __import__("datetime").datetime.now(
|
||||
__import__("datetime").timezone.utc
|
||||
).isoformat(),
|
||||
}
|
||||
history_list = list(item.update_history or [])
|
||||
history_list.append(history_entry)
|
||||
|
||||
item.value = new_value
|
||||
item.version = item.version + 1
|
||||
item.is_filled = True
|
||||
item.update_history = history_list
|
||||
item.correction_reason = reason
|
||||
await self.db.flush()
|
||||
|
||||
logger.info(
|
||||
f"更正信息项: session={session_id} name={name} "
|
||||
f"old={actual_old} new={new_value} v={item.version}"
|
||||
)
|
||||
return item
|
||||
|
||||
async def check_dependencies(
|
||||
self, session_id: str, item_key: str
|
||||
) -> List[dict]:
|
||||
"""检查信息项依赖关系。
|
||||
|
||||
查询所有 derived_from 包含该 item_key 的其他信息项。
|
||||
"""
|
||||
# 获取所有信息项
|
||||
items = await self._get_items(session_id)
|
||||
|
||||
warnings = []
|
||||
for item in items:
|
||||
if item.name == item_key:
|
||||
continue
|
||||
if item.derived_from and item_key in item.derived_from:
|
||||
warnings.append({
|
||||
"item_key": item.name,
|
||||
"current_value": item.value,
|
||||
"derived_from": item_key,
|
||||
"message": f"信息项「{item.name}」依赖「{item_key}」,可能需要同步更新",
|
||||
})
|
||||
|
||||
return warnings
|
||||
|
||||
async def get_correction_history(
|
||||
self, session_id: str
|
||||
) -> List[dict]:
|
||||
"""获取更正历史(基于快照列表)。"""
|
||||
snapshots = await self.snapshot_service.get_snapshot_history(session_id)
|
||||
return [
|
||||
{
|
||||
"snapshot_id": s.id,
|
||||
"trigger_item_key": s.trigger_item_key,
|
||||
"correction_ids": s.correction_ids,
|
||||
"is_undone": s.is_undone,
|
||||
"created_at": s.created_at.isoformat() if s.created_at else None,
|
||||
"snapshot_data": s.snapshot_data,
|
||||
}
|
||||
for s in snapshots
|
||||
]
|
||||
|
||||
async def get_version_chain(
|
||||
self, session_id: str, item_name: str
|
||||
) -> List[dict]:
|
||||
"""获取信息项版本链。"""
|
||||
item = await self._get_item(session_id, item_name)
|
||||
if item is None:
|
||||
return []
|
||||
|
||||
chain = []
|
||||
# 从 update_history 构建版本链
|
||||
for h in (item.update_history or []):
|
||||
chain.append({
|
||||
"version": h["version"],
|
||||
"value": h["old_value"],
|
||||
"new_value": h["new_value"],
|
||||
"action": h["action"],
|
||||
"reason": h.get("reason"),
|
||||
"timestamp": h.get("timestamp"),
|
||||
})
|
||||
|
||||
# 当前版本
|
||||
chain.append({
|
||||
"version": item.version,
|
||||
"value": item.value,
|
||||
"new_value": item.value,
|
||||
"action": "current",
|
||||
"reason": item.correction_reason,
|
||||
"timestamp": item.updated_at.isoformat() if item.updated_at else None,
|
||||
})
|
||||
|
||||
return chain
|
||||
|
||||
async def get_version_diff(
|
||||
self, session_id: str, item_name: str, v1: int, v2: int
|
||||
) -> dict:
|
||||
"""版本对比(委托 SnapshotService)。"""
|
||||
return await self.snapshot_service.get_version_diff(
|
||||
session_id, item_name, v1, v2
|
||||
)
|
||||
|
||||
async def undo_correction(self, session_id: str) -> dict:
|
||||
"""撤销最近一次更正(委托 SnapshotService)。"""
|
||||
return await self.snapshot_service.undo_correction(session_id)
|
||||
|
||||
async def _get_items(self, session_id: str) -> List[InformationItem]:
|
||||
"""获取会话下所有信息项。"""
|
||||
stmt = select(InformationItem).where(
|
||||
InformationItem.session_id == session_id
|
||||
)
|
||||
return list((await self.db.execute(stmt)).scalars().all())
|
||||
|
||||
async def _get_item(
|
||||
self, session_id: str, name: str
|
||||
) -> Optional[InformationItem]:
|
||||
"""按名称获取单个信息项。"""
|
||||
stmt = select(InformationItem).where(
|
||||
InformationItem.session_id == session_id,
|
||||
InformationItem.name == name,
|
||||
)
|
||||
return (await self.db.execute(stmt)).scalar_one_or_none()
|
||||
@@ -0,0 +1,336 @@
|
||||
# =============================================================================
|
||||
# 企微IT智能服务台 — 复杂场景重构 信息项管理服务
|
||||
# =============================================================================
|
||||
# 说明:管理对话中收集的信息项(InformationItem),支持创建、查询、更正、
|
||||
# 补充、锁定及下游影响检测。更正时保留变更历史(version + update_history),
|
||||
# 补充时按修饰符决定追加或覆盖。动作执行后锁定含「固定」修饰符的信息项。
|
||||
#
|
||||
# 关键逻辑:
|
||||
# 1. correct_value() — 检查 is_locked,旧值存入 update_history,value 覆盖,version+1
|
||||
# 2. supplement_value() — 不存在则创建(modifiers=["增量"]);含「增量」则追加,否则覆盖
|
||||
# 3. lock_items_for_action() — 动作执行后锁定含「固定」修饰符的关联信息项
|
||||
# 4. check_downstream_impact() — 检查更正是否影响已生成的待执行动作
|
||||
# =============================================================================
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any, List, Optional
|
||||
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.constants import (
|
||||
AutomationErrorCode,
|
||||
INFO_MODIFIER_FIXED,
|
||||
INFO_MODIFIER_INCREMENTAL,
|
||||
INFO_MODIFIER_REQUIRED,
|
||||
)
|
||||
from app.models.automation import AutoAction, InformationItem
|
||||
from app.services.automation.exception_handler import AutomationException
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# 动作已执行的状态集合(执行后锁定固定信息项)
|
||||
_ACTION_EXECUTED_STATUSES = {"success", "failed", "rejected", "skipped"}
|
||||
|
||||
|
||||
class InformationItemService:
|
||||
"""信息项管理服务。
|
||||
|
||||
提供信息项的 CRUD、更正、补充、锁定及下游影响检测能力。
|
||||
"""
|
||||
|
||||
def __init__(self, db: Any, redis: Any = None):
|
||||
self.db = db
|
||||
self.redis = redis
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# 创建
|
||||
# --------------------------------------------------------------------------
|
||||
async def create_item(
|
||||
self,
|
||||
session_id: str,
|
||||
name: str,
|
||||
value: str,
|
||||
modifiers: Optional[List[str]] = None,
|
||||
) -> InformationItem:
|
||||
"""创建信息项。
|
||||
|
||||
Args:
|
||||
session_id: 关联会话ID
|
||||
name: 信息项名称
|
||||
value: 初始值
|
||||
modifiers: 修饰符列表,如 ["固定", "必需"]
|
||||
Returns:
|
||||
InformationItem: 创建的信息项
|
||||
"""
|
||||
item = InformationItem(
|
||||
session_id=session_id,
|
||||
name=name,
|
||||
value=value,
|
||||
modifiers=modifiers or [],
|
||||
is_filled=bool(value),
|
||||
is_locked=False,
|
||||
version=1,
|
||||
update_history=[],
|
||||
)
|
||||
self.db.add(item)
|
||||
await self.db.flush()
|
||||
logger.info(f"创建信息项: session={session_id} name={name} value={value}")
|
||||
return item
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# 查询
|
||||
# --------------------------------------------------------------------------
|
||||
async def get_items(self, session_id: str) -> List[InformationItem]:
|
||||
"""获取会话下所有信息项。"""
|
||||
stmt = select(InformationItem).where(
|
||||
InformationItem.session_id == session_id
|
||||
)
|
||||
return list((await self.db.execute(stmt)).scalars().all())
|
||||
|
||||
async def get_item(
|
||||
self, session_id: str, name: str
|
||||
) -> Optional[InformationItem]:
|
||||
"""按名称获取单个信息项。"""
|
||||
stmt = select(InformationItem).where(
|
||||
InformationItem.session_id == session_id,
|
||||
InformationItem.name == name,
|
||||
)
|
||||
return (await self.db.execute(stmt)).scalar_one_or_none()
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# 更正(CORRECT 意图)
|
||||
# --------------------------------------------------------------------------
|
||||
async def correct_value(
|
||||
self,
|
||||
session_id: str,
|
||||
name: str,
|
||||
new_value: str,
|
||||
old_value: Optional[str] = None,
|
||||
) -> InformationItem:
|
||||
"""更正信息项值。
|
||||
|
||||
逻辑:
|
||||
- 检查 is_locked,若 True → 抛出 INFO_ITEM_LOCKED 异常
|
||||
- 旧值存入 update_history
|
||||
- value = new_value, version += 1, is_filled = True
|
||||
|
||||
Args:
|
||||
session_id: 会话ID
|
||||
name: 字段名
|
||||
new_value: 新值
|
||||
old_value: 旧值(可选,Dify 提取时可能有)
|
||||
Returns:
|
||||
InformationItem: 更正后的信息项
|
||||
Raises:
|
||||
AutomationException: 信息项已锁定
|
||||
"""
|
||||
item = await self.get_item(session_id, name)
|
||||
if item is None:
|
||||
# 信息项不存在 → 创建(视为首次填写)
|
||||
item = await self.create_item(session_id, name, new_value, [])
|
||||
return item
|
||||
|
||||
if item.is_locked:
|
||||
raise AutomationException(
|
||||
AutomationErrorCode.INFO_ITEM_LOCKED,
|
||||
f"信息项「{name}」已锁定,不可更正",
|
||||
)
|
||||
|
||||
# 记录变更历史
|
||||
actual_old_value = old_value if old_value is not None else item.value
|
||||
history_entry = {
|
||||
"version": item.version,
|
||||
"old_value": actual_old_value,
|
||||
"new_value": new_value,
|
||||
"action": "correct",
|
||||
"timestamp": datetime.now(timezone.utc).isoformat(),
|
||||
}
|
||||
history_list = list(item.update_history or [])
|
||||
history_list.append(history_entry)
|
||||
|
||||
item.value = new_value
|
||||
item.version = item.version + 1
|
||||
item.is_filled = True
|
||||
item.update_history = history_list
|
||||
await self.db.flush()
|
||||
|
||||
logger.info(
|
||||
f"更正信息项: session={session_id} name={name} "
|
||||
f"old={actual_old_value} new={new_value} v={item.version}"
|
||||
)
|
||||
return item
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# 补充(SUPPLEMENT 意图)
|
||||
# --------------------------------------------------------------------------
|
||||
async def supplement_value(
|
||||
self,
|
||||
session_id: str,
|
||||
name: str,
|
||||
value: str,
|
||||
) -> InformationItem:
|
||||
"""补充信息项值。
|
||||
|
||||
逻辑:
|
||||
- 信息项不存在 → 创建新项(modifiers 默认 ["增量"])
|
||||
- modifiers 含「增量」→ value = value + "; " + new_value(追加)
|
||||
- 否则同 correct 处理(覆盖)
|
||||
|
||||
Args:
|
||||
session_id: 会话ID
|
||||
name: 字段名
|
||||
value: 补充值
|
||||
Returns:
|
||||
InformationItem: 补充后的信息项
|
||||
"""
|
||||
item = await self.get_item(session_id, name)
|
||||
|
||||
if item is None:
|
||||
# 不存在 → 创建,默认增量修饰符
|
||||
item = await self.create_item(
|
||||
session_id, name, value, [INFO_MODIFIER_INCREMENTAL]
|
||||
)
|
||||
return item
|
||||
|
||||
modifiers = item.modifiers or []
|
||||
old_value = item.value
|
||||
|
||||
if INFO_MODIFIER_INCREMENTAL in modifiers:
|
||||
# 增量修饰符 → 追加
|
||||
new_value = f"{old_value}; {value}" if old_value else value
|
||||
else:
|
||||
# 非增量 → 覆盖(同 correct)
|
||||
if item.is_locked:
|
||||
raise AutomationException(
|
||||
AutomationErrorCode.INFO_ITEM_LOCKED,
|
||||
f"信息项「{name}」已锁定,不可更正",
|
||||
)
|
||||
new_value = value
|
||||
|
||||
# 记录变更历史
|
||||
history_entry = {
|
||||
"version": item.version,
|
||||
"old_value": old_value,
|
||||
"new_value": new_value,
|
||||
"action": "supplement",
|
||||
"timestamp": datetime.now(timezone.utc).isoformat(),
|
||||
}
|
||||
history_list = list(item.update_history or [])
|
||||
history_list.append(history_entry)
|
||||
|
||||
item.value = new_value
|
||||
item.version = item.version + 1
|
||||
item.is_filled = True
|
||||
item.update_history = history_list
|
||||
await self.db.flush()
|
||||
|
||||
logger.info(
|
||||
f"补充信息项: session={session_id} name={name} "
|
||||
f"old={old_value} new={new_value} v={item.version}"
|
||||
)
|
||||
return item
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# 锁定(动作执行后调用)
|
||||
# --------------------------------------------------------------------------
|
||||
async def lock_items_for_action(
|
||||
self, session_id: str, action_id: str
|
||||
) -> None:
|
||||
"""动作执行后锁定关联信息项。
|
||||
|
||||
逻辑:
|
||||
- 查找该 action 的 payload 中引用的信息项(通过 payload key 名匹配信息项 name)
|
||||
- 对含「固定」修饰符的信息项设置 is_locked = True
|
||||
|
||||
Args:
|
||||
session_id: 会话ID
|
||||
action_id: 关联动作ID
|
||||
"""
|
||||
# 查询动作
|
||||
act_stmt = select(AutoAction).where(AutoAction.id == action_id)
|
||||
action = (await self.db.execute(act_stmt)).scalar_one_or_none()
|
||||
if action is None:
|
||||
return
|
||||
|
||||
payload = action.payload or {}
|
||||
# payload 中的 key 名即为信息项 name 的候选集
|
||||
candidate_names = set(payload.keys())
|
||||
|
||||
if not candidate_names:
|
||||
return
|
||||
|
||||
# 查询会话下所有信息项
|
||||
items = await self.get_items(session_id)
|
||||
locked_count = 0
|
||||
for item in items:
|
||||
if item.name in candidate_names and INFO_MODIFIER_FIXED in (item.modifiers or []):
|
||||
if not item.is_locked:
|
||||
item.is_locked = True
|
||||
locked_count += 1
|
||||
|
||||
if locked_count > 0:
|
||||
await self.db.flush()
|
||||
logger.info(
|
||||
f"锁定信息项: session={session_id} action={action_id} "
|
||||
f"locked={locked_count}"
|
||||
)
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# 下游影响检测
|
||||
# --------------------------------------------------------------------------
|
||||
async def check_downstream_impact(
|
||||
self, session_id: str, field_name: str
|
||||
) -> bool:
|
||||
"""检查更正是否影响已生成的待执行动作。
|
||||
|
||||
逻辑:
|
||||
- 查询 auto_actions 表中 status in ('pending', 'await_approval') 的动作
|
||||
- 检查动作 payload 是否引用了被更正的字段
|
||||
- 返回 True/False
|
||||
|
||||
Args:
|
||||
session_id: 会话ID
|
||||
field_name: 被更正的字段名
|
||||
Returns:
|
||||
bool: True 表示影响下游动作
|
||||
"""
|
||||
stmt = select(AutoAction).where(
|
||||
AutoAction.session_id == session_id,
|
||||
AutoAction.status.in_(["pending", "await_approval"]),
|
||||
)
|
||||
pending_actions = list((await self.db.execute(stmt)).scalars().all())
|
||||
|
||||
for action in pending_actions:
|
||||
payload = action.payload or {}
|
||||
if field_name in payload:
|
||||
logger.info(
|
||||
f"下游影响检测: session={session_id} field={field_name} "
|
||||
f"affected_action={action.id}"
|
||||
)
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# 获取未填写的必需信息项
|
||||
# --------------------------------------------------------------------------
|
||||
async def get_pending_required_items(self, session_id: str) -> List[str]:
|
||||
"""获取未填写的必需信息项名称列表。
|
||||
|
||||
用于恢复会话时检查必需信息项完整性。
|
||||
|
||||
Args:
|
||||
session_id: 会话ID
|
||||
Returns:
|
||||
List[str]: 未填写的必需信息项名称列表
|
||||
"""
|
||||
items = await self.get_items(session_id)
|
||||
pending: List[str] = []
|
||||
for item in items:
|
||||
modifiers = item.modifiers or []
|
||||
if INFO_MODIFIER_REQUIRED in modifiers and not item.is_filled:
|
||||
pending.append(item.name)
|
||||
return pending
|
||||
@@ -1,8 +1,10 @@
|
||||
# =============================================================================
|
||||
# 企微IT智能服务台 — 阶段5 自动化 意图识别
|
||||
# 企微IT智能服务台 — 自动化 意图识别(含全局意图)
|
||||
# =============================================================================
|
||||
# 说明:调用 Dify 识别员工诉求命中哪个自动化场景;Dify 未配置时走关键词兜底,
|
||||
# 保证 P0 四个场景在无真实 Dify 环境下也能跑通闭环。
|
||||
# 复杂场景重构:新增全局意图检测(PAUSE/RESUME_TASK/CORRECT/SUPPLEMENT),
|
||||
# detect() 内部先调全局意图检测,命中则返回全局意图,跳过场景识别。
|
||||
# =============================================================================
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -17,17 +19,31 @@ logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class IntentRouter:
|
||||
"""意图识别路由器。"""
|
||||
"""意图识别路由器(含全局对话控制意图)。"""
|
||||
|
||||
def __init__(self, db: Any = None, audit: Any = None):
|
||||
self.db = db
|
||||
self.audit = audit
|
||||
|
||||
async def detect(self, description: str, employee_id: str = "") -> Dict[str, Any]:
|
||||
"""识别意图,返回 {scenario_key, confidence, raw, error}。
|
||||
"""识别意图,返回包含 global_intent 的完整结果。
|
||||
|
||||
优先走 Dify;若 Dify 未配置或调用失败,使用关键词兜底。
|
||||
优先检测全局对话控制意图(pause/resume_task/correct/supplement),
|
||||
命中则直接返回全局意图,跳过场景识别;
|
||||
未命中则走原有场景识别流程。
|
||||
|
||||
Returns:
|
||||
Dict: {global_intent, scenario_key, confidence, corrected_field,
|
||||
old_value, new_value, supplement_field, supplement_value,
|
||||
raw, error}
|
||||
"""
|
||||
# 1. 先检测全局意图
|
||||
global_result = await self.detect_global_intent(description)
|
||||
if global_result.get("global_intent") is not None:
|
||||
# 命中全局意图 → 直接返回,跳过场景识别
|
||||
return global_result
|
||||
|
||||
# 2. 未命中全局意图 → 走原场景识别
|
||||
client: Optional[DifyClient] = None
|
||||
try:
|
||||
client = await build_dify_client(audit=self.audit)
|
||||
@@ -37,12 +53,97 @@ class IntentRouter:
|
||||
if client is None:
|
||||
fb = DifyClient._fallback_intent(description)
|
||||
fb["error"] = "dify_not_configured"
|
||||
# 确保全局意图字段存在
|
||||
fb.setdefault("global_intent", None)
|
||||
fb.setdefault("corrected_field", None)
|
||||
fb.setdefault("old_value", None)
|
||||
fb.setdefault("new_value", None)
|
||||
fb.setdefault("supplement_field", None)
|
||||
fb.setdefault("supplement_value", None)
|
||||
return fb
|
||||
|
||||
try:
|
||||
return await client.detect_intent(description, employee_id)
|
||||
result = await client.detect_intent(description, employee_id)
|
||||
# 确保全局意图字段存在(兼容旧版 Dify 返回)
|
||||
result.setdefault("global_intent", None)
|
||||
result.setdefault("corrected_field", None)
|
||||
result.setdefault("old_value", None)
|
||||
result.setdefault("new_value", None)
|
||||
result.setdefault("supplement_field", None)
|
||||
result.setdefault("supplement_value", None)
|
||||
return result
|
||||
except Exception as e: # noqa: BLE001
|
||||
logger.warning(f"Dify 意图识别异常,转关键词兜底: {e}")
|
||||
fb = DifyClient._fallback_intent(description)
|
||||
fb["error"] = str(e)
|
||||
fb.setdefault("global_intent", None)
|
||||
fb.setdefault("corrected_field", None)
|
||||
fb.setdefault("old_value", None)
|
||||
fb.setdefault("new_value", None)
|
||||
fb.setdefault("supplement_field", None)
|
||||
fb.setdefault("supplement_value", None)
|
||||
return fb
|
||||
|
||||
async def detect_global_intent(self, text: str) -> Dict[str, Any]:
|
||||
"""检测全局对话控制意图(pause/resume_task/correct/supplement)。
|
||||
|
||||
优先调用 Dify(复用现有客户端),Prompt 中增加全局意图判断;
|
||||
Dify 不可用时走关键词兜底(使用 GLOBAL_INTENT_KEYWORDS)。
|
||||
|
||||
Returns:
|
||||
Dict: {global_intent, scenario_key, confidence, corrected_field,
|
||||
old_value, new_value, supplement_field, supplement_value,
|
||||
raw, error}
|
||||
"""
|
||||
# 尝试 Dify
|
||||
client: Optional[DifyClient] = None
|
||||
try:
|
||||
client = await build_dify_client(audit=self.audit)
|
||||
except Exception as e: # noqa: BLE001
|
||||
logger.debug(f"构建 Dify 客户端失败,全局意图转关键词兜底: {e}")
|
||||
|
||||
if client is not None:
|
||||
try:
|
||||
result = await client.detect_intent(text, "")
|
||||
# detect_intent 已返回 global_intent,直接使用
|
||||
return result
|
||||
except Exception as e: # noqa: BLE001
|
||||
logger.warning(f"Dify 全局意图识别异常,转关键词兜底: {e}")
|
||||
|
||||
# 关键词兜底
|
||||
return self._keyword_fallback_global(text)
|
||||
|
||||
def _keyword_fallback_global(self, text: str) -> Dict[str, Any]:
|
||||
"""全局意图关键词兜底。"""
|
||||
lower_text = (text or "").lower()
|
||||
try:
|
||||
from app.constants import GLOBAL_INTENT_KEYWORDS
|
||||
|
||||
for intent, keywords in GLOBAL_INTENT_KEYWORDS.items():
|
||||
if any(kw.lower() in lower_text for kw in keywords):
|
||||
return {
|
||||
"global_intent": intent,
|
||||
"scenario_key": None,
|
||||
"confidence": 0.6,
|
||||
"corrected_field": None,
|
||||
"old_value": None,
|
||||
"new_value": None,
|
||||
"supplement_field": None,
|
||||
"supplement_value": None,
|
||||
"raw": "",
|
||||
"error": "fallback",
|
||||
}
|
||||
except Exception: # noqa: BLE001
|
||||
pass
|
||||
return {
|
||||
"global_intent": None,
|
||||
"scenario_key": None,
|
||||
"confidence": 0.0,
|
||||
"corrected_field": None,
|
||||
"old_value": None,
|
||||
"new_value": None,
|
||||
"supplement_field": None,
|
||||
"supplement_value": None,
|
||||
"raw": "",
|
||||
"error": "fallback",
|
||||
}
|
||||
|
||||
@@ -20,9 +20,14 @@ from app.constants import (
|
||||
AUTOMATION_SILENT_CLOSE_TTL,
|
||||
AUTOMATION_WS_ACTION_REQUIRED,
|
||||
AUTOMATION_WS_ERROR,
|
||||
AUTOMATION_WS_INFO_CORRECTED,
|
||||
AUTOMATION_WS_INFO_SUPPLEMENTED,
|
||||
AUTOMATION_WS_PAUSED,
|
||||
AUTOMATION_WS_PROGRESS,
|
||||
AUTOMATION_WS_RESOLVED,
|
||||
AUTOMATION_WS_RESUMED,
|
||||
AUTOMATION_WS_TAKEOVER,
|
||||
AUTOMATION_WS_TIMEOUT_CLOSED,
|
||||
)
|
||||
from app.services.ws_manager import manager as ws_manager
|
||||
|
||||
@@ -150,6 +155,139 @@ async def publish_error(session_id: str, code: int, message: str) -> None:
|
||||
await _publish(AUTOMATION_WS_ERROR, session_id, {"code": code, "message": message})
|
||||
|
||||
|
||||
# ==========================================================================
|
||||
# 复杂场景重构第一阶段 — 新增 WS 事件推送
|
||||
# ==========================================================================
|
||||
async def publish_paused(
|
||||
session_id: str,
|
||||
title: str,
|
||||
paused_at: str,
|
||||
resume_hint: str = "",
|
||||
) -> None:
|
||||
"""推送会话暂停事件。
|
||||
|
||||
Args:
|
||||
session_id: 会话ID
|
||||
title: 会话标题
|
||||
paused_at: 暂停时间(ISO 字符串)
|
||||
resume_hint: 恢复提示文案
|
||||
"""
|
||||
await _publish(
|
||||
AUTOMATION_WS_PAUSED,
|
||||
session_id,
|
||||
{
|
||||
"session_id": session_id,
|
||||
"title": title,
|
||||
"paused_at": paused_at,
|
||||
"resume_hint": resume_hint,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
async def publish_resumed(
|
||||
session_id: str,
|
||||
title: str,
|
||||
resumed_at: str,
|
||||
current_step: str = "",
|
||||
) -> None:
|
||||
"""推送会话恢复事件。
|
||||
|
||||
Args:
|
||||
session_id: 会话ID
|
||||
title: 会话标题
|
||||
resumed_at: 恢复时间(ISO 字符串)
|
||||
current_step: 当前步骤描述
|
||||
"""
|
||||
await _publish(
|
||||
AUTOMATION_WS_RESUMED,
|
||||
session_id,
|
||||
{
|
||||
"session_id": session_id,
|
||||
"title": title,
|
||||
"resumed_at": resumed_at,
|
||||
"current_step": current_step,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
async def publish_timeout_closed(
|
||||
session_id: str,
|
||||
closed_at: str,
|
||||
reason: str = "",
|
||||
) -> None:
|
||||
"""推送暂停超时关闭事件。
|
||||
|
||||
Args:
|
||||
session_id: 会话ID
|
||||
closed_at: 关闭时间(ISO 字符串)
|
||||
reason: 关闭原因
|
||||
"""
|
||||
await _publish(
|
||||
AUTOMATION_WS_TIMEOUT_CLOSED,
|
||||
session_id,
|
||||
{
|
||||
"session_id": session_id,
|
||||
"closed_at": closed_at,
|
||||
"reason": reason,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
async def publish_info_corrected(
|
||||
session_id: str,
|
||||
field: str,
|
||||
old_value: str,
|
||||
new_value: str,
|
||||
version: int,
|
||||
) -> None:
|
||||
"""推送信息更正事件。
|
||||
|
||||
Args:
|
||||
session_id: 会话ID
|
||||
field: 更正的字段名
|
||||
old_value: 旧值
|
||||
new_value: 新值
|
||||
version: 更正后的版本号
|
||||
"""
|
||||
await _publish(
|
||||
AUTOMATION_WS_INFO_CORRECTED,
|
||||
session_id,
|
||||
{
|
||||
"session_id": session_id,
|
||||
"field": field,
|
||||
"old_value": old_value,
|
||||
"new_value": new_value,
|
||||
"version": version,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
async def publish_info_supplemented(
|
||||
session_id: str,
|
||||
field: str,
|
||||
supplement_value: str,
|
||||
new_value: str,
|
||||
) -> None:
|
||||
"""推送信息补充事件。
|
||||
|
||||
Args:
|
||||
session_id: 会话ID
|
||||
field: 补充的字段名
|
||||
supplement_value: 本次补充的值
|
||||
new_value: 补充后的完整值
|
||||
"""
|
||||
await _publish(
|
||||
AUTOMATION_WS_INFO_SUPPLEMENTED,
|
||||
session_id,
|
||||
{
|
||||
"session_id": session_id,
|
||||
"field": field,
|
||||
"supplement_value": supplement_value,
|
||||
"new_value": new_value,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def schedule_silent_close(
|
||||
session_id: str, ttl: int = AUTOMATION_SILENT_CLOSE_TTL, on_expire=None
|
||||
) -> None:
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any, Dict, List, Optional
|
||||
@@ -18,23 +19,40 @@ from typing import Any, Dict, List, Optional
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.config import settings
|
||||
from app.constants import AutomationErrorCode
|
||||
from app.constants import (
|
||||
AutomationErrorCode,
|
||||
GLOBAL_INTENT_CORRECT,
|
||||
GLOBAL_INTENT_PAUSE,
|
||||
GLOBAL_INTENT_RESUME_TASK,
|
||||
GLOBAL_INTENT_SUPPLEMENT,
|
||||
PAUSE_TIMEOUT_HOURS,
|
||||
REDIS_KEY_PAUSED_SESSIONS,
|
||||
REDIS_KEY_RESUME_POINT,
|
||||
RESUME_POINT_REDIS_TTL,
|
||||
)
|
||||
from app.database import _get_session_factory
|
||||
from app.models.automation import (
|
||||
ApprovalTicket,
|
||||
AutoAction,
|
||||
AutoSession,
|
||||
InformationItem,
|
||||
ScenarioConfig,
|
||||
)
|
||||
from app.services.automation.approval import ApprovalService
|
||||
from app.services.automation.exception_handler import AutomationException
|
||||
from app.services.automation.executor import ActionExecutor
|
||||
from app.services.automation.information_item_service import InformationItemService
|
||||
from app.services.automation.intent_router import IntentRouter
|
||||
from app.services.automation.mapping_resolver import MappingResolver
|
||||
from app.services.automation.progress_publisher import (
|
||||
cancel_silent_close,
|
||||
publish_info_corrected,
|
||||
publish_info_supplemented,
|
||||
publish_paused,
|
||||
publish_progress,
|
||||
publish_resumed,
|
||||
publish_takeover,
|
||||
publish_timeout_closed,
|
||||
)
|
||||
from app.services.automation import DEFAULT_SCENARIO_CONFIGS
|
||||
|
||||
@@ -124,7 +142,11 @@ class AutoSessionService:
|
||||
# 编排主流程
|
||||
# --------------------------------------------------------------------------
|
||||
async def start(self, session_id: str) -> None:
|
||||
"""编排:意图识别 → 场景校验 → 映射 → 计划 → 执行。"""
|
||||
"""编排:意图识别 → 全局意图分流 → 场景校验 → 映射 → 计划 → 执行。
|
||||
|
||||
复杂场景重构:意图识别后先检查 global_intent,
|
||||
命中 pause/resume_task/correct/supplement 时分流到对应方法。
|
||||
"""
|
||||
session = await self.get_session(session_id)
|
||||
if session is None:
|
||||
logger.warning(f"start 会话不存在: {session_id}")
|
||||
@@ -139,12 +161,12 @@ class AutoSessionService:
|
||||
|
||||
description = (session.meta or {}).get("description", "")
|
||||
|
||||
# 1. 意图识别
|
||||
# 1. 意图识别(含全局意图检测)
|
||||
router = IntentRouter(self.db, audit=self.audit)
|
||||
try:
|
||||
intent = await router.detect(description, session.employee_id)
|
||||
except Exception as e: # noqa: BLE001
|
||||
intent = {"scenario_key": None, "confidence": 0.0, "error": str(e)}
|
||||
intent = {"global_intent": None, "scenario_key": None, "confidence": 0.0, "error": str(e)}
|
||||
session.scenario_key = intent.get("scenario_key")
|
||||
session.confidence = float(intent.get("confidence") or 0.0)
|
||||
session.intent = intent
|
||||
@@ -155,6 +177,29 @@ class AutoSessionService:
|
||||
f"识别场景: {session.scenario_key or '未知'}(置信度 {session.confidence:.2f})",
|
||||
)
|
||||
|
||||
# 1.5 全局意图分流(复杂场景重构)
|
||||
global_intent = intent.get("global_intent")
|
||||
if global_intent == GLOBAL_INTENT_PAUSE:
|
||||
await self.pause_session(session_id, reason="用户主动暂停")
|
||||
return
|
||||
if global_intent == GLOBAL_INTENT_RESUME_TASK:
|
||||
# 当前会话刚创建,恢复逻辑应指向已有暂停会话
|
||||
await self.resume_session(session.employee_id)
|
||||
return
|
||||
if global_intent == GLOBAL_INTENT_CORRECT:
|
||||
field = intent.get("corrected_field") or ""
|
||||
new_value = intent.get("new_value") or ""
|
||||
old_value = intent.get("old_value")
|
||||
if field and new_value:
|
||||
await self.correct_info(session_id, field, new_value, old_value)
|
||||
return
|
||||
if global_intent == GLOBAL_INTENT_SUPPLEMENT:
|
||||
field = intent.get("supplement_field") or ""
|
||||
value = intent.get("supplement_value") or ""
|
||||
if field and value:
|
||||
await self.supplement_info(session_id, field, value)
|
||||
return
|
||||
|
||||
# 2. 置信度门槛 → 低置信度转人工
|
||||
thresholds = settings.get_automation_thresholds()
|
||||
confidence_min = float(thresholds.get("confidence_min", 0.6))
|
||||
@@ -310,6 +355,469 @@ class AutoSessionService:
|
||||
session.closed_by = "system(auto)"
|
||||
await self.db.flush()
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# 复杂场景重构:暂停 / 恢复 / 更正 / 补充 / 坐席操作
|
||||
# --------------------------------------------------------------------------
|
||||
async def pause_session(
|
||||
self, session_id: str, reason: Optional[str] = None
|
||||
) -> AutoSession:
|
||||
"""暂停会话。
|
||||
|
||||
校验状态:running / await_approval → paused;终态不可暂停。
|
||||
构建恢复点快照存入 Redis,推送 WS 暂停事件。
|
||||
|
||||
Args:
|
||||
session_id: 会话ID
|
||||
reason: 暂停原因
|
||||
Returns:
|
||||
AutoSession: 暂停后的会话
|
||||
Raises:
|
||||
AutomationException: 会话不存在或不可暂停
|
||||
"""
|
||||
session = await self.get_session(session_id)
|
||||
if session is None:
|
||||
raise AutomationException(AutomationErrorCode.SESSION_NOT_FOUND)
|
||||
|
||||
# 终态不可暂停
|
||||
if session.status in ("closed", "handoff", "error"):
|
||||
raise AutomationException(AutomationErrorCode.SESSION_NOT_PAUSABLE)
|
||||
|
||||
# 如果是 await_approval 状态暂停 → 记录到 meta(挂起审批计时)
|
||||
meta = dict(session.meta or {})
|
||||
if session.status == "await_approval" or self._is_awaiting_approval(session):
|
||||
meta["paused_from_approval"] = True
|
||||
|
||||
now = datetime.now(timezone.utc)
|
||||
session.status = "paused"
|
||||
session.paused_at = now
|
||||
session.meta = meta
|
||||
await self.db.flush()
|
||||
|
||||
# 构建恢复点快照
|
||||
info_svc = InformationItemService(self.db, self.redis)
|
||||
info_items = await info_svc.get_items(session_id)
|
||||
info_items_snapshot = [
|
||||
{
|
||||
"id": item.id,
|
||||
"name": item.name,
|
||||
"value": item.value,
|
||||
"modifiers": item.modifiers or [],
|
||||
"is_filled": item.is_filled,
|
||||
"is_locked": item.is_locked,
|
||||
"version": item.version,
|
||||
}
|
||||
for item in info_items
|
||||
]
|
||||
step_desc = await self._get_current_step_desc(session)
|
||||
resume_point = {
|
||||
"title": session.title,
|
||||
"scenario_key": session.scenario_key,
|
||||
"current_action_id": session.current_action_id,
|
||||
"step_desc": step_desc,
|
||||
"info_items": info_items_snapshot,
|
||||
"paused_at": now.isoformat(),
|
||||
}
|
||||
|
||||
# Redis 存储恢复点 + 暂停会话集合
|
||||
if self.redis:
|
||||
try:
|
||||
import json
|
||||
resume_key = REDIS_KEY_RESUME_POINT.format(session_id=session_id)
|
||||
await self.redis.setex(
|
||||
resume_key,
|
||||
RESUME_POINT_REDIS_TTL,
|
||||
json.dumps(resume_point, ensure_ascii=False),
|
||||
)
|
||||
paused_key = REDIS_KEY_PAUSED_SESSIONS.format(
|
||||
employee_id=session.employee_id
|
||||
)
|
||||
await self.redis.sadd(paused_key, session_id)
|
||||
except Exception as e: # noqa: BLE001
|
||||
logger.warning(f"Redis 存储恢复点失败 session={session_id}: {e}")
|
||||
|
||||
# 推送 WS 暂停事件
|
||||
resume_hint = "需要继续时跟我说一声「继续」就好"
|
||||
await publish_paused(
|
||||
session_id=session_id,
|
||||
title=session.title,
|
||||
paused_at=now.isoformat(),
|
||||
resume_hint=resume_hint,
|
||||
)
|
||||
|
||||
logger.info(f"暂停会话: session={session_id} reason={reason or ''}")
|
||||
return session
|
||||
|
||||
async def resume_session(
|
||||
self,
|
||||
employee_id: str,
|
||||
session_id: Optional[str] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""恢复暂停的会话。
|
||||
|
||||
若 session_id 为 None → 查询该员工的暂停会话列表。
|
||||
若多个暂停会话 → 返回列表供前端选择(不直接恢复)。
|
||||
若单个 → 直接恢复。
|
||||
|
||||
Args:
|
||||
employee_id: 员工ID
|
||||
session_id: 指定恢复的会话ID(可选)
|
||||
Returns:
|
||||
Dict: {"session": AutoSession, "resume_point": dict, "need_select": bool, "paused_list": list}
|
||||
Raises:
|
||||
AutomationException: 会话不存在或不可恢复
|
||||
"""
|
||||
# 未指定 session_id → 查询暂停会话列表
|
||||
if session_id is None:
|
||||
paused_list = await self.list_paused_sessions(employee_id)
|
||||
if len(paused_list) == 0:
|
||||
return {"session": None, "resume_point": None, "need_select": False, "paused_list": []}
|
||||
if len(paused_list) > 1:
|
||||
return {
|
||||
"session": None,
|
||||
"resume_point": None,
|
||||
"need_select": True,
|
||||
"paused_list": paused_list,
|
||||
}
|
||||
# 单个暂停会话 → 直接恢复
|
||||
session_id = paused_list[0]["session_id"]
|
||||
|
||||
session = await self.get_session(session_id)
|
||||
if session is None:
|
||||
raise AutomationException(AutomationErrorCode.SESSION_NOT_FOUND)
|
||||
|
||||
if session.status != "paused":
|
||||
if session.status == "closed":
|
||||
raise AutomationException(
|
||||
AutomationErrorCode.SESSION_NOT_RESUMABLE,
|
||||
"该任务已超时关闭,请重新发起",
|
||||
)
|
||||
raise AutomationException(AutomationErrorCode.SESSION_NOT_RESUMABLE)
|
||||
|
||||
# 从 Redis 加载恢复点
|
||||
resume_point = await self._load_resume_point(session_id)
|
||||
|
||||
# 恢复状态
|
||||
now = datetime.now(timezone.utc)
|
||||
session.status = "running"
|
||||
session.paused_at = None
|
||||
await self.db.flush()
|
||||
|
||||
# Redis 清理
|
||||
if self.redis:
|
||||
try:
|
||||
resume_key = REDIS_KEY_RESUME_POINT.format(session_id=session_id)
|
||||
await self.redis.delete(resume_key)
|
||||
paused_key = REDIS_KEY_PAUSED_SESSIONS.format(
|
||||
employee_id=employee_id
|
||||
)
|
||||
await self.redis.srem(paused_key, session_id)
|
||||
except Exception as e: # noqa: BLE001
|
||||
logger.warning(f"Redis 清理恢复点失败 session={session_id}: {e}")
|
||||
|
||||
# 检查必需信息项完整性
|
||||
info_svc = InformationItemService(self.db, self.redis)
|
||||
pending_items = await info_svc.get_pending_required_items(session_id)
|
||||
|
||||
# 推送 WS 恢复事件
|
||||
current_step = resume_point.get("step_desc", "") if resume_point else ""
|
||||
await publish_resumed(
|
||||
session_id=session_id,
|
||||
title=session.title,
|
||||
resumed_at=now.isoformat(),
|
||||
current_step=current_step,
|
||||
)
|
||||
|
||||
# 若信息完整 → 续行执行
|
||||
if not pending_items:
|
||||
executor = ActionExecutor(self.db, self.redis, audit=self.audit)
|
||||
asyncio.create_task(self._run_executor(session_id))
|
||||
|
||||
logger.info(f"恢复会话: session={session_id} pending_items={pending_items}")
|
||||
return {
|
||||
"session": session,
|
||||
"resume_point": resume_point,
|
||||
"need_select": False,
|
||||
"paused_list": [],
|
||||
"pending_items": pending_items,
|
||||
}
|
||||
|
||||
async def list_paused_sessions(self, employee_id: str) -> List[Dict[str, Any]]:
|
||||
"""获取员工的暂停会话列表。
|
||||
|
||||
Args:
|
||||
employee_id: 员工ID
|
||||
Returns:
|
||||
List[Dict]: 暂停会话列表项(含暂停时长)
|
||||
"""
|
||||
stmt = select(AutoSession).where(
|
||||
AutoSession.employee_id == employee_id,
|
||||
AutoSession.status == "paused",
|
||||
).order_by(AutoSession.paused_at.desc())
|
||||
sessions = list((await self.db.execute(stmt)).scalars().all())
|
||||
|
||||
now = datetime.now(timezone.utc)
|
||||
result: List[Dict[str, Any]] = []
|
||||
for s in sessions:
|
||||
paused_at = s.paused_at or s.updated_at
|
||||
duration = self._format_duration(paused_at, now) if paused_at else ""
|
||||
result.append({
|
||||
"session_id": s.id,
|
||||
"title": s.title,
|
||||
"scenario_key": s.scenario_key,
|
||||
"paused_at": paused_at.isoformat() if paused_at else None,
|
||||
"paused_duration": duration,
|
||||
})
|
||||
return result
|
||||
|
||||
async def correct_info(
|
||||
self,
|
||||
session_id: str,
|
||||
field: str,
|
||||
new_value: str,
|
||||
old_value: Optional[str] = None,
|
||||
) -> InformationItem:
|
||||
"""信息更正。
|
||||
|
||||
委托 InformationItemService.correct_value(),检查下游影响,
|
||||
推送 WS 更正事件。
|
||||
|
||||
Args:
|
||||
session_id: 会话ID
|
||||
field: 更正的字段名
|
||||
new_value: 新值
|
||||
old_value: 旧值(可选)
|
||||
Returns:
|
||||
InformationItem: 更正后的信息项
|
||||
"""
|
||||
info_svc = InformationItemService(self.db, self.redis)
|
||||
item = await info_svc.correct_value(session_id, field, new_value, old_value)
|
||||
|
||||
# 检查下游影响
|
||||
has_impact = await info_svc.check_downstream_impact(session_id, field)
|
||||
if has_impact:
|
||||
logger.info(f"更正影响下游动作: session={session_id} field={field}")
|
||||
# TODO: 重新校验映射/动作计划(后续阶段实现)
|
||||
|
||||
# 推送 WS 更正事件
|
||||
actual_old_value = old_value
|
||||
if not actual_old_value and item.update_history:
|
||||
actual_old_value = item.update_history[-1].get("old_value", "")
|
||||
await publish_info_corrected(
|
||||
session_id=session_id,
|
||||
field=field,
|
||||
old_value=actual_old_value or "",
|
||||
new_value=new_value,
|
||||
version=item.version,
|
||||
)
|
||||
|
||||
logger.info(f"更正信息: session={session_id} field={field} v={item.version}")
|
||||
return item
|
||||
|
||||
async def supplement_info(
|
||||
self,
|
||||
session_id: str,
|
||||
field: str,
|
||||
value: str,
|
||||
) -> InformationItem:
|
||||
"""信息补充。
|
||||
|
||||
委托 InformationItemService.supplement_value(),推送 WS 补充事件。
|
||||
|
||||
Args:
|
||||
session_id: 会话ID
|
||||
field: 补充的字段名
|
||||
value: 补充值
|
||||
Returns:
|
||||
InformationItem: 补充后的信息项
|
||||
"""
|
||||
info_svc = InformationItemService(self.db, self.redis)
|
||||
item = await info_svc.supplement_value(session_id, field, value)
|
||||
|
||||
# 推送 WS 补充事件
|
||||
await publish_info_supplemented(
|
||||
session_id=session_id,
|
||||
field=field,
|
||||
supplement_value=value,
|
||||
new_value=item.value,
|
||||
)
|
||||
|
||||
logger.info(f"补充信息: session={session_id} field={field} v={item.version}")
|
||||
return item
|
||||
|
||||
async def agent_resume(
|
||||
self,
|
||||
session_id: str,
|
||||
agent_id: str,
|
||||
note: Optional[str] = None,
|
||||
) -> AutoSession:
|
||||
"""坐席代恢复暂停会话。
|
||||
|
||||
无需员工授权,恢复后标记 closed_by = "agent:{agent_id}(resume)"。
|
||||
|
||||
Args:
|
||||
session_id: 会话ID
|
||||
agent_id: 坐席ID
|
||||
note: 备注
|
||||
Returns:
|
||||
AutoSession: 恢复后的会话
|
||||
Raises:
|
||||
AutomationException: 会话不存在或不可恢复
|
||||
"""
|
||||
session = await self.get_session(session_id)
|
||||
if session is None:
|
||||
raise AutomationException(AutomationErrorCode.SESSION_NOT_FOUND)
|
||||
|
||||
if session.status != "paused":
|
||||
raise AutomationException(AutomationErrorCode.SESSION_NOT_RESUMABLE)
|
||||
|
||||
# 从 Redis 加载恢复点
|
||||
resume_point = await self._load_resume_point(session_id)
|
||||
|
||||
now = datetime.now(timezone.utc)
|
||||
session.status = "running"
|
||||
session.paused_at = None
|
||||
session.agent_id = agent_id
|
||||
session.closed_by = f"agent:{agent_id}(resume)"
|
||||
await self.db.flush()
|
||||
|
||||
# Redis 清理
|
||||
if self.redis:
|
||||
try:
|
||||
resume_key = REDIS_KEY_RESUME_POINT.format(session_id=session_id)
|
||||
await self.redis.delete(resume_key)
|
||||
paused_key = REDIS_KEY_PAUSED_SESSIONS.format(
|
||||
employee_id=session.employee_id
|
||||
)
|
||||
await self.redis.srem(paused_key, session_id)
|
||||
except Exception as e: # noqa: BLE001
|
||||
logger.warning(f"Redis 清理恢复点失败 session={session_id}: {e}")
|
||||
|
||||
# 推送 WS 恢复事件
|
||||
current_step = resume_point.get("step_desc", "") if resume_point else ""
|
||||
await publish_resumed(
|
||||
session_id=session_id,
|
||||
title=session.title,
|
||||
resumed_at=now.isoformat(),
|
||||
current_step=f"[坐席代恢复] {current_step}",
|
||||
)
|
||||
|
||||
# 续行执行
|
||||
asyncio.create_task(self._run_executor(session_id))
|
||||
|
||||
logger.info(f"坐席代恢复: session={session_id} agent={agent_id} note={note or ''}")
|
||||
return session
|
||||
|
||||
async def agent_close(
|
||||
self,
|
||||
session_id: str,
|
||||
agent_id: str,
|
||||
note: Optional[str] = None,
|
||||
) -> AutoSession:
|
||||
"""坐席手动关闭暂停会话。
|
||||
|
||||
Args:
|
||||
session_id: 会话ID
|
||||
agent_id: 坐席ID
|
||||
note: 备注
|
||||
Returns:
|
||||
AutoSession: 关闭后的会话
|
||||
Raises:
|
||||
AutomationException: 会话不存在或不可关闭
|
||||
"""
|
||||
session = await self.get_session(session_id)
|
||||
if session is None:
|
||||
raise AutomationException(AutomationErrorCode.SESSION_NOT_FOUND)
|
||||
|
||||
if session.status != "paused":
|
||||
raise AutomationException(
|
||||
AutomationErrorCode.SESSION_NOT_RESUMABLE,
|
||||
"仅暂停状态的会话可由坐席关闭",
|
||||
)
|
||||
|
||||
now = datetime.now(timezone.utc)
|
||||
session.status = "closed"
|
||||
session.closed_by = f"agent:{agent_id}(close)"
|
||||
session.agent_id = agent_id
|
||||
await self.db.flush()
|
||||
|
||||
# Redis 清理
|
||||
if self.redis:
|
||||
try:
|
||||
resume_key = REDIS_KEY_RESUME_POINT.format(session_id=session_id)
|
||||
await self.redis.delete(resume_key)
|
||||
paused_key = REDIS_KEY_PAUSED_SESSIONS.format(
|
||||
employee_id=session.employee_id
|
||||
)
|
||||
await self.redis.srem(paused_key, session_id)
|
||||
except Exception as e: # noqa: BLE001
|
||||
logger.warning(f"Redis 清理恢复点失败 session={session_id}: {e}")
|
||||
|
||||
logger.info(f"坐席关闭会话: session={session_id} agent={agent_id} note={note or ''}")
|
||||
return session
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# 复杂场景重构:内部辅助方法
|
||||
# --------------------------------------------------------------------------
|
||||
async def _load_resume_point(self, session_id: str) -> Optional[Dict[str, Any]]:
|
||||
"""从 Redis 加载恢复点快照。"""
|
||||
if not self.redis:
|
||||
return None
|
||||
try:
|
||||
import json
|
||||
resume_key = REDIS_KEY_RESUME_POINT.format(session_id=session_id)
|
||||
raw = await self.redis.get(resume_key)
|
||||
if raw:
|
||||
return json.loads(raw)
|
||||
except Exception as e: # noqa: BLE001
|
||||
logger.warning(f"加载恢复点失败 session={session_id}: {e}")
|
||||
return None
|
||||
|
||||
async def _get_current_step_desc(self, session: AutoSession) -> str:
|
||||
"""获取当前步骤描述(用于恢复点)。"""
|
||||
if not session.current_action_id:
|
||||
return "等待开始处置"
|
||||
stmt = select(AutoAction).where(AutoAction.id == session.current_action_id)
|
||||
action = (await self.db.execute(stmt)).scalar_one_or_none()
|
||||
if action:
|
||||
return f"当前步骤:{action.title}({action.status})"
|
||||
return "处置进行中"
|
||||
|
||||
def _is_awaiting_approval(self, session: AutoSession) -> bool:
|
||||
"""检查会话是否有待审批动作。"""
|
||||
# 通过 current_action_id 和 status 间接判断
|
||||
return session.current_action_id is not None and session.status == "paused"
|
||||
|
||||
@staticmethod
|
||||
def _format_duration(start: datetime, end: datetime) -> str:
|
||||
"""格式化时长为人类可读字符串(如 "2h 15min")。"""
|
||||
# SQLite 读取的 datetime 可能是 timezone-naive,统一补上 UTC 时区后再相减
|
||||
if start.tzinfo is None:
|
||||
start = start.replace(tzinfo=timezone.utc)
|
||||
if end.tzinfo is None:
|
||||
end = end.replace(tzinfo=timezone.utc)
|
||||
delta = end - start
|
||||
total_seconds = int(delta.total_seconds())
|
||||
if total_seconds < 0:
|
||||
return ""
|
||||
hours = total_seconds // 3600
|
||||
minutes = (total_seconds % 3600) // 60
|
||||
if hours > 0:
|
||||
return f"{hours}h {minutes}min"
|
||||
return f"{minutes}min"
|
||||
|
||||
async def _run_executor(self, session_id: str) -> None:
|
||||
"""在独立 DB 会话中运行执行器(续行)。"""
|
||||
factory = _get_session_factory()
|
||||
async with factory() as db:
|
||||
svc = AutoSessionService(db, self.redis)
|
||||
executor = ActionExecutor(db, self.redis, audit=self.audit)
|
||||
try:
|
||||
await executor.run(session_id)
|
||||
await db.commit()
|
||||
except Exception as e: # noqa: BLE001
|
||||
await db.rollback()
|
||||
logger.error(f"续行执行失败 session={session_id}: {e}")
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# 场景配置管理(管理端)
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
@@ -0,0 +1,210 @@
|
||||
"""
|
||||
快照管理服务 — P3 核心组件。
|
||||
|
||||
在每次更正发生前创建信息项快照,支持更正撤销(undo)。
|
||||
撤销限制:最多撤销最近5次。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.config import settings
|
||||
from app.models.automation import InformationItem, InformationSnapshot
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
MAX_UNDO_COUNT = getattr(settings, "max_undo_count", 5)
|
||||
|
||||
|
||||
class SnapshotService:
|
||||
"""信息项快照管理服务。"""
|
||||
|
||||
def __init__(self, db: AsyncSession):
|
||||
self.db = db
|
||||
|
||||
async def create_snapshot(
|
||||
self,
|
||||
session_id: str,
|
||||
trigger_item_key: str,
|
||||
correction_ids: List[str],
|
||||
) -> InformationSnapshot:
|
||||
"""在更正前创建快照,记录当前全部信息项状态。
|
||||
|
||||
Args:
|
||||
session_id: 会话ID
|
||||
trigger_item_key: 触发更正的信息项 key
|
||||
correction_ids: 本次更正涉及的信息项 ID 列表
|
||||
Returns:
|
||||
InformationSnapshot: 创建的快照记录
|
||||
"""
|
||||
# 获取当前所有信息项
|
||||
items = await self._get_items(session_id)
|
||||
|
||||
# 构建快照数据
|
||||
snapshot_data = {}
|
||||
for item in items:
|
||||
snapshot_data[item.name] = {
|
||||
"value": item.value,
|
||||
"version": item.version,
|
||||
"id": item.id,
|
||||
}
|
||||
|
||||
snapshot = InformationSnapshot(
|
||||
session_id=session_id,
|
||||
trigger_item_key=trigger_item_key,
|
||||
snapshot_data=snapshot_data,
|
||||
correction_ids=correction_ids,
|
||||
is_undone=False,
|
||||
)
|
||||
self.db.add(snapshot)
|
||||
await self.db.flush()
|
||||
|
||||
logger.info(
|
||||
f"创建快照: session={session_id} trigger={trigger_item_key} "
|
||||
f"items={len(snapshot_data)}"
|
||||
)
|
||||
return snapshot
|
||||
|
||||
async def undo_correction(self, session_id: str) -> dict:
|
||||
"""撤销最近一次更正。
|
||||
|
||||
Returns:
|
||||
dict: {
|
||||
"undone_items": List[str], # 被回滚的信息项名称
|
||||
"restored_values": Dict[str, str], # 恢复的值
|
||||
"snapshot_id": int,
|
||||
}
|
||||
Raises:
|
||||
ValueError: 无可撤销快照 或 撤销次数超限
|
||||
"""
|
||||
# 检查撤销次数
|
||||
undone_count = await self._count_undone(session_id)
|
||||
if undone_count >= MAX_UNDO_COUNT:
|
||||
raise ValueError(
|
||||
f"撤销次数超限,最多可撤销{MAX_UNDO_COUNT}次更正"
|
||||
)
|
||||
|
||||
# 获取最近一条未撤销的快照
|
||||
snapshot = await self.get_latest_snapshot(session_id)
|
||||
if snapshot is None:
|
||||
raise ValueError("无可撤销的更正")
|
||||
|
||||
# 回滚信息项
|
||||
undone_items = []
|
||||
restored_values = {}
|
||||
for item_name, item_data in snapshot.snapshot_data.items():
|
||||
item = await self._get_item(session_id, item_name)
|
||||
if item is not None:
|
||||
old_value = item.value
|
||||
item.value = item_data["value"]
|
||||
item.version = item_data["version"]
|
||||
undone_items.append(item_name)
|
||||
restored_values[item_name] = item_data["value"]
|
||||
|
||||
logger.info(
|
||||
f"撤销回滚: session={session_id} item={item_name} "
|
||||
f"value={old_value}->{item.value}"
|
||||
)
|
||||
|
||||
# 标记快照为已撤销
|
||||
snapshot.is_undone = True
|
||||
await self.db.flush()
|
||||
|
||||
logger.info(
|
||||
f"撤销完成: session={session_id} snapshot={snapshot.id} "
|
||||
f"items={undone_items}"
|
||||
)
|
||||
|
||||
return {
|
||||
"undone_items": undone_items,
|
||||
"restored_values": restored_values,
|
||||
"snapshot_id": snapshot.id,
|
||||
}
|
||||
|
||||
async def get_latest_snapshot(
|
||||
self, session_id: str
|
||||
) -> Optional[InformationSnapshot]:
|
||||
"""获取最近一条未撤销的快照。"""
|
||||
stmt = (
|
||||
select(InformationSnapshot)
|
||||
.where(
|
||||
InformationSnapshot.session_id == session_id,
|
||||
InformationSnapshot.is_undone == False, # noqa: E712
|
||||
)
|
||||
.order_by(InformationSnapshot.created_at.desc())
|
||||
.limit(1)
|
||||
)
|
||||
return (await self.db.execute(stmt)).scalar_one_or_none()
|
||||
|
||||
async def get_snapshot_history(
|
||||
self, session_id: str
|
||||
) -> List[InformationSnapshot]:
|
||||
"""获取快照历史列表。"""
|
||||
stmt = (
|
||||
select(InformationSnapshot)
|
||||
.where(InformationSnapshot.session_id == session_id)
|
||||
.order_by(InformationSnapshot.created_at.desc())
|
||||
)
|
||||
return list((await self.db.execute(stmt)).scalars().all())
|
||||
|
||||
async def get_version_diff(
|
||||
self, session_id: str, item_name: str, v1: int, v2: int
|
||||
) -> dict:
|
||||
"""对比某个信息项的两个版本。
|
||||
|
||||
从 update_history 中提取指定版本号的值进行对比。
|
||||
"""
|
||||
item = await self._get_item(session_id, item_name)
|
||||
if item is None:
|
||||
raise ValueError(f"信息项 {item_name} 不存在")
|
||||
|
||||
# 从 update_history 中找到对应版本
|
||||
history = {h["version"]: h for h in (item.update_history or [])}
|
||||
|
||||
v1_data = history.get(v1, {})
|
||||
v2_data = history.get(v2, {})
|
||||
|
||||
v1_value = v1_data.get("new_value", item.value if v1 == item.version else "")
|
||||
v2_value = v2_data.get("new_value", item.value if v2 == item.version else "")
|
||||
|
||||
return {
|
||||
"item_key": item_name,
|
||||
"v1": v1,
|
||||
"v1_value": v1_value,
|
||||
"v2": v2,
|
||||
"v2_value": v2_value,
|
||||
"changed": v1_value != v2_value,
|
||||
}
|
||||
|
||||
async def _get_items(self, session_id: str) -> List[InformationItem]:
|
||||
"""获取会话下所有信息项。"""
|
||||
stmt = select(InformationItem).where(
|
||||
InformationItem.session_id == session_id
|
||||
)
|
||||
return list((await self.db.execute(stmt)).scalars().all())
|
||||
|
||||
async def _get_item(
|
||||
self, session_id: str, name: str
|
||||
) -> Optional[InformationItem]:
|
||||
"""按名称获取单个信息项。"""
|
||||
stmt = select(InformationItem).where(
|
||||
InformationItem.session_id == session_id,
|
||||
InformationItem.name == name,
|
||||
)
|
||||
return (await self.db.execute(stmt)).scalar_one_or_none()
|
||||
|
||||
async def _count_undone(self, session_id: str) -> int:
|
||||
"""统计已撤销的快照数量。"""
|
||||
stmt = (
|
||||
select(func.count(InformationSnapshot.id))
|
||||
.where(
|
||||
InformationSnapshot.session_id == session_id,
|
||||
InformationSnapshot.is_undone == True, # noqa: E712
|
||||
)
|
||||
)
|
||||
result = await self.db.execute(stmt)
|
||||
return result.scalar() or 0
|
||||
@@ -0,0 +1,129 @@
|
||||
# =============================================================================
|
||||
# 企微IT智能服务台 — 复杂场景重构 暂停超时清理定时任务
|
||||
# =============================================================================
|
||||
# 说明:后台定时任务,扫描 paused 状态且超过 24 小时未恢复的会话,
|
||||
# 自动标记为 closed(closed_by = "system(timeout)"),
|
||||
# 并清理 Redis 恢复点与暂停会话集合,推送超时关闭 WS 事件。
|
||||
#
|
||||
# 调用方式:
|
||||
# 1. FastAPI lifespan 中 asyncio.create_task(TimeoutCleaner(...).run_scheduled())
|
||||
# 2. 或外部调度器定期调用 run_once()
|
||||
# =============================================================================
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.constants import (
|
||||
PAUSE_TIMEOUT_HOURS,
|
||||
REDIS_KEY_PAUSED_SESSIONS,
|
||||
REDIS_KEY_RESUME_POINT,
|
||||
)
|
||||
from app.models.automation import AutoSession
|
||||
from app.services.automation.progress_publisher import publish_timeout_closed
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class TimeoutCleaner:
|
||||
"""暂停超时清理器。
|
||||
|
||||
扫描 paused 状态的会话,超过 PAUSE_TIMEOUT_HOURS(默认 24 小时)
|
||||
未恢复的会话自动关闭,并推送 WS 通知。
|
||||
"""
|
||||
|
||||
def __init__(self, db_factory: Any, redis: Any = None):
|
||||
"""初始化。
|
||||
|
||||
Args:
|
||||
db_factory: 异步 DB 会话工厂(如 app.database._get_session_factory())
|
||||
redis: Redis 客户端(可选,用于清理恢复点)
|
||||
"""
|
||||
self.db_factory = db_factory
|
||||
self.redis = redis
|
||||
|
||||
async def run_once(self) -> int:
|
||||
"""执行一次扫描,返回关闭的会话数。
|
||||
|
||||
Returns:
|
||||
int: 本次扫描关闭的会话数量
|
||||
"""
|
||||
closed_count = 0
|
||||
async with self.db_factory() as db:
|
||||
# 查询超时的 paused 会话(paused_at < cutoff 隐含 NOT NULL)
|
||||
cutoff = datetime.now(timezone.utc) - timedelta(hours=PAUSE_TIMEOUT_HOURS)
|
||||
stmt = select(AutoSession).where(
|
||||
AutoSession.status == "paused",
|
||||
AutoSession.paused_at < cutoff,
|
||||
)
|
||||
sessions = list((await db.execute(stmt)).scalars().all())
|
||||
|
||||
if not sessions:
|
||||
return 0
|
||||
|
||||
for session in sessions:
|
||||
try:
|
||||
# 标记关闭
|
||||
session.status = "closed"
|
||||
session.closed_by = "system(timeout)"
|
||||
closed_at = datetime.now(timezone.utc)
|
||||
|
||||
# 清理 Redis 恢复点
|
||||
if self.redis:
|
||||
await self._cleanup_redis(session.id, session.employee_id)
|
||||
|
||||
await db.flush()
|
||||
closed_count += 1
|
||||
|
||||
# 推送 WS 超时关闭事件
|
||||
await publish_timeout_closed(
|
||||
session_id=session.id,
|
||||
closed_at=closed_at.isoformat(),
|
||||
reason=f"暂停超过 {PAUSE_TIMEOUT_HOURS} 小时未恢复,已自动关闭",
|
||||
)
|
||||
|
||||
logger.info(
|
||||
f"超时关闭会话: session={session.id} "
|
||||
f"paused_at={session.paused_at} closed_at={closed_at}"
|
||||
)
|
||||
except Exception as e: # noqa: BLE001
|
||||
logger.error(f"超时关闭会话失败 session={session.id}: {e}")
|
||||
|
||||
await db.commit()
|
||||
|
||||
if closed_count > 0:
|
||||
logger.info(f"超时清理完成: 共关闭 {closed_count} 个暂停会话")
|
||||
return closed_count
|
||||
|
||||
async def run_scheduled(self, interval: int = 3600) -> None:
|
||||
"""定时扫描(每小时一次)。
|
||||
|
||||
Args:
|
||||
interval: 扫描间隔(秒),默认 3600 = 1 小时
|
||||
"""
|
||||
logger.info(f"启动暂停超时清理定时任务,间隔 {interval} 秒")
|
||||
while True:
|
||||
try:
|
||||
await self.run_once()
|
||||
except Exception as e: # noqa: BLE001
|
||||
logger.error(f"超时清理定时任务异常: {e}")
|
||||
await asyncio.sleep(interval)
|
||||
|
||||
async def _cleanup_redis(self, session_id: str, employee_id: str) -> None:
|
||||
"""清理 Redis 中的恢复点和暂停会话集合。"""
|
||||
if not self.redis:
|
||||
return
|
||||
try:
|
||||
# 删除恢复点
|
||||
resume_key = REDIS_KEY_RESUME_POINT.format(session_id=session_id)
|
||||
await self.redis.delete(resume_key)
|
||||
# 从暂停会话集合中移除
|
||||
paused_key = REDIS_KEY_PAUSED_SESSIONS.format(employee_id=employee_id)
|
||||
await self.redis.srem(paused_key, session_id)
|
||||
except Exception as e: # noqa: BLE001
|
||||
logger.warning(f"Redis 清理失败 session={session_id}: {e}")
|
||||
Reference in New Issue
Block a user