2026-07-07 21:52:11 +08:00
|
|
|
|
# =============================================================================
|
|
|
|
|
|
# 企微IT智能服务台 — 阶段5 自动化闭环 API
|
|
|
|
|
|
# =============================================================================
|
|
|
|
|
|
# 说明:提供自动化会话的 REST 接口与专用 WebSocket 通道。
|
|
|
|
|
|
# 前缀(经 Vite/ nginx 剥离 /api 后):/itportal/automation
|
|
|
|
|
|
#
|
|
|
|
|
|
# 坐席端(agent):
|
|
|
|
|
|
# POST /itportal/automation/sessions — 创建并启动会话
|
|
|
|
|
|
# GET /itportal/automation/sessions — 会话列表
|
|
|
|
|
|
# GET /itportal/automation/sessions/{id} — 会话详情
|
|
|
|
|
|
# POST /itportal/automation/sessions/{id}/approve — 坐席审批/驳回
|
|
|
|
|
|
# POST /itportal/automation/sessions/{id}/takeover — 转人工接管
|
|
|
|
|
|
#
|
|
|
|
|
|
# 员工端(H5):
|
|
|
|
|
|
# POST /itportal/automation/sessions/by-employee — 员工创建会话
|
|
|
|
|
|
# GET /itportal/automation/sessions/{id}/employee — 员工查看详情
|
|
|
|
|
|
# POST /itportal/automation/sessions/{id}/confirm — 员工 H5 二次确认
|
|
|
|
|
|
# POST /itportal/automation/sessions/{id}/feedback — 员工结果反馈
|
|
|
|
|
|
#
|
|
|
|
|
|
# 管理端(admin,配置写需 OTP):
|
|
|
|
|
|
# GET /itportal/automation/admin/scenarios — 场景配置列表
|
|
|
|
|
|
# PUT /itportal/automation/admin/scenarios/{key} — 更新场景配置(OTP)
|
|
|
|
|
|
# GET /itportal/automation/admin/rule-versions — 规则版本列表
|
|
|
|
|
|
# GET /itportal/automation/admin/metrics — 看板指标
|
|
|
|
|
|
#
|
|
|
|
|
|
# WebSocket:
|
|
|
|
|
|
# /ws/automation/{session_id} — 自动化进度/审批/确认实时推送
|
|
|
|
|
|
# =============================================================================
|
|
|
|
|
|
|
|
|
|
|
|
from __future__ import annotations
|
|
|
|
|
|
|
|
|
|
|
|
import asyncio
|
|
|
|
|
|
import logging
|
|
|
|
|
|
from typing import Optional
|
|
|
|
|
|
|
|
|
|
|
|
from fastapi import APIRouter, Depends, Query, WebSocket, WebSocketDisconnect
|
|
|
|
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
|
|
|
|
|
|
|
|
|
|
from app.database import get_db
|
|
|
|
|
|
from app.dependencies import require_high_risk_otp
|
|
|
|
|
|
from app.dependencies.automation import get_current_employee_id
|
|
|
|
|
|
from app.api.agents import get_current_agent
|
|
|
|
|
|
from app.models.agent import Agent
|
|
|
|
|
|
from app.schemas.automation import (
|
|
|
|
|
|
CreateSessionRequest,
|
2026-07-09 11:47:16 +08:00
|
|
|
|
ResolveFeedbackRequest,
|
2026-07-07 21:52:11 +08:00
|
|
|
|
ScenarioConfigResponse,
|
|
|
|
|
|
ScenarioConfigUpdate,
|
|
|
|
|
|
SessionResponse,
|
|
|
|
|
|
ApprovalDecisionRequest,
|
|
|
|
|
|
ConfirmRequest,
|
|
|
|
|
|
RuleVersionResponse,
|
|
|
|
|
|
ResolveFeedbackRequest,
|
|
|
|
|
|
TakeoverRequest,
|
|
|
|
|
|
AutoMetricsResponse,
|
|
|
|
|
|
serialize_action,
|
|
|
|
|
|
serialize_approval,
|
2026-07-11 23:13:10 +08:00
|
|
|
|
serialize_information_item,
|
2026-07-07 21:52:11 +08:00
|
|
|
|
serialize_session,
|
2026-07-11 23:13:10 +08:00
|
|
|
|
PauseRequest,
|
|
|
|
|
|
ResumeRequest,
|
|
|
|
|
|
CorrectRequest,
|
|
|
|
|
|
SupplementRequest,
|
|
|
|
|
|
AgentResumeRequest,
|
|
|
|
|
|
AgentCloseRequest,
|
|
|
|
|
|
BatchCorrectRequest,
|
|
|
|
|
|
BatchCorrectResponse,
|
|
|
|
|
|
UndoCorrectionResponse,
|
|
|
|
|
|
CorrectionHistoryResponse,
|
|
|
|
|
|
VersionChainResponse,
|
|
|
|
|
|
VersionDiffResponse,
|
|
|
|
|
|
CompressionLogItem,
|
|
|
|
|
|
CompressionLogListResponse,
|
|
|
|
|
|
VersionDiffRequest,
|
2026-07-07 21:52:11 +08:00
|
|
|
|
)
|
|
|
|
|
|
from app.services.automation import (
|
|
|
|
|
|
ActionExecutor,
|
|
|
|
|
|
AutoSessionService,
|
|
|
|
|
|
AutomationException,
|
2026-07-11 23:13:10 +08:00
|
|
|
|
InformationItemService,
|
2026-07-07 21:52:11 +08:00
|
|
|
|
to_app_exception,
|
|
|
|
|
|
)
|
2026-07-11 23:13:10 +08:00
|
|
|
|
from app.services.automation.correction_service import CorrectionService
|
|
|
|
|
|
from app.services.automation.snapshot_service import SnapshotService
|
|
|
|
|
|
from app.services.automation.context_compressor import ContextCompressor
|
|
|
|
|
|
from app.models.automation import ContextCompression
|
2026-07-07 21:52:11 +08:00
|
|
|
|
from app.services.automation.progress_publisher import (
|
|
|
|
|
|
register_ws,
|
|
|
|
|
|
set_parties,
|
|
|
|
|
|
unregister_ws,
|
|
|
|
|
|
)
|
|
|
|
|
|
from app.services.cache_service import cache_service
|
|
|
|
|
|
from app.utils.response import AppException, success_response
|
|
|
|
|
|
|
|
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
|
|
|
|
|
|
# --------------------------------------------------------------------------
|
|
|
|
|
|
# REST 路由器(前缀 /itportal/automation,经 /api 代理剥离)
|
|
|
|
|
|
# --------------------------------------------------------------------------
|
|
|
|
|
|
router = APIRouter(prefix="/itportal/automation")
|
|
|
|
|
|
|
|
|
|
|
|
# --------------------------------------------------------------------------
|
|
|
|
|
|
# WebSocket 路由器(根路径 /ws/automation/{session_id})
|
|
|
|
|
|
# --------------------------------------------------------------------------
|
|
|
|
|
|
ws_router = APIRouter()
|
|
|
|
|
|
|
|
|
|
|
|
# WS 认证失败关闭码(与 ws.py 保持一致)
|
|
|
|
|
|
WS_CLOSE_UNAUTHORIZED = 4001
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ==========================================================================
|
|
|
|
|
|
# 坐席端接口
|
|
|
|
|
|
# ==========================================================================
|
|
|
|
|
|
@router.post("/sessions", tags=["自动化闭环"])
|
|
|
|
|
|
async def create_session(
|
|
|
|
|
|
req: CreateSessionRequest,
|
|
|
|
|
|
db: AsyncSession = Depends(get_db),
|
|
|
|
|
|
current_agent: Agent = Depends(get_current_agent),
|
|
|
|
|
|
):
|
|
|
|
|
|
"""坐席创建自动化会话并启动后台编排。"""
|
|
|
|
|
|
svc = AutoSessionService(db)
|
|
|
|
|
|
session = await svc.create_session(
|
|
|
|
|
|
conversation_id=req.conversation_id,
|
|
|
|
|
|
employee_id=req.employee_id,
|
|
|
|
|
|
description=req.description,
|
|
|
|
|
|
mode=req.mode,
|
|
|
|
|
|
)
|
|
|
|
|
|
await db.flush()
|
|
|
|
|
|
# 记录参与方,供进度兜底推送
|
|
|
|
|
|
set_parties(session.id, employee_id=req.employee_id)
|
|
|
|
|
|
await db.commit()
|
|
|
|
|
|
|
|
|
|
|
|
# 后台运行编排(不阻塞响应)
|
|
|
|
|
|
asyncio.create_task(_run_background(session.id))
|
|
|
|
|
|
|
|
|
|
|
|
data = serialize_session(session)
|
|
|
|
|
|
return success_response(data.model_dump() if hasattr(data, "model_dump") else data.__dict__)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@router.get("/sessions", tags=["自动化闭环"])
|
|
|
|
|
|
async def list_sessions(
|
|
|
|
|
|
employee_id: Optional[str] = Query(None),
|
|
|
|
|
|
status: Optional[str] = Query(None),
|
|
|
|
|
|
page: int = Query(1, ge=1),
|
|
|
|
|
|
page_size: int = Query(50, ge=1, le=200),
|
|
|
|
|
|
db: AsyncSession = Depends(get_db),
|
|
|
|
|
|
_agent: Agent = Depends(get_current_agent),
|
|
|
|
|
|
):
|
|
|
|
|
|
"""坐席查看自动化会话列表。"""
|
|
|
|
|
|
svc = AutoSessionService(db)
|
|
|
|
|
|
sessions = await svc.list_sessions(
|
|
|
|
|
|
employee_id=employee_id, status=status, page=page, page_size=page_size
|
|
|
|
|
|
)
|
|
|
|
|
|
return success_response([_session_min(s) for s in sessions])
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-07-11 23:13:10 +08:00
|
|
|
|
# --- 复杂场景重构:暂停会话相关端点(必须在 /sessions/{session_id} 之前注册)---
|
|
|
|
|
|
|
|
|
|
|
|
@router.get("/sessions/paused", tags=["自动化闭环"])
|
|
|
|
|
|
async def list_paused_sessions(
|
|
|
|
|
|
employee_id: str = Query(..., description="员工ID"),
|
|
|
|
|
|
db: AsyncSession = Depends(get_db),
|
|
|
|
|
|
_agent: Agent = Depends(get_current_agent),
|
|
|
|
|
|
):
|
|
|
|
|
|
"""获取员工的暂停会话列表。"""
|
|
|
|
|
|
svc = AutoSessionService(db)
|
|
|
|
|
|
paused_list = await svc.list_paused_sessions(employee_id)
|
|
|
|
|
|
return success_response(paused_list)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@router.post("/sessions/resume", tags=["自动化闭环"])
|
|
|
|
|
|
async def resume_session_select(
|
|
|
|
|
|
req: ResumeRequest,
|
|
|
|
|
|
db: AsyncSession = Depends(get_db),
|
|
|
|
|
|
employee_id: str = Depends(get_current_employee_id),
|
|
|
|
|
|
):
|
|
|
|
|
|
"""恢复暂停会话(员工端,支持多任务选择)。
|
|
|
|
|
|
|
|
|
|
|
|
若未指定 session_id 且存在多个暂停会话,返回列表供前端选择。
|
|
|
|
|
|
"""
|
|
|
|
|
|
svc = AutoSessionService(db)
|
|
|
|
|
|
try:
|
|
|
|
|
|
result = await svc.resume_session(employee_id, req.session_id)
|
|
|
|
|
|
except AutomationException as e:
|
|
|
|
|
|
raise to_app_exception(e)
|
|
|
|
|
|
await db.commit()
|
|
|
|
|
|
|
|
|
|
|
|
# 多任务选择场景
|
|
|
|
|
|
if result.get("need_select"):
|
|
|
|
|
|
return success_response({
|
|
|
|
|
|
"need_select": True,
|
|
|
|
|
|
"paused_list": result["paused_list"],
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
session = result["session"]
|
|
|
|
|
|
if session is None:
|
|
|
|
|
|
return success_response({"need_select": False, "session": None, "message": "没有暂停的任务"})
|
|
|
|
|
|
|
|
|
|
|
|
return success_response({
|
|
|
|
|
|
"need_select": False,
|
|
|
|
|
|
"session": serialize_session(session).__dict__,
|
|
|
|
|
|
"resume_point": result.get("resume_point"),
|
|
|
|
|
|
"pending_items": result.get("pending_items", []),
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-07-07 21:52:11 +08:00
|
|
|
|
@router.get("/sessions/{session_id}", tags=["自动化闭环"])
|
|
|
|
|
|
async def get_session(
|
|
|
|
|
|
session_id: str,
|
|
|
|
|
|
db: AsyncSession = Depends(get_db),
|
|
|
|
|
|
_agent: Agent = Depends(get_current_agent),
|
|
|
|
|
|
):
|
|
|
|
|
|
"""坐席查看会话详情。"""
|
|
|
|
|
|
svc = AutoSessionService(db)
|
|
|
|
|
|
detail = await svc.get_session_detail(session_id)
|
|
|
|
|
|
if detail is None:
|
|
|
|
|
|
raise AppException(4005, "自动化会话不存在")
|
|
|
|
|
|
return success_response(_detail_payload(detail))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@router.post("/sessions/{session_id}/approve", tags=["自动化闭环"])
|
|
|
|
|
|
async def approve_session(
|
|
|
|
|
|
session_id: str,
|
|
|
|
|
|
req: ApprovalDecisionRequest,
|
|
|
|
|
|
db: AsyncSession = Depends(get_db),
|
|
|
|
|
|
current_agent: Agent = Depends(get_current_agent),
|
|
|
|
|
|
):
|
|
|
|
|
|
"""坐席审批/驳回当前待决高危动作。"""
|
|
|
|
|
|
svc = AutoSessionService(db)
|
|
|
|
|
|
detail = await svc.get_session_detail(session_id)
|
|
|
|
|
|
if detail is None:
|
|
|
|
|
|
raise AppException(4005, "自动化会话不存在")
|
|
|
|
|
|
action_id = detail["session"].current_action_id
|
|
|
|
|
|
if not action_id or detail["ticket"] is None:
|
|
|
|
|
|
raise AppException(4004, "当前没有待审批的动作")
|
|
|
|
|
|
executor = ActionExecutor(db)
|
|
|
|
|
|
try:
|
|
|
|
|
|
await executor.resume(
|
|
|
|
|
|
session_id,
|
|
|
|
|
|
action_id,
|
|
|
|
|
|
decision=req.decision,
|
|
|
|
|
|
note=req.note,
|
|
|
|
|
|
approver_id=current_agent.user_id,
|
|
|
|
|
|
)
|
|
|
|
|
|
except AutomationException as e:
|
|
|
|
|
|
raise to_app_exception(e)
|
|
|
|
|
|
await db.commit()
|
|
|
|
|
|
return success_response(_detail_payload(await svc.get_session_detail(session_id)))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@router.post("/sessions/{session_id}/takeover", tags=["自动化闭环"])
|
|
|
|
|
|
async def takeover_session(
|
|
|
|
|
|
session_id: str,
|
|
|
|
|
|
req: TakeoverRequest,
|
|
|
|
|
|
db: AsyncSession = Depends(get_db),
|
|
|
|
|
|
current_agent: Agent = Depends(get_current_agent),
|
|
|
|
|
|
):
|
|
|
|
|
|
"""坐席转人工接管会话。"""
|
|
|
|
|
|
svc = AutoSessionService(db)
|
|
|
|
|
|
try:
|
|
|
|
|
|
session = await svc.takeover(
|
|
|
|
|
|
session_id, agent_id=current_agent.user_id, note=req.note
|
|
|
|
|
|
)
|
|
|
|
|
|
except AutomationException as e:
|
|
|
|
|
|
raise to_app_exception(e)
|
|
|
|
|
|
await db.commit()
|
|
|
|
|
|
return success_response(serialize_session(session).__dict__)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ==========================================================================
|
|
|
|
|
|
# 员工端(H5)接口
|
|
|
|
|
|
# ==========================================================================
|
|
|
|
|
|
@router.post("/sessions/by-employee", tags=["自动化闭环"])
|
|
|
|
|
|
async def create_session_by_employee(
|
|
|
|
|
|
req: CreateSessionRequest,
|
|
|
|
|
|
db: AsyncSession = Depends(get_db),
|
|
|
|
|
|
employee_id: str = Depends(get_current_employee_id),
|
|
|
|
|
|
):
|
|
|
|
|
|
"""员工(H5)创建自动化会话。"""
|
|
|
|
|
|
svc = AutoSessionService(db)
|
|
|
|
|
|
session = await svc.create_session(
|
|
|
|
|
|
conversation_id=req.conversation_id,
|
|
|
|
|
|
employee_id=employee_id,
|
|
|
|
|
|
description=req.description,
|
|
|
|
|
|
mode=req.mode,
|
|
|
|
|
|
)
|
|
|
|
|
|
await db.flush()
|
|
|
|
|
|
set_parties(session.id, employee_id=employee_id)
|
|
|
|
|
|
await db.commit()
|
|
|
|
|
|
asyncio.create_task(_run_background(session.id))
|
|
|
|
|
|
return success_response(serialize_session(session).__dict__)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@router.get("/sessions/{session_id}/employee", tags=["自动化闭环"])
|
|
|
|
|
|
async def get_session_employee(
|
|
|
|
|
|
session_id: str,
|
|
|
|
|
|
db: AsyncSession = Depends(get_db),
|
|
|
|
|
|
employee_id: str = Depends(get_current_employee_id),
|
|
|
|
|
|
):
|
|
|
|
|
|
"""员工(H5)查看自己会话详情。"""
|
|
|
|
|
|
svc = AutoSessionService(db)
|
|
|
|
|
|
detail = await svc.get_session_detail(session_id)
|
|
|
|
|
|
if detail is None or detail["session"].employee_id != employee_id:
|
|
|
|
|
|
raise AppException(4005, "自动化会话不存在")
|
|
|
|
|
|
return success_response(_detail_payload(detail))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@router.post("/sessions/{session_id}/confirm", tags=["自动化闭环"])
|
|
|
|
|
|
async def confirm_session(
|
|
|
|
|
|
session_id: str,
|
|
|
|
|
|
req: ConfirmRequest,
|
|
|
|
|
|
db: AsyncSession = Depends(get_db),
|
|
|
|
|
|
employee_id: str = Depends(get_current_employee_id),
|
|
|
|
|
|
):
|
|
|
|
|
|
"""员工 H5 二次确认(高危写操作)。"""
|
|
|
|
|
|
svc = AutoSessionService(db)
|
|
|
|
|
|
detail = await svc.get_session_detail(session_id)
|
|
|
|
|
|
if detail is None or detail["session"].employee_id != employee_id:
|
|
|
|
|
|
raise AppException(4005, "自动化会话不存在")
|
|
|
|
|
|
action_id = detail["session"].current_action_id
|
|
|
|
|
|
if not action_id or detail["ticket"] is None:
|
|
|
|
|
|
raise AppException(4004, "当前没有待确认的动作")
|
|
|
|
|
|
if detail["ticket"].channel != "h5":
|
|
|
|
|
|
raise AppException(4004, "该动作需坐席审批,员工无需确认")
|
|
|
|
|
|
executor = ActionExecutor(db)
|
|
|
|
|
|
try:
|
|
|
|
|
|
await executor.resume(
|
|
|
|
|
|
session_id,
|
|
|
|
|
|
action_id,
|
|
|
|
|
|
decision="approve" if req.confirmed else "reject",
|
|
|
|
|
|
note=req.note,
|
|
|
|
|
|
approver_id=employee_id,
|
|
|
|
|
|
)
|
|
|
|
|
|
except AutomationException as e:
|
|
|
|
|
|
raise to_app_exception(e)
|
|
|
|
|
|
await db.commit()
|
|
|
|
|
|
return success_response(_detail_payload(await svc.get_session_detail(session_id)))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@router.post("/sessions/{session_id}/feedback", tags=["自动化闭环"])
|
|
|
|
|
|
async def feedback_session(
|
|
|
|
|
|
session_id: str,
|
|
|
|
|
|
req: ResolveFeedbackRequest,
|
|
|
|
|
|
db: AsyncSession = Depends(get_db),
|
|
|
|
|
|
employee_id: str = Depends(get_current_employee_id),
|
|
|
|
|
|
):
|
|
|
|
|
|
"""员工对处置结果反馈(满意→关单 / 不满意→转人工)。"""
|
|
|
|
|
|
svc = AutoSessionService(db)
|
|
|
|
|
|
try:
|
|
|
|
|
|
session = await svc.resolve_feedback(
|
|
|
|
|
|
session_id, satisfied=req.satisfied, note=req.note
|
|
|
|
|
|
)
|
|
|
|
|
|
except AutomationException as e:
|
|
|
|
|
|
raise to_app_exception(e)
|
|
|
|
|
|
await db.commit()
|
|
|
|
|
|
return success_response(serialize_session(session).__dict__)
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-07-11 23:13:10 +08:00
|
|
|
|
# --- 复杂场景重构:员工端暂停/恢复/更正/补充/信息项端点 ---
|
|
|
|
|
|
|
|
|
|
|
|
@router.post("/sessions/{session_id}/pause", tags=["自动化闭环"])
|
|
|
|
|
|
async def pause_session(
|
|
|
|
|
|
session_id: str,
|
|
|
|
|
|
req: PauseRequest,
|
|
|
|
|
|
db: AsyncSession = Depends(get_db),
|
|
|
|
|
|
employee_id: str = Depends(get_current_employee_id),
|
|
|
|
|
|
):
|
|
|
|
|
|
"""暂停会话(员工/坐席均可)。"""
|
|
|
|
|
|
svc = AutoSessionService(db)
|
|
|
|
|
|
try:
|
|
|
|
|
|
session = await svc.pause_session(session_id, reason=req.reason)
|
|
|
|
|
|
except AutomationException as e:
|
|
|
|
|
|
raise to_app_exception(e)
|
|
|
|
|
|
await db.commit()
|
|
|
|
|
|
return success_response(serialize_session(session).__dict__)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@router.post("/sessions/{session_id}/resume", tags=["自动化闭环"])
|
|
|
|
|
|
async def resume_session_by_id(
|
|
|
|
|
|
session_id: str,
|
|
|
|
|
|
db: AsyncSession = Depends(get_db),
|
|
|
|
|
|
employee_id: str = Depends(get_current_employee_id),
|
|
|
|
|
|
):
|
|
|
|
|
|
"""恢复指定会话(员工端,选中某个暂停会话后调用)。"""
|
|
|
|
|
|
svc = AutoSessionService(db)
|
|
|
|
|
|
try:
|
|
|
|
|
|
result = await svc.resume_session(employee_id, session_id)
|
|
|
|
|
|
except AutomationException as e:
|
|
|
|
|
|
raise to_app_exception(e)
|
|
|
|
|
|
await db.commit()
|
|
|
|
|
|
session = result["session"]
|
|
|
|
|
|
return success_response({
|
|
|
|
|
|
"session": serialize_session(session).__dict__ if session else None,
|
|
|
|
|
|
"resume_point": result.get("resume_point"),
|
|
|
|
|
|
"pending_items": result.get("pending_items", []),
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@router.post("/sessions/{session_id}/correct", tags=["自动化闭环"])
|
|
|
|
|
|
async def correct_info(
|
|
|
|
|
|
session_id: str,
|
|
|
|
|
|
req: CorrectRequest,
|
|
|
|
|
|
db: AsyncSession = Depends(get_db),
|
|
|
|
|
|
employee_id: str = Depends(get_current_employee_id),
|
|
|
|
|
|
):
|
|
|
|
|
|
"""信息更正。"""
|
|
|
|
|
|
svc = AutoSessionService(db)
|
|
|
|
|
|
try:
|
|
|
|
|
|
item = await svc.correct_info(
|
|
|
|
|
|
session_id, req.field, req.new_value, req.old_value
|
|
|
|
|
|
)
|
|
|
|
|
|
except AutomationException as e:
|
|
|
|
|
|
raise to_app_exception(e)
|
|
|
|
|
|
await db.commit()
|
|
|
|
|
|
return success_response(serialize_information_item(item).__dict__)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@router.post("/sessions/{session_id}/supplement", tags=["自动化闭环"])
|
|
|
|
|
|
async def supplement_info(
|
|
|
|
|
|
session_id: str,
|
|
|
|
|
|
req: SupplementRequest,
|
|
|
|
|
|
db: AsyncSession = Depends(get_db),
|
|
|
|
|
|
employee_id: str = Depends(get_current_employee_id),
|
|
|
|
|
|
):
|
|
|
|
|
|
"""信息补充。"""
|
|
|
|
|
|
svc = AutoSessionService(db)
|
|
|
|
|
|
try:
|
|
|
|
|
|
item = await svc.supplement_info(session_id, req.field, req.value)
|
|
|
|
|
|
except AutomationException as e:
|
|
|
|
|
|
raise to_app_exception(e)
|
|
|
|
|
|
await db.commit()
|
|
|
|
|
|
return success_response(serialize_information_item(item).__dict__)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@router.get("/sessions/{session_id}/info-items", tags=["自动化闭环"])
|
|
|
|
|
|
async def get_info_items(
|
|
|
|
|
|
session_id: str,
|
|
|
|
|
|
db: AsyncSession = Depends(get_db),
|
|
|
|
|
|
_agent: Agent = Depends(get_current_agent),
|
|
|
|
|
|
):
|
|
|
|
|
|
"""获取会话信息项列表。"""
|
|
|
|
|
|
info_svc = InformationItemService(db)
|
|
|
|
|
|
items = await info_svc.get_items(session_id)
|
|
|
|
|
|
return success_response([serialize_information_item(item).__dict__ for item in items])
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# --- 复杂场景重构:坐席端代恢复/关闭端点 ---
|
|
|
|
|
|
|
|
|
|
|
|
@router.post("/sessions/{session_id}/agent-resume", tags=["自动化闭环"])
|
|
|
|
|
|
async def agent_resume_session(
|
|
|
|
|
|
session_id: str,
|
|
|
|
|
|
req: AgentResumeRequest,
|
|
|
|
|
|
db: AsyncSession = Depends(get_db),
|
|
|
|
|
|
current_agent: Agent = Depends(get_current_agent),
|
|
|
|
|
|
):
|
|
|
|
|
|
"""坐席代恢复暂停会话。"""
|
|
|
|
|
|
svc = AutoSessionService(db)
|
|
|
|
|
|
try:
|
|
|
|
|
|
session = await svc.agent_resume(
|
|
|
|
|
|
session_id, current_agent.user_id, req.note
|
|
|
|
|
|
)
|
|
|
|
|
|
except AutomationException as e:
|
|
|
|
|
|
raise to_app_exception(e)
|
|
|
|
|
|
await db.commit()
|
|
|
|
|
|
return success_response(serialize_session(session).__dict__)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@router.post("/sessions/{session_id}/agent-close", tags=["自动化闭环"])
|
|
|
|
|
|
async def agent_close_session(
|
|
|
|
|
|
session_id: str,
|
|
|
|
|
|
req: AgentCloseRequest,
|
|
|
|
|
|
db: AsyncSession = Depends(get_db),
|
|
|
|
|
|
current_agent: Agent = Depends(get_current_agent),
|
|
|
|
|
|
):
|
|
|
|
|
|
"""坐席关闭暂停会话。"""
|
|
|
|
|
|
svc = AutoSessionService(db)
|
|
|
|
|
|
try:
|
|
|
|
|
|
session = await svc.agent_close(
|
|
|
|
|
|
session_id, current_agent.user_id, req.note
|
|
|
|
|
|
)
|
|
|
|
|
|
except AutomationException as e:
|
|
|
|
|
|
raise to_app_exception(e)
|
|
|
|
|
|
await db.commit()
|
|
|
|
|
|
return success_response(serialize_session(session).__dict__)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# --- 复杂场景重构第二阶段:P2/P3 端点 ---
|
|
|
|
|
|
|
|
|
|
|
|
@router.post("/sessions/{session_id}/batch-correct", tags=["自动化闭环"])
|
|
|
|
|
|
async def batch_correct_info(
|
|
|
|
|
|
session_id: str,
|
|
|
|
|
|
req: BatchCorrectRequest,
|
|
|
|
|
|
db: AsyncSession = Depends(get_db),
|
|
|
|
|
|
employee_id: str = Depends(get_current_employee_id),
|
|
|
|
|
|
):
|
|
|
|
|
|
"""批量更正信息项(原子事务,失败整体回滚)。"""
|
|
|
|
|
|
svc = CorrectionService(db)
|
|
|
|
|
|
try:
|
|
|
|
|
|
result = await svc.batch_correct(
|
|
|
|
|
|
session_id, req.corrections, req.reason
|
|
|
|
|
|
)
|
|
|
|
|
|
except AutomationException:
|
|
|
|
|
|
raise to_app_exception(AutomationException(4019, "批量更正失败,已回滚"))
|
|
|
|
|
|
except Exception as e:
|
|
|
|
|
|
logger.error(f"批量更正失败: {e}")
|
|
|
|
|
|
raise to_app_exception(AutomationException(4019, f"批量更正失败: {e}"))
|
|
|
|
|
|
await db.commit()
|
|
|
|
|
|
return success_response(result.to_dict())
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@router.post("/sessions/{session_id}/undo-correction", tags=["自动化闭环"])
|
|
|
|
|
|
async def undo_correction(
|
|
|
|
|
|
session_id: str,
|
|
|
|
|
|
db: AsyncSession = Depends(get_db),
|
|
|
|
|
|
employee_id: str = Depends(get_current_employee_id),
|
|
|
|
|
|
):
|
|
|
|
|
|
"""撤销最近一次更正。"""
|
|
|
|
|
|
svc = CorrectionService(db)
|
|
|
|
|
|
try:
|
|
|
|
|
|
result = await svc.undo_correction(session_id)
|
|
|
|
|
|
except ValueError as e:
|
|
|
|
|
|
if "超限" in str(e):
|
|
|
|
|
|
raise to_app_exception(AutomationException(4018, str(e)))
|
|
|
|
|
|
raise AppException(4004, str(e))
|
|
|
|
|
|
await db.commit()
|
|
|
|
|
|
# 计算剩余撤销次数:查询当前已撤销的快照数量
|
|
|
|
|
|
from app.config import settings
|
|
|
|
|
|
from sqlalchemy import func, select as _select
|
|
|
|
|
|
from app.models.automation import InformationSnapshot
|
|
|
|
|
|
undone_stmt = (
|
|
|
|
|
|
_select(func.count(InformationSnapshot.id))
|
|
|
|
|
|
.where(
|
|
|
|
|
|
InformationSnapshot.session_id == session_id,
|
|
|
|
|
|
InformationSnapshot.is_undone == True, # noqa: E712
|
|
|
|
|
|
)
|
|
|
|
|
|
)
|
|
|
|
|
|
undone_count = (await db.execute(undone_stmt)).scalar() or 0
|
|
|
|
|
|
remaining = settings.max_undo_count - undone_count
|
|
|
|
|
|
result["remaining_undo_count"] = max(0, remaining)
|
|
|
|
|
|
return success_response(result)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@router.get("/sessions/{session_id}/correction-history", tags=["自动化闭环"])
|
|
|
|
|
|
async def get_correction_history(
|
|
|
|
|
|
session_id: str,
|
|
|
|
|
|
db: AsyncSession = Depends(get_db),
|
|
|
|
|
|
_agent: Agent = Depends(get_current_agent),
|
|
|
|
|
|
):
|
|
|
|
|
|
"""获取更正历史。"""
|
|
|
|
|
|
svc = CorrectionService(db)
|
|
|
|
|
|
history = await svc.get_correction_history(session_id)
|
|
|
|
|
|
return success_response({"history": history})
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@router.get("/sessions/{session_id}/version-chain/{item_name}", tags=["自动化闭环"])
|
|
|
|
|
|
async def get_version_chain(
|
|
|
|
|
|
session_id: str,
|
|
|
|
|
|
item_name: str,
|
|
|
|
|
|
db: AsyncSession = Depends(get_db),
|
|
|
|
|
|
_agent: Agent = Depends(get_current_agent),
|
|
|
|
|
|
):
|
|
|
|
|
|
"""获取信息项版本链。"""
|
|
|
|
|
|
svc = CorrectionService(db)
|
|
|
|
|
|
chain = await svc.get_version_chain(session_id, item_name)
|
|
|
|
|
|
return success_response({"item_key": item_name, "chain": chain})
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@router.post("/sessions/{session_id}/version-diff/{item_name}", tags=["自动化闭环"])
|
|
|
|
|
|
async def get_version_diff(
|
|
|
|
|
|
session_id: str,
|
|
|
|
|
|
item_name: str,
|
|
|
|
|
|
req: VersionDiffRequest,
|
|
|
|
|
|
db: AsyncSession = Depends(get_db),
|
|
|
|
|
|
_agent: Agent = Depends(get_current_agent),
|
|
|
|
|
|
):
|
|
|
|
|
|
"""版本对比。"""
|
|
|
|
|
|
svc = CorrectionService(db)
|
|
|
|
|
|
diff = await svc.get_version_diff(session_id, item_name, req.v1, req.v2)
|
|
|
|
|
|
return success_response(diff)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@router.get("/sessions/{session_id}/compression-logs", tags=["自动化闭环"])
|
|
|
|
|
|
async def get_compression_logs(
|
|
|
|
|
|
session_id: str,
|
|
|
|
|
|
page: int = Query(1, ge=1),
|
|
|
|
|
|
page_size: int = Query(20, ge=1, le=100),
|
|
|
|
|
|
db: AsyncSession = Depends(get_db),
|
|
|
|
|
|
_agent: Agent = Depends(get_current_agent),
|
|
|
|
|
|
):
|
|
|
|
|
|
"""获取上下文压缩日志。"""
|
|
|
|
|
|
from sqlalchemy import func, select
|
|
|
|
|
|
stmt = (
|
|
|
|
|
|
select(ContextCompression)
|
|
|
|
|
|
.where(ContextCompression.session_id == session_id)
|
|
|
|
|
|
.order_by(ContextCompression.created_at.desc())
|
|
|
|
|
|
.offset((page - 1) * page_size)
|
|
|
|
|
|
.limit(page_size)
|
|
|
|
|
|
)
|
|
|
|
|
|
result = await db.execute(stmt)
|
|
|
|
|
|
logs = result.scalars().all()
|
|
|
|
|
|
|
|
|
|
|
|
count_stmt = select(func.count()).select_from(ContextCompression).where(
|
|
|
|
|
|
ContextCompression.session_id == session_id
|
|
|
|
|
|
)
|
|
|
|
|
|
total = (await db.execute(count_stmt)).scalar() or 0
|
|
|
|
|
|
|
|
|
|
|
|
return success_response({
|
|
|
|
|
|
"logs": [
|
|
|
|
|
|
{
|
|
|
|
|
|
"id": log.id,
|
|
|
|
|
|
"session_id": log.session_id,
|
|
|
|
|
|
"tokens_before": log.tokens_before,
|
|
|
|
|
|
"tokens_after": log.tokens_after,
|
|
|
|
|
|
"compression_ratio": float(log.compression_ratio),
|
|
|
|
|
|
"task_node": log.task_node,
|
|
|
|
|
|
"duration_ms": log.duration_ms,
|
|
|
|
|
|
"compression_level": log.compression_level,
|
|
|
|
|
|
"summary": log.summary,
|
|
|
|
|
|
"created_at": log.created_at.isoformat() if log.created_at else None,
|
|
|
|
|
|
}
|
|
|
|
|
|
for log in logs
|
|
|
|
|
|
],
|
|
|
|
|
|
"total": total,
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-07-07 21:52:11 +08:00
|
|
|
|
# ==========================================================================
|
|
|
|
|
|
# 管理端接口(配置写需 OTP)
|
|
|
|
|
|
# ==========================================================================
|
|
|
|
|
|
@router.get("/admin/scenarios", tags=["自动化闭环-管理"])
|
|
|
|
|
|
async def list_scenarios(
|
|
|
|
|
|
db: AsyncSession = Depends(get_db),
|
|
|
|
|
|
):
|
|
|
|
|
|
"""场景配置列表(只读,无需 OTP)。"""
|
|
|
|
|
|
svc = AutoSessionService(db)
|
|
|
|
|
|
configs = await svc.list_scenario_configs()
|
|
|
|
|
|
return success_response([_scenario_payload(c) for c in configs])
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@router.put("/admin/scenarios/{scenario_key}", tags=["自动化闭环-管理"])
|
|
|
|
|
|
async def update_scenario(
|
|
|
|
|
|
scenario_key: str,
|
|
|
|
|
|
req: ScenarioConfigUpdate,
|
|
|
|
|
|
db: AsyncSession = Depends(get_db),
|
|
|
|
|
|
_otp: object = Depends(require_high_risk_otp),
|
|
|
|
|
|
):
|
|
|
|
|
|
"""更新场景配置(高危写操作,需 OTP)。"""
|
|
|
|
|
|
svc = AutoSessionService(db)
|
|
|
|
|
|
data = req.model_dump(exclude_unset=True)
|
|
|
|
|
|
config = await svc.upsert_scenario_config(scenario_key, data, operator="admin")
|
|
|
|
|
|
await db.commit()
|
|
|
|
|
|
return success_response(_scenario_payload(config))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@router.get("/admin/rule-versions", tags=["自动化闭环-管理"])
|
|
|
|
|
|
async def list_rule_versions(
|
|
|
|
|
|
scenario_key: Optional[str] = Query(None),
|
|
|
|
|
|
db: AsyncSession = Depends(get_db),
|
|
|
|
|
|
):
|
|
|
|
|
|
"""规则版本列表。"""
|
|
|
|
|
|
svc = AutoSessionService(db)
|
|
|
|
|
|
versions = await svc.list_rule_versions(scenario_key=scenario_key)
|
|
|
|
|
|
return success_response([_rule_version_payload(v) for v in versions])
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@router.get("/admin/metrics", tags=["自动化闭环-管理"])
|
|
|
|
|
|
async def get_metrics(
|
|
|
|
|
|
db: AsyncSession = Depends(get_db),
|
|
|
|
|
|
):
|
|
|
|
|
|
"""自动化看板指标。"""
|
|
|
|
|
|
svc = AutoSessionService(db)
|
|
|
|
|
|
metrics = await svc.metrics()
|
|
|
|
|
|
return success_response(metrics)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ==========================================================================
|
|
|
|
|
|
# WebSocket:自动化进度专用通道
|
|
|
|
|
|
# ==========================================================================
|
|
|
|
|
|
@ws_router.websocket("/ws/automation/{session_id}")
|
|
|
|
|
|
async def automation_ws_endpoint(websocket: WebSocket, session_id: str) -> None:
|
|
|
|
|
|
"""自动化会话专用 WebSocket(坐席/员工均可连)。
|
|
|
|
|
|
|
|
|
|
|
|
认证:优先 subprotocol bearer.{token},其次 Authorization header,
|
|
|
|
|
|
最后 query ?token=。token 需在 agent:token / employee:token 中存在。
|
|
|
|
|
|
"""
|
|
|
|
|
|
subprotocol = websocket.headers.get("sec-websocket-protocol", "")
|
|
|
|
|
|
if subprotocol.startswith("bearer."):
|
|
|
|
|
|
token = subprotocol[7:]
|
|
|
|
|
|
else:
|
|
|
|
|
|
auth_header = websocket.headers.get("Authorization", "")
|
|
|
|
|
|
token = auth_header[7:] if auth_header.startswith("Bearer ") else websocket.query_params.get("token", "")
|
|
|
|
|
|
|
|
|
|
|
|
if not token:
|
|
|
|
|
|
await websocket.accept()
|
|
|
|
|
|
await websocket.close(code=WS_CLOSE_UNAUTHORIZED, reason="Missing token")
|
|
|
|
|
|
return
|
|
|
|
|
|
|
|
|
|
|
|
# 校验 token(坐席或员工任一即可)
|
|
|
|
|
|
try:
|
|
|
|
|
|
aid = await cache_service.get(f"agent:token:{token}")
|
|
|
|
|
|
eid = await cache_service.get(f"employee:token:{token}") if not aid else None
|
|
|
|
|
|
except Exception as e: # noqa: BLE001
|
|
|
|
|
|
logger.error(f"自动化 WS token 校验失败: {e}")
|
|
|
|
|
|
await websocket.accept()
|
|
|
|
|
|
await websocket.close(code=WS_CLOSE_UNAUTHORIZED, reason="Auth unavailable")
|
|
|
|
|
|
return
|
|
|
|
|
|
|
|
|
|
|
|
if not aid and not eid:
|
|
|
|
|
|
await websocket.accept()
|
|
|
|
|
|
await websocket.close(code=WS_CLOSE_UNAUTHORIZED, reason="Invalid token")
|
|
|
|
|
|
return
|
|
|
|
|
|
|
|
|
|
|
|
register_ws(session_id, websocket)
|
|
|
|
|
|
logger.info(f"自动化 WS 连接: session={session_id}")
|
|
|
|
|
|
try:
|
|
|
|
|
|
while True:
|
|
|
|
|
|
data = await websocket.receive_json()
|
|
|
|
|
|
if data.get("type") == "ping":
|
|
|
|
|
|
await websocket.send_json({"type": "pong"})
|
|
|
|
|
|
except WebSocketDisconnect:
|
|
|
|
|
|
unregister_ws(session_id, websocket)
|
|
|
|
|
|
logger.info(f"自动化 WS 断开: session={session_id}")
|
|
|
|
|
|
except Exception: # noqa: BLE001
|
|
|
|
|
|
unregister_ws(session_id, websocket)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ==========================================================================
|
|
|
|
|
|
# 辅助函数
|
|
|
|
|
|
# ==========================================================================
|
|
|
|
|
|
async def _run_background(session_id: str) -> None:
|
|
|
|
|
|
"""后台运行编排(独立导入避免循环依赖)。"""
|
|
|
|
|
|
from app.services.automation import run_session_in_background
|
|
|
|
|
|
|
|
|
|
|
|
await run_session_in_background(session_id)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _session_min(session) -> dict:
|
|
|
|
|
|
"""会话列表最小字段。"""
|
|
|
|
|
|
return {
|
|
|
|
|
|
"id": session.id,
|
|
|
|
|
|
"employee_id": session.employee_id,
|
|
|
|
|
|
"scenario_key": session.scenario_key,
|
|
|
|
|
|
"status": session.status,
|
|
|
|
|
|
"mode": session.mode,
|
|
|
|
|
|
"confidence": session.confidence,
|
|
|
|
|
|
"title": session.title,
|
2026-07-11 23:13:10 +08:00
|
|
|
|
"paused_at": session.paused_at.isoformat() if session.paused_at else None,
|
2026-07-07 21:52:11 +08:00
|
|
|
|
"created_at": session.created_at.isoformat() if session.created_at else None,
|
|
|
|
|
|
"updated_at": session.updated_at.isoformat() if session.updated_at else None,
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _detail_payload(detail: dict) -> dict:
|
|
|
|
|
|
"""构造会话详情响应 data。"""
|
|
|
|
|
|
session = detail["session"]
|
|
|
|
|
|
actions = detail.get("actions", [])
|
|
|
|
|
|
ticket = detail.get("ticket")
|
|
|
|
|
|
return serialize_session(session, actions=actions, ticket=ticket).__dict__
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _scenario_payload(config) -> dict:
|
|
|
|
|
|
"""场景配置响应。"""
|
|
|
|
|
|
return ScenarioConfigResponse(
|
|
|
|
|
|
id=config.id,
|
|
|
|
|
|
scenario_key=config.scenario_key,
|
|
|
|
|
|
name=config.name,
|
|
|
|
|
|
description=config.description,
|
|
|
|
|
|
enabled=config.enabled,
|
|
|
|
|
|
trigger_conditions=config.trigger_conditions,
|
|
|
|
|
|
actions=config.actions,
|
|
|
|
|
|
approval_strategy=config.approval_strategy,
|
|
|
|
|
|
current_version_id=config.current_version_id,
|
|
|
|
|
|
).model_dump()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _rule_version_payload(version) -> dict:
|
|
|
|
|
|
"""规则版本响应。"""
|
|
|
|
|
|
return RuleVersionResponse(
|
|
|
|
|
|
id=version.id,
|
|
|
|
|
|
scenario_key=version.scenario_key,
|
|
|
|
|
|
version=version.version,
|
|
|
|
|
|
content=version.content,
|
|
|
|
|
|
status=version.status,
|
|
|
|
|
|
canary_percent=version.canary_percent,
|
|
|
|
|
|
created_by=version.created_by,
|
|
|
|
|
|
remark=version.remark,
|
|
|
|
|
|
created_at=version.created_at.isoformat() if version.created_at else None,
|
|
|
|
|
|
).model_dump()
|