一、客服 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 生成的本地产物
446 lines
20 KiB
Python
446 lines
20 KiB
Python
from collections.abc import Awaitable, Callable
|
||
from datetime import UTC, datetime
|
||
from typing import Any
|
||
from uuid import uuid4
|
||
|
||
from sqlalchemy import select, update
|
||
from sqlalchemy.ext.asyncio import AsyncSession
|
||
|
||
from app.core.contracts import DomainEvent, RequestContext
|
||
from app.core.conversation_privacy import sanitize_customer_service_message
|
||
from app.core.customer_service_rules import (
|
||
normalize_transfer_reason,
|
||
transfer_priority,
|
||
)
|
||
from app.core.errors import (
|
||
FeedbackAlreadyExistsError,
|
||
GenericResourceNotFoundError,
|
||
InvalidStateError,
|
||
RunNotCancellableError,
|
||
RunNotFoundError,
|
||
)
|
||
from app.core.profile_projection import project_profile
|
||
from app.infrastructure.db import SessionFactory
|
||
from app.model.audit import InteractionAudit
|
||
from app.model.conversation import ConversationFeedback
|
||
from app.model.platform import AgentRun, HandoverTicket, RequestIdempotency
|
||
from app.model.session import ConversationSession
|
||
from app.repository.conversation_repository import ConversationRepository
|
||
from app.repository.outbox_repository import OutboxRepository
|
||
from app.repository.platform_repository import PlatformRepository
|
||
from app.repository.session_repository import SessionRepository
|
||
from app.service.admin_service import public
|
||
from app.service.agent.bootstrap import get_agent_factory
|
||
from app.service.api_transaction_service import ApiTransactionService
|
||
from app.service.authorization_service import AuthorizationService
|
||
|
||
|
||
class PublicPlatformService:
|
||
async def session(self, session_id: str, context: RequestContext) -> dict[str, Any]:
|
||
async with SessionFactory() as session:
|
||
row = await SessionRepository(session).owned(session_id, int(context.user_id))
|
||
return {"data": self._session_view(row), "meta": {"trace_id": context.trace_id}}
|
||
|
||
async def handover(self, ticket_no: str, context: RequestContext) -> dict[str, Any]:
|
||
async with SessionFactory() as session:
|
||
row = await session.scalar(
|
||
select(HandoverTicket).where(
|
||
HandoverTicket.ticket_no == ticket_no,
|
||
HandoverTicket.customer_id == int(context.user_id),
|
||
)
|
||
)
|
||
if row is None:
|
||
raise GenericResourceNotFoundError("转人工请求不存在")
|
||
return {
|
||
"data": {
|
||
"handover_id": row.ticket_no,
|
||
"status": row.status,
|
||
"created_at": public(row.created_at),
|
||
},
|
||
"meta": {"trace_id": context.trace_id},
|
||
}
|
||
|
||
async def write(
|
||
self,
|
||
operation: str,
|
||
target: str,
|
||
context: RequestContext,
|
||
key: str | None,
|
||
payload: dict[str, Any],
|
||
) -> dict[str, Any]:
|
||
permission = {
|
||
"create": "conversation:create",
|
||
"close": "conversation:close",
|
||
"cancel": "agent:cancel",
|
||
"feedback": "conversation:feedback",
|
||
"handover": "handover:create",
|
||
}[operation]
|
||
await AuthorizationService.require(context, permission)
|
||
|
||
async def action(session: AsyncSession) -> dict[str, Any]:
|
||
now = datetime.now(UTC).replace(tzinfo=None)
|
||
user_id = int(context.user_id)
|
||
session_id: str | None = None
|
||
if operation == "create":
|
||
get_agent_factory().authorize(payload["agent_type"], context)
|
||
# 这四个时间列在模型里都是 `server_default=CURRENT_TIMESTAMP(6)`。
|
||
# 不显式赋值的话,`flush()` 之后 SQLAlchemy 需要**回读**这些由数据库生成的
|
||
# 值,而在 async session 里回读是异步 IO —— 紧接着 `_session_view(row)`
|
||
# 以同步属性访问去读,就抛 `MissingGreenlet: greenlet_spawn has not been
|
||
# called`,整个 `POST /api/v1/conversations` 500,连带转人工也做不了
|
||
# (会话建不出来 ⇒ 后续 404 会话不存在)。显式传 `now` 与同文件
|
||
# `ConversationFeedback(...)` 的写法一致,也贴合本项目"应用侧赋时间"的约定。
|
||
row = ConversationSession(
|
||
session_id=str(uuid4()),
|
||
user_id=user_id,
|
||
agent_type=payload["agent_type"],
|
||
portal=context.portal,
|
||
status="active",
|
||
clarification_round=0,
|
||
message_count=0,
|
||
started_at=now,
|
||
last_active_at=now,
|
||
created_at=now,
|
||
updated_at=now,
|
||
)
|
||
session.add(row)
|
||
await session.flush()
|
||
session_id = row.session_id
|
||
data = self._session_view(row)
|
||
elif operation == "cancel":
|
||
data, session_id = await self._cancel(session, target, user_id, now)
|
||
elif operation == "feedback":
|
||
message = await ConversationRepository(session).message(int(target), user_id)
|
||
if message is None:
|
||
raise GenericResourceNotFoundError("消息不存在")
|
||
if await ConversationRepository(session).feedback(int(target), user_id):
|
||
raise FeedbackAlreadyExistsError("消息已经反馈")
|
||
feedback = ConversationFeedback(
|
||
feedback_no=f"fb-{uuid4().hex[:24]}",
|
||
session_id=message.session_id,
|
||
message_id=message.id,
|
||
customer_id=user_id,
|
||
created_at=now,
|
||
updated_at=now,
|
||
**payload,
|
||
)
|
||
session.add(feedback)
|
||
await session.flush()
|
||
session_id = message.session_id
|
||
data = {"feedback_no": feedback.feedback_no, "status": feedback.status}
|
||
else:
|
||
row = await SessionRepository(session).owned(target, user_id, lock=True)
|
||
session_id = row.session_id
|
||
if operation == "close":
|
||
if row.status not in {"active", "ended"}:
|
||
raise InvalidStateError("当前会话状态不能关闭")
|
||
if row.status == "active":
|
||
row.status, row.ended_at = "ended", now
|
||
data = self._session_view(row)
|
||
else:
|
||
if row.status != "active":
|
||
raise InvalidStateError("会话不在可转人工状态")
|
||
messages = await ConversationRepository(session).messages(target, user_id, 1)
|
||
ticket = HandoverTicket(
|
||
ticket_no=f"ticket-{uuid4().hex[:24]}",
|
||
session_id=target,
|
||
customer_id=user_id,
|
||
source_agent=row.agent_type or "customer_service",
|
||
source_message_id=messages[0].id if messages else None,
|
||
# `E-01` ③:请求取值(`user_requested`)不是工单枚举码,
|
||
# 落库前统一收敛,保证 `reason_code` 列只有一套词汇。
|
||
reason_code=normalize_transfer_reason(
|
||
payload["reason_code"]
|
||
),
|
||
# `E-01` ②:会话页自助转人工 = 客户主动要人工 → P2。
|
||
priority=transfer_priority(
|
||
normalize_transfer_reason(payload["reason_code"])
|
||
),
|
||
# 用户自填原因同样是客服会话链路的一部分,不能把凭据原样落工单。
|
||
reason_detail=(
|
||
sanitize_customer_service_message(payload["reason_detail"])
|
||
if payload.get("reason_detail") is not None else None
|
||
),
|
||
status="pending",
|
||
created_at=now,
|
||
updated_at=now,
|
||
)
|
||
session.add(ticket)
|
||
await session.flush()
|
||
await OutboxRepository(session).append(
|
||
DomainEvent(
|
||
event_id=str(uuid4()),
|
||
event_type="conversation.transfer_requested",
|
||
aggregate_type="conversation",
|
||
aggregate_id=target,
|
||
trace_id=context.trace_id,
|
||
payload={"ticket_no": ticket.ticket_no},
|
||
occurred_at=now,
|
||
)
|
||
)
|
||
data = {
|
||
"handover_id": ticket.ticket_no,
|
||
"status": ticket.status,
|
||
"session_id": target,
|
||
"created_at": public(now),
|
||
}
|
||
session.add(
|
||
InteractionAudit(
|
||
actor_type="user",
|
||
actor_id=user_id,
|
||
session_id=session_id,
|
||
portal=context.portal,
|
||
action_type=f"platform.{operation}",
|
||
detail={
|
||
"trace_id": context.trace_id,
|
||
"target": target,
|
||
},
|
||
created_at=now,
|
||
)
|
||
)
|
||
return {"data": data, "meta": {"trace_id": context.trace_id}}
|
||
|
||
return await self._transact(context, operation, target, key, payload, action)
|
||
|
||
@staticmethod
|
||
async def _transact(
|
||
context: RequestContext,
|
||
operation: str,
|
||
target: str,
|
||
key: str | None,
|
||
payload: dict[str, Any],
|
||
action: Callable[[AsyncSession], Awaitable[dict[str, Any]]],
|
||
) -> dict[str, Any]:
|
||
"""取消之外的写接口走幂等响应缓存。
|
||
|
||
取消**刻意不走**响应缓存:文档 §6.4 要求重复取消返回同一状态,而运行状态是
|
||
服务端权威状态——worker 把 `cancel_requested` 推进到 `cancelled` 之后,缓存的旧
|
||
快照会变成过期数据。取消的幂等性由 `_cancel` 读实时状态保证。
|
||
"""
|
||
if operation == "cancel":
|
||
async with SessionFactory() as session, session.begin():
|
||
return await action(session)
|
||
return await ApiTransactionService().execute(
|
||
context, f"public:{operation}:{target}", key, payload, action
|
||
)
|
||
|
||
async def _cancel(
|
||
self, session: AsyncSession, run_id: str, user_id: int, now: datetime
|
||
) -> tuple[dict[str, Any], str]:
|
||
"""文档 §6.4 的取消语义。
|
||
|
||
- 首次取消 `queued`/`running` → 置 `cancel_requested`,并把原请求的
|
||
`request_idempotency` 写成 `failed + RUN_CANCELLED`;
|
||
- 重复取消 → **幂等**,返回与首次一致的状态与 `cancel_requested_at`,绝不报错;
|
||
- 已成功、已失败或已进入最终提交事务 → `409 RUN_NOT_CANCELLABLE`。
|
||
|
||
`RUN_CANCELLED` 只落 `request_idempotency`,不作为 HTTP 响应错误码返回。
|
||
"""
|
||
run = await session.scalar(
|
||
select(AgentRun)
|
||
.where(AgentRun.run_id == run_id, AgentRun.user_id == user_id)
|
||
.with_for_update()
|
||
)
|
||
if run is None:
|
||
raise RunNotFoundError("运行不存在或不可见")
|
||
if run.status in {"succeeded", "failed"}:
|
||
raise RunNotCancellableError("运行已进入不可取消阶段")
|
||
if run.status in {"cancel_requested", "cancelled"}:
|
||
# 重复取消:返回同一状态与同一受理时间,不重复改写幂等记录。
|
||
return {
|
||
"run_id": run_id,
|
||
"status": run.status,
|
||
"cancel_requested_at": public(run.cancel_requested_at),
|
||
}, run.session_id
|
||
run.status, run.cancel_requested_at = "cancel_requested", now
|
||
# 文档 §6.4:取消成功后原请求在 request_idempotency 中以 failed + RUN_CANCELLED
|
||
# 结束,不扩展其既有状态枚举。
|
||
await session.execute(
|
||
update(RequestIdempotency)
|
||
.where(RequestIdempotency.id == run.idempotency_id)
|
||
.values(status="failed", error_code="RUN_CANCELLED", updated_at=now)
|
||
)
|
||
return {
|
||
"run_id": run_id,
|
||
"status": run.status,
|
||
"cancel_requested_at": public(run.cancel_requested_at),
|
||
}, run.session_id
|
||
|
||
@staticmethod
|
||
def _session_view(row: ConversationSession) -> dict[str, Any]:
|
||
return {
|
||
"session_id": row.session_id,
|
||
"agent_type": row.agent_type,
|
||
"portal": row.portal,
|
||
"status": row.status,
|
||
"clarification_round": row.clarification_round,
|
||
"message_count": row.message_count,
|
||
"last_active_at": public(row.last_active_at),
|
||
"started_at": public(row.started_at),
|
||
"ended_at": public(row.ended_at),
|
||
"created_at": public(row.created_at),
|
||
}
|
||
|
||
async def memory(self, customer_id: int, context: RequestContext) -> dict[str, Any]:
|
||
own = str(customer_id) == context.user_id
|
||
permission = "memory:read:self" if own else "memory:read:customer"
|
||
await AuthorizationService.require(context, permission)
|
||
scope = context.permission_scopes.get(permission, "self")
|
||
if (
|
||
not own
|
||
and scope != "all"
|
||
and (scope != "own_customers" or str(customer_id) not in context.customer_ids)
|
||
):
|
||
raise GenericResourceNotFoundError("客户不可访问")
|
||
async with SessionFactory() as session, session.begin():
|
||
rows = await PlatformRepository(session).rows(
|
||
"profile_snapshots", {"customer_id": customer_id, "is_current": 1}, limit=1
|
||
)
|
||
session.add(
|
||
InteractionAudit(
|
||
actor_type="user",
|
||
actor_id=int(context.user_id),
|
||
target_customer_id=customer_id,
|
||
portal=context.portal,
|
||
action_type="memory.profile_read",
|
||
detail={"trace_id": context.trace_id},
|
||
created_at=datetime.now(UTC).replace(tzinfo=None),
|
||
)
|
||
)
|
||
# 字段策略投影(白名单 + 测评有效期实时判定);不再返回空字典。
|
||
data: dict[str, Any] = {
|
||
"customer_id": str(customer_id),
|
||
"version": str(rows[0]["version"]) if rows else None,
|
||
"generated_at": public(rows[0].get("generated_at")) if rows else None,
|
||
"profile": project_profile(rows[0].get("snapshot")) if rows else {},
|
||
}
|
||
return {"data": data, "meta": {"trace_id": context.trace_id}}
|
||
|
||
async def memories_debug(
|
||
self, customer_id: int, context: RequestContext, *, query: str | None = None,
|
||
limit: int = 10,
|
||
) -> dict[str, Any]:
|
||
"""记忆系统的可观测快照:库里有什么 / 能不能召回 / 事件有没有被消费。
|
||
|
||
排查"记忆是否真的在工作"时,需要同时看清四件事,缺一件就会误判:
|
||
|
||
1. `stored` —— `memory_unit` 里到底有没有行(写入是否成功)
|
||
2. `recalled` —— 走完整召回链路(MySQL + 可选向量)能拿到什么
|
||
3. `pending` —— `domain_event_outbox` 里是否还堆着未消费事件(Worker 是否在跑)
|
||
4. `facts` / `profile` —— 记忆的下游产物(画像)有没有被重建
|
||
|
||
只返回 `profile_snapshots` 的 `memory-profile` 端点无法区分
|
||
"还没重建" 与 "压根没写入",本端点就是为消除这个盲区而加的。
|
||
"""
|
||
await AuthorizationService.require(context, "memory:read:self")
|
||
from app.model.memory import MemoryEvidence, MemoryUnit
|
||
from app.model.platform import DomainEventOutbox
|
||
from app.model.profile import ProfileSnapshot, UserFact
|
||
from app.service.agent.bootstrap import build_memory_recall_service
|
||
|
||
async with SessionFactory() as session:
|
||
stored = list(await session.scalars(
|
||
select(MemoryUnit)
|
||
.where(MemoryUnit.customer_id == customer_id)
|
||
.order_by(MemoryUnit.updated_at.desc())
|
||
.limit(50)
|
||
))
|
||
status_counts: dict[str, int] = {}
|
||
for row in stored:
|
||
status_counts[row.status] = status_counts.get(row.status, 0) + 1
|
||
# 证据按「本客户的记忆」统计,不是全表行数 —— 全表数字无法说明本客户是否写入成功。
|
||
memory_ids = [row.id for row in stored]
|
||
evidence_count = 0
|
||
if memory_ids:
|
||
evidence_count = len(list(await session.scalars(
|
||
select(MemoryEvidence.id)
|
||
.where(MemoryEvidence.memory_id.in_(memory_ids))
|
||
.limit(500)
|
||
)))
|
||
facts = list(await session.scalars(
|
||
select(UserFact).where(UserFact.customer_id == customer_id).limit(50)
|
||
))
|
||
snapshots = list(await session.scalars(
|
||
select(ProfileSnapshot)
|
||
.where(ProfileSnapshot.customer_id == customer_id)
|
||
.order_by(ProfileSnapshot.id.desc())
|
||
.limit(3)
|
||
))
|
||
pending = list(await session.scalars(
|
||
select(DomainEventOutbox).where(
|
||
DomainEventOutbox.aggregate_id == str(customer_id),
|
||
DomainEventOutbox.status == "pending",
|
||
).limit(50)
|
||
))
|
||
|
||
# 用生产装配(含 Milvus 向量通道 + embedding),这样 `degraded_reasons`
|
||
# 能真实反映"语义通道是否可用",而不是因为没装配而假装正常。
|
||
recall_service = build_memory_recall_service(SessionFactory())
|
||
try:
|
||
result = await recall_service.recall(
|
||
customer_id, query, limit=max(1, min(limit, 100)), use_cache=False
|
||
)
|
||
recalled = [
|
||
{
|
||
"memory_uuid": item.memory_uuid,
|
||
"memory_key": item.memory_key,
|
||
"content": item.content,
|
||
"memory_type": item.memory_type,
|
||
"confidence": item.confidence,
|
||
"sources": list(item.sources),
|
||
"evidence": item.evidence,
|
||
}
|
||
for item in result.items
|
||
]
|
||
degraded, reasons = result.degraded, list(result.degraded_reasons)
|
||
finally:
|
||
await recall_service.session.close()
|
||
|
||
return {
|
||
"data": {
|
||
"customer_id": str(customer_id),
|
||
"stored": {
|
||
"total": len(stored),
|
||
"by_status": status_counts,
|
||
"items": [
|
||
{
|
||
"memory_uuid": row.memory_uuid,
|
||
"memory_key": row.memory_key,
|
||
"content": row.content,
|
||
"memory_type": row.memory_type,
|
||
"status": row.status,
|
||
"confidence": float(row.confidence),
|
||
"source_type": row.source_type,
|
||
"valid_until": public(row.valid_until),
|
||
"updated_at": public(row.updated_at),
|
||
}
|
||
for row in stored
|
||
],
|
||
},
|
||
"recalled": {
|
||
"query": query,
|
||
"count": len(recalled),
|
||
"degraded": degraded,
|
||
"degraded_reasons": reasons,
|
||
"items": recalled,
|
||
},
|
||
"evidence_rows_sampled": evidence_count,
|
||
"downstream": {
|
||
"user_facts": [
|
||
{"fact_key": f.fact_key, "confidence": float(f.confidence)}
|
||
for f in facts
|
||
],
|
||
"profile_snapshots": [
|
||
{"is_current": s.is_current, "generated_at": public(s.generated_at)}
|
||
for s in snapshots
|
||
],
|
||
},
|
||
"pending_events": [
|
||
{"event_type": e.event_type, "status": e.status,
|
||
"retry_count": e.retry_count, "occurred_at": public(e.occurred_at)}
|
||
for e in pending
|
||
],
|
||
},
|
||
"meta": {"trace_id": context.trace_id},
|
||
}
|