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
@@ -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}")
# --------------------------------------------------------------------------
# 场景配置管理(管理端)
# --------------------------------------------------------------------------