Files
group_fqcd_jr/app/api/controllers/admin.py
T
张胜宇 5d0becb67d 客服 Agent 重构收口:五出口决策链 + 知识库档位隔离 + 前端入参边界(答辩演示版本)
一、客服 Agent 智能增强(正面回应"不智能、动不动就转人工")
- 决策链由 2 个出口扩到 5 个:E1 澄清 / E2 计算型 / E3 知识直返 / E4 证据约束生成 / E5 分级回退
- 转人工从"默认动作"降为最后一档 E5c,只保留 4 类白名单:
  P0 反诈 / P1 账户与个人数据 / P2 写操作与争议 / 用户明确要求人工
- 46 条金标实测(修复前 → 修复后):
  转人工率 43.5% → 10.9%;出口准确率 45.7% → 100%;事实正确率 69.6% → 100%
  禁忌违反 1 → 0;档位越权 / 无出处数字 / 误拒 四项零容忍全 0
- 安全不变量 INV-1~INV-5;零容忍规则未删,改的是挂载点
  (输出侧字面黑名单 → 检索层档位隔离 + 判定层合规词表 + 输出守护)

二、知识库:档位单点化与物理隔离
- 新增 app/core/knowledge_tier.py 作为档位规则唯一落点(G-03),
  knowledge_contracts.py 原定义块改为显式再导出(X as X,非副本)
- 档位过滤由 bool 默认值(fail-open)改为 tiers 必填集合(缺参即 TypeError)
- Milvus 侧四集合按 visibility 分区键物理隔离;双 schema 收敛为一套
- 新增 app/core/actor.py:访客三元组与匿名判定的唯一构造/判定点(G-01/G-01b)
- 新增 app/core/fund_fee_rules.py:费率计算纯函数

三、前端入参边界对齐(本轮 W11 新修,4 处"校验宽于存储")
- message 加 max_length=8000(与浮窗 widget.js 的 maxlength 一致)
- session_id 加 1—64;idempotency_key 上限 128 → 64(对齐列宽 String(64))
- feedback_type 加 max_length=32(对齐列宽 String(32))
- 8 条路径参数补 min_length=1 + max_length=64 + 字符集正则
  ({session_id} / {run_id} / {handover_id})
- 改前超限值会落到 MySQL 才失败(500);改后一律 422 AGENT_INPUT_INVALID + 字段级定位
- 新增 tests/unit/api/test_frontend_boundaries.py(33 例),含"端点表 ↔ OpenAPI 全量对照"

四、投顾模块整体清除(D4.4 / D4.5)
- 删除投顾相关 controller / schema / model / repository / service 及门户页面
- tools/portal_api_check.py 同步作废 AD003/AD005/AD011/A047 四条用例与 advisor_t 登录
  (端点与账号均已不存在,此前稳定报 3 条假红)

五、验证(提交前实测)
- pytest -q:1856 passed / 2 skipped / 0 failed
- ruff check app tools tests:19(= 基线);mypy app:2(= 基线)
- 前端接口契约体检 portal_api_check.py:38 项,通过 34,失败 0,跳过 4
- 全链路冒烟 e2e_smoke_test.py --read-only:31/31
- HTTP 全链路探针 http_probe.py:11/11 succeeded
- 跨文档一致性 _consistency.py:GATE PASS
- 真机边界复验 12 条:12/12 符合预期

六、纪律与文档
- 可改文件白名单 A-09(docs/46)与底座会签申请单 A-10(docs/47,组 1—组 4 全部受理)
- 零 DDL:未新增/修改任何表结构,89 张业务表与基线一致
- 证据留痕:docs/evidence/**(含 46 条金标 score、快照、清除与重建记录)
- 未提交(刻意排除,见提交说明):仓库内 客服agent/ 与 开发文档/ 是 2026-09-16 前的
  过期副本(Todolist 440 行 vs 权威 D2.1 1167 行),权威正本在仓库外;
  _chunks_report.txt 是 tools/build_knowledge_chunks.py 生成的本地产物
2026-09-20 14:33:30 +08:00

395 lines
16 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
from collections.abc import Awaitable, Callable
from typing import Any
from fastapi import APIRouter, Depends, Header, Path, Query, Response
from pydantic import BaseModel
from sqlalchemy.ext.asyncio import AsyncSession
from app.api.dependencies.auth import build_request_context
from app.api.dependencies.database import get_session
from app.api.dependencies.rate_limit import enforce_rate_limit
from app.api.schemas.admin import (
EmptyPayload,
EndpointPayload,
HandoverAssignPayload,
HandoverCancelPayload,
HandoverClosePayload,
HandoverResolvePayload,
HandoverStatus,
IntentPayload,
ItemPayload,
NegativePayload,
PromptPayload,
ReleasePayload,
ReplyPayload,
ReviewPayload,
RoutingPayload,
)
from app.api.views.envelope import envelope as _envelope
from app.core.contracts import RequestContext
from app.core.profile_governance_contracts import ProfileDriftReviewRequest
from app.service.admin_service import AdminService
from app.service.api_transaction_service import ApiTransactionService
from app.service.customer_profile_candidate_service import CustomerProfileCandidateService
from app.service.customer_service_handover_action_service import (
CustomerServiceHandoverActionService,
)
from app.service.customer_service_handover_admin_service import CustomerServiceHandoverAdminService
from app.service.profile_governance_service import ProfileGovernanceService
router = APIRouter(
prefix="/api/v1/admin", tags=["platform-admin"], dependencies=[Depends(enforce_rate_limit)]
)
@router.get("/advisor/profile-tags")
async def list_profile_tags(
customer_id: int = Query(gt=0),
context: RequestContext = Depends(build_request_context), # noqa: B008
) -> dict[str, object]:
return await ProfileGovernanceService().tags(customer_id, context)
@router.get("/advisor/profile-drift-reviews")
async def list_profile_drift_reviews(
context: RequestContext = Depends(build_request_context), # noqa: B008
) -> dict[str, object]:
return await ProfileGovernanceService().pending_reviews(context)
@router.post("/advisor/profile-drift-reviews/{review_id}/reviews")
async def review_profile_drift(
payload: ProfileDriftReviewRequest,
review_id: int = Path(gt=0),
context: RequestContext = Depends(build_request_context), # noqa: B008
key: str | None = Header(default=None, alias="Idempotency-Key"),
) -> dict[str, object]:
return await ProfileGovernanceService().review(review_id, payload, context, key)
def register_resource(
resource: str,
schema: type[BaseModel],
id_name: str,
*,
scoped: bool = False,
update: bool = True,
detail: bool = True,
) -> None:
prefix = f"/config-releases/{{release_id}}/{resource}" if scoped else f"/{resource}"
async def create(
payload: BaseModel,
response: Response,
release_id: int | None = None,
context: RequestContext = Depends(build_request_context), # noqa: B008
key: str | None = Header(default=None, alias="Idempotency-Key"),
) -> dict[str, Any]:
result = await AdminService().mutate(
resource, context, payload.model_dump(mode="json"), key, None, release_id=release_id
)
response.headers["ETag"] = f'"{result["meta"]["etag"]}"'
return result
create.__annotations__["payload"] = schema
router.add_api_route(
prefix, create, methods=["POST"], status_code=201, operation_id=f"create_{resource}"
)
async def list_rows(
release_id: int | None = None,
limit: int = Query(default=20, ge=1, le=100),
cursor: str | None = Query(default=None),
context: RequestContext = Depends(build_request_context), # noqa: B008
) -> dict[str, Any]:
"""列表查询(文档 §3.8 统一游标)。游标校验在 Service 的权限闸门之后进行。"""
return await AdminService().query(
resource, context, release_id=release_id, limit=limit, cursor=cursor
)
router.add_api_route(prefix, list_rows, methods=["GET"], operation_id=f"list_{resource}")
async def get(
response: Response,
row_id: int = Path(alias=id_name, gt=0),
context: RequestContext = Depends(build_request_context), # noqa: B008
) -> dict[str, Any]:
result = await AdminService().query(resource, context, row_id=row_id)
response.headers["ETag"] = f'"{result["meta"]["etag"]}"'
return result
if detail:
router.add_api_route(
f"{prefix}/{{{id_name}}}", get, methods=["GET"], operation_id=f"get_{resource}"
)
async def put(
payload: BaseModel,
response: Response,
row_id: int = Path(alias=id_name, gt=0),
release_id: int | None = None,
context: RequestContext = Depends(build_request_context), # noqa: B008
key: str | None = Header(default=None, alias="Idempotency-Key"),
if_match: str | None = Header(default=None, alias="If-Match"),
) -> dict[str, Any]:
result = await AdminService().mutate(
resource,
context,
payload.model_dump(mode="json"),
key,
if_match,
row_id=row_id,
release_id=release_id,
)
response.headers["ETag"] = f'"{result["meta"]["etag"]}"'
return result
put.__annotations__["payload"] = schema
if update:
router.add_api_route(
f"{prefix}/{{{id_name}}}", put, methods=["PUT"], operation_id=f"update_{resource}"
)
def register_transition(resource: str, id_name: str, action: str) -> None:
async def transition(
payload: BaseModel,
response: Response,
row_id: int = Path(alias=id_name, gt=0),
context: RequestContext = Depends(build_request_context), # noqa: B008
key: str | None = Header(default=None, alias="Idempotency-Key"),
if_match: str | None = Header(default=None, alias="If-Match"),
) -> dict[str, Any]:
result = await AdminService().mutate(
resource,
context,
payload.model_dump(mode="json"),
key,
if_match,
row_id=row_id,
action=action,
)
response.headers["ETag"] = f'"{result["meta"]["etag"]}"'
return result
transition.__annotations__["payload"] = ReviewPayload if action == "reviews" else EmptyPayload
router.add_api_route(
f"/{resource}/{{{id_name}}}/{action}",
transition,
methods=["POST"],
status_code=201 if action == "rollbacks" else 200,
operation_id=f"{action}_{resource}",
)
register_resource("config-releases", ReleasePayload, "release_id", update=False)
# ⚠️ `platform-config-items` 与 `model-routing-rules` 必须是 `detail=True`:
# 它们的更新端点(PUT)**硬性要求 `If-Match`**,而校验用的是**该行内容的 digest**
# (`admin_service.mutate`:`if_match is None or if_match != digest(existing) → 409`)。
# 此前 `detail=False` 意味着**没有任何端点能返回这个 digest** —— 列表的 `meta` 只有
# trace_id、也没有详情端点,于是**首次编辑必然 409**:乐观并发成了死锁,
# 编辑功能实际不可用(2026-09-13 前端等价测试发现)。
register_resource("platform-config-items", ItemPayload, "item_id", scoped=True, detail=True)
register_resource("model-endpoints", EndpointPayload, "endpoint_id")
register_resource("model-routing-rules", RoutingPayload, "rule_id", scoped=True, detail=True)
register_resource("prompt-templates", PromptPayload, "prompt_id", update=False)
register_resource("agent-intent-configs", IntentPayload, "config_id")
register_resource("reply-templates", ReplyPayload, "template_id", detail=False)
register_resource("negative-word-rules", NegativePayload, "rule_id", detail=False)
for action in ("validations", "reviews", "activations", "rollbacks"):
register_transition("config-releases", "release_id", action)
for action in ("reviews", "activations", "disablements"):
register_transition("model-endpoints", "endpoint_id", action)
# 意图配置沿用同一套"审核 → 生效 → 归档"流转;归档落 `archived`
# (该表 CHECK 约束只允许 draft/approved/active/archived,没有 disabled)。
for action in ("reviews", "activations", "archivals"):
register_transition("agent-intent-configs", "config_id", action)
@router.get("/audit-records")
async def audit_records(
limit: int = Query(default=20, ge=1, le=100),
cursor: str | None = Query(default=None),
context: RequestContext = Depends(build_request_context), # noqa: B008
) -> dict[str, Any]:
"""审计查询(文档 §9.6 支持游标过滤)。游标非法时返回 `400 INVALID_CURSOR`。"""
return await AdminService().query("audit-records", context, limit=limit, cursor=cursor)
@router.get("/customer-service/handover-tickets")
async def list_customer_service_handover_tickets(
limit: int = Query(default=20, ge=1, le=100),
cursor: str | None = Query(default=None),
status: HandoverStatus | None = Query(default=None), # noqa: B008
context: RequestContext = Depends(build_request_context), # noqa: B008
) -> dict[str, Any]:
"""只读查看客服转人工队列;不暴露原始会话。可按状态筛。
处置动作在下面五个端点里,需要 `handover:write`(本端点只需 `handover:read`)。
"""
return await CustomerServiceHandoverAdminService().list_tickets(
context, limit=limit, cursor=cursor, status=status
)
@router.get("/customer-service/handover-tickets/{ticket_no}")
async def get_customer_service_handover_ticket(
ticket_no: str,
context: RequestContext = Depends(build_request_context), # noqa: B008
) -> dict[str, Any]:
"""只读查看单个工单的脱敏转接摘要与流转状态。"""
return await CustomerServiceHandoverAdminService().get_ticket(ticket_no, context)
# ---- 转人工工单处置(分配 / 接单 / 解决 / 关闭 / 取消)----
#
# 状态机与权限口径见 `docs/02` §7.2 与 `app/service/customer_service_handover_action_service.py`:
# pending -> assigned -> processing -> resolved -> closed,未解决可 cancelled。
# 五个端点都要求 `handover:write` + admin 角色,并且都要求 `Idempotency-Key`
# (重复提交直接回放上次结果,不会二次推进状态机)。
@router.post("/customer-service/handover-tickets/{ticket_no}/assignments")
async def assign_customer_service_handover_ticket(
payload: HandoverAssignPayload,
ticket_no: str = Path(min_length=1, max_length=64, pattern=r"^[A-Za-z0-9_-]+$"),
context: RequestContext = Depends(build_request_context), # noqa: B008
session: AsyncSession = Depends(get_session), # noqa: B008
key: str | None = Header(default=None, alias="Idempotency-Key"),
) -> dict[str, Any]:
"""把待处理工单分配给一个坐席(`pending -> assigned`)。"""
return await _handover_action(
session,
context,
key,
f"POST /api/v1/admin/customer-service/handover-tickets/{ticket_no}/assignments",
payload.model_dump(),
lambda inner: CustomerServiceHandoverActionService(inner).assign(
ticket_no, payload.assignee_id, context
),
)
@router.post("/customer-service/handover-tickets/{ticket_no}/acceptances")
async def accept_customer_service_handover_ticket(
ticket_no: str = Path(min_length=1, max_length=64, pattern=r"^[A-Za-z0-9_-]+$"),
context: RequestContext = Depends(build_request_context), # noqa: B008
session: AsyncSession = Depends(get_session), # noqa: B008
key: str | None = Header(default=None, alias="Idempotency-Key"),
) -> dict[str, Any]:
"""坐席接单(`pending` 自助接管 / `assigned -> processing`)。"""
return await _handover_action(
session,
context,
key,
f"POST /api/v1/admin/customer-service/handover-tickets/{ticket_no}/acceptances",
{},
lambda inner: CustomerServiceHandoverActionService(inner).accept(ticket_no, context),
)
@router.post("/customer-service/handover-tickets/{ticket_no}/resolutions")
async def resolve_customer_service_handover_ticket(
payload: HandoverResolvePayload,
ticket_no: str = Path(min_length=1, max_length=64, pattern=r"^[A-Za-z0-9_-]+$"),
context: RequestContext = Depends(build_request_context), # noqa: B008
session: AsyncSession = Depends(get_session), # noqa: B008
key: str | None = Header(default=None, alias="Idempotency-Key"),
) -> dict[str, Any]:
"""给出解决结论(`processing -> resolved`)。"""
return await _handover_action(
session,
context,
key,
f"POST /api/v1/admin/customer-service/handover-tickets/{ticket_no}/resolutions",
payload.model_dump(),
lambda inner: CustomerServiceHandoverActionService(inner).resolve(
ticket_no, payload.resolution, context
),
)
@router.post("/customer-service/handover-tickets/{ticket_no}/closures")
async def close_customer_service_handover_ticket(
ticket_no: str = Path(min_length=1, max_length=64, pattern=r"^[A-Za-z0-9_-]+$"),
payload: HandoverClosePayload | None = None,
context: RequestContext = Depends(build_request_context), # noqa: B008
session: AsyncSession = Depends(get_session), # noqa: B008
key: str | None = Header(default=None, alias="Idempotency-Key"),
) -> dict[str, Any]:
"""归档关闭(`resolved -> closed`);可选补一条说明。"""
note = payload.note if payload is not None else ""
return await _handover_action(
session,
context,
key,
f"POST /api/v1/admin/customer-service/handover-tickets/{ticket_no}/closures",
{"note": note},
lambda inner: CustomerServiceHandoverActionService(inner).close(
ticket_no, note, context
),
)
@router.post("/customer-service/handover-tickets/{ticket_no}/cancellations")
async def cancel_customer_service_handover_ticket(
payload: HandoverCancelPayload,
ticket_no: str = Path(min_length=1, max_length=64, pattern=r"^[A-Za-z0-9_-]+$"),
context: RequestContext = Depends(build_request_context), # noqa: B008
session: AsyncSession = Depends(get_session), # noqa: B008
key: str | None = Header(default=None, alias="Idempotency-Key"),
) -> dict[str, Any]:
"""取消未解决的工单(`pending|assigned|processing -> cancelled`)。"""
return await _handover_action(
session,
context,
key,
f"POST /api/v1/admin/customer-service/handover-tickets/{ticket_no}/cancellations",
payload.model_dump(),
lambda inner: CustomerServiceHandoverActionService(inner).cancel(
ticket_no, payload.reason, context
),
)
async def _handover_action(
session: AsyncSession,
context: RequestContext,
key: str | None,
scope: str,
body: dict[str, Any],
action: Callable[[AsyncSession], Awaitable[dict[str, Any]]],
) -> dict[str, Any]:
"""工单处置的统一外壳:幂等 + 统一信封。
幂等记录与业务写入**同事务**(`ApiTransactionService.execute_in`),
重复请求直接回放 `response_json`,不会二次驱动状态机。
幂等范围含路径参数 —— 同一个键用在两张不同工单上会被当成两次不同操作
(与风控处置同一口径:把路径参数折成模板会让不同资源互相回放)。
"""
data = await ApiTransactionService().execute_in(
session, context, scope, key, body, action
)
return _envelope(data, context)
@router.get("/customer-profile-candidates")
async def list_customer_profile_candidates(
limit: int = Query(default=20, ge=1, le=100),
context: RequestContext = Depends(build_request_context), # noqa: B008
) -> dict[str, Any]:
"""管理员查看待确认或待审核的画像候选。"""
return await CustomerProfileCandidateService().list_for_admin(context, limit=limit)
@router.post("/customer-profile-candidates/{candidate_id}/reviews", status_code=200)
async def review_customer_profile_candidate(
candidate_id: int,
payload: ReviewPayload,
context: RequestContext = Depends(build_request_context), # noqa: B008
) -> dict[str, Any]:
"""管理员批准或驳回候选;批准会处理同键旧正式记忆。"""
return await CustomerProfileCandidateService().review_by_admin(
candidate_id, payload.decision, context, comment=payload.comment
)