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:
Simon
2026-07-11 23:13:10 +08:00
parent 3d152fc8eb
commit bea288e414
928 changed files with 85169 additions and 54205 deletions
@@ -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_historyvalue 覆盖,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 小时未恢复的会话,
# 自动标记为 closedclosed_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}")