feat: add profile candidate review workflow

This commit is contained in:
张胜宇
2026-09-11 17:47:06 +08:00
parent 3a21afa847
commit a769130658
7 changed files with 437 additions and 14 deletions
+22
View File
@@ -19,6 +19,7 @@ from app.api.schemas.admin import (
)
from app.core.contracts import RequestContext
from app.service.admin_service import AdminService
from app.service.customer_profile_candidate_service import CustomerProfileCandidateService
from app.service.customer_service_handover_admin_service import CustomerServiceHandoverAdminService
router = APIRouter(prefix="/api/v1/admin", tags=["platform-admin"],
@@ -152,3 +153,24 @@ async def get_customer_service_handover_ticket(
) -> dict[str, Any]:
"""只读查看单个工单的脱敏转接摘要。"""
return await CustomerServiceHandoverAdminService().get_ticket(ticket_no, 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
)
+29 -1
View File
@@ -1,4 +1,4 @@
from typing import Any
from typing import Any, Literal
from fastapi import APIRouter, Depends, Header
from pydantic import Field
@@ -22,6 +22,10 @@ class Cancellation(StrictPayload):
reason: str = Field(default="user_cancelled", max_length=128)
class CandidateDecisionPayload(StrictPayload):
decision: Literal["confirmed", "rejected"]
@router.post("/conversations", status_code=201)
async def create_session(
payload: SessionCreate,
@@ -90,3 +94,27 @@ async def customer_memory(
customer_id: int, context: RequestContext = Depends(build_request_context), # noqa: B008
) -> dict[str, Any]:
return await PublicPlatformService().memory(customer_id, context)
@router.get("/users/me/memory-candidates")
async def my_memory_candidates(
context: RequestContext = Depends(build_request_context), # noqa: B008
) -> dict[str, Any]:
"""返回当前用户可确认的画像候选,不返回证据原文。"""
from app.service.customer_profile_candidate_service import CustomerProfileCandidateService
return await CustomerProfileCandidateService().list_for_customer(context)
@router.post("/users/me/memory-candidates/{candidate_id}/decisions")
async def decide_memory_candidate(
candidate_id: int,
payload: CandidateDecisionPayload,
context: RequestContext = Depends(build_request_context), # noqa: B008
) -> dict[str, Any]:
"""用户确认或拒绝自己的候选;确认后仍需管理员审核才能激活。"""
from app.service.customer_profile_candidate_service import CustomerProfileCandidateService
return await CustomerProfileCandidateService().decide_by_customer(
candidate_id, payload.decision, context
)
@@ -0,0 +1,180 @@
"""客户画像候选的确认、审核与晋升服务。
候选与正式记忆共用 ``memory_unit``,但状态转换必须经过本服务;客服 Agent 不具备
调用权限。用户确认只把候选标记为 ``verified``,管理员批准后才切换为 ``active``。
"""
from datetime import UTC, datetime
from typing import Any, Literal
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.contracts import RequestContext
from app.core.errors import GenericResourceNotFoundError, InvalidStateError
from app.infrastructure.db import SessionFactory
from app.model.audit import InteractionAudit
from app.model.memory import MemoryConflict, MemoryUnit
from app.service.agent.bootstrap import get_memory_cache_adapter
from app.service.authorization_service import AuthorizationService
from app.service.memory_service import MemoryService
CandidateDecision = Literal["confirmed", "rejected"]
ReviewDecision = Literal["approved", "rejected"]
USER_CONFIRM_PERMISSION = "memory:candidate:confirm"
ADMIN_REVIEW_PERMISSION = "memory:candidate:review"
class CustomerProfileCandidateService:
"""候选状态机的唯一应用服务入口。"""
async def list_for_customer(
self, context: RequestContext, *, limit: int = 20
) -> dict[str, Any]:
"""返回当前登录用户自己的候选,不暴露证据原文或其他客户数据。"""
await AuthorizationService.require(context, "memory:read:self")
customer_id = int(context.user_id)
async with SessionFactory() as session:
rows = await session.scalars(
select(MemoryUnit)
.where(
MemoryUnit.customer_id == customer_id,
MemoryUnit.status.in_(("candidate", "verified")),
)
.order_by(MemoryUnit.updated_at.desc())
.limit(max(1, min(limit, 100)))
)
data = [self._view(item) for item in rows]
return {"data": data, "meta": {"trace_id": context.trace_id}}
async def list_for_admin(
self, context: RequestContext, *, limit: int = 20
) -> dict[str, Any]:
"""管理员查看所有待处理候选;列表仍不返回证据原文。"""
await AuthorizationService.require(context, ADMIN_REVIEW_PERMISSION, admin=True)
async with SessionFactory() as session:
rows = await session.scalars(
select(MemoryUnit)
.where(MemoryUnit.status.in_(("candidate", "verified")))
.order_by(MemoryUnit.updated_at.asc())
.limit(max(1, min(limit, 100)))
)
data = [self._view(item) for item in rows]
return {"data": data, "meta": {"trace_id": context.trace_id}}
async def decide_by_customer(
self, candidate_id: int, decision: CandidateDecision, context: RequestContext
) -> dict[str, Any]:
"""用户确认或拒绝自己的候选;确认不会直接激活正式记忆。"""
await AuthorizationService.require(context, USER_CONFIRM_PERMISSION)
customer_id = int(context.user_id)
target_status = "verified" if decision == "confirmed" else "rejected"
async with SessionFactory() as session, session.begin():
candidate = await self._locked_candidate(session, candidate_id, customer_id)
if candidate.status != "candidate":
raise InvalidStateError("候选已处理,不能重复确认")
candidate.status = target_status
candidate.updated_at = self._now()
session.add(self._audit(
context, customer_id, "memory.candidate_user_decision",
{"candidate_id": candidate_id, "decision": decision},
))
await session.flush()
return {"data": self._view(candidate), "meta": {"trace_id": context.trace_id}}
async def review_by_admin(
self, candidate_id: int, decision: ReviewDecision, context: RequestContext,
*, comment: str = "",
) -> dict[str, Any]:
"""管理员审核候选;批准时处理同键正式记忆冲突并激活候选。"""
await AuthorizationService.require(context, ADMIN_REVIEW_PERMISSION, admin=True)
async with SessionFactory() as session, session.begin():
candidate = await self._locked_candidate(session, candidate_id, None)
if candidate.status not in {"candidate", "verified"}:
raise InvalidStateError("候选已处理,不能重复审核")
if decision == "rejected":
candidate.status = "rejected"
else:
await self._promote(session, candidate, int(context.user_id))
candidate.updated_at = self._now()
session.add(self._audit(
context, candidate.customer_id, "memory.candidate_admin_review",
{"candidate_id": candidate_id, "decision": decision, "comment": comment[:1000]},
))
await session.flush()
return {"data": self._view(candidate), "meta": {"trace_id": context.trace_id}}
async def _locked_candidate(
self, session: AsyncSession, candidate_id: int, customer_id: int | None
) -> MemoryUnit:
"""按身份范围加锁读取候选,找不到时统一隐藏资源存在性。"""
conditions = [MemoryUnit.id == candidate_id]
if customer_id is not None:
conditions.append(MemoryUnit.customer_id == customer_id)
candidate = await session.scalar(select(MemoryUnit).where(*conditions).with_for_update())
if candidate is None:
raise GenericResourceNotFoundError("候选不存在")
return candidate
async def _promote(
self, session: AsyncSession, candidate: MemoryUnit, reviewer_id: int
) -> None:
"""同一客户同一键只保留一条 active,旧值失效并留下冲突审计记录。"""
current = await session.scalar(
select(MemoryUnit)
.where(
MemoryUnit.customer_id == candidate.customer_id,
MemoryUnit.memory_key == candidate.memory_key,
MemoryUnit.status == "active",
)
.with_for_update()
)
now = self._now()
if current is not None and current.id != candidate.id:
current.status = "invalidated"
current.updated_at = now
session.add(MemoryConflict(
left_memory_id=current.id, right_memory_id=candidate.id,
conflict_type="candidate_promoted", severity="medium", status="resolved",
resolution="管理员审核候选后替换旧正式记忆", winner_memory_id=candidate.id,
resolved_by=reviewer_id, resolved_at=now, created_at=now,
))
candidate.status = "active"
candidate.promoted_at = now
candidate.version += 1
await MemoryService(session, cache=get_memory_cache_adapter()).invalidate_recall_cache(
int(candidate.customer_id)
)
@staticmethod
def _view(item: MemoryUnit) -> dict[str, Any]:
"""只返回结构化候选值,不返回对话证据摘录。"""
return {
"candidate_id": int(item.id),
"customer_id": str(item.customer_id),
"memory_key": item.memory_key,
"value": item.content,
"memory_type": item.memory_type,
"confidence": float(item.confidence),
"status": item.status,
"version": item.version,
"created_at": item.created_at.isoformat(),
"updated_at": item.updated_at.isoformat(),
}
@staticmethod
def _audit(
context: RequestContext, customer_id: int, action_type: str, detail: dict[str, Any]
) -> InteractionAudit:
"""统一生成候选状态变更审计,不携带证据原文。"""
return InteractionAudit(
actor_type="user", actor_id=int(context.user_id), target_customer_id=customer_id,
session_id=None, portal=context.portal, action_type=action_type,
detail={**detail, "trace_id": context.trace_id},
created_at=CustomerProfileCandidateService._now(),
)
@staticmethod
def _now() -> datetime:
"""使用 UTC 无时区值,与现有数据库 DATETIME 字段保持一致。"""
return datetime.now(UTC).replace(tzinfo=None)
+24 -1
View File
@@ -615,7 +615,26 @@ Authorization: Bearer <token>
记忆提取没有客户端写接口。`memory.extraction_requested` 由 `complete_run()` 与最终结果在同一事务写入 Outbox,再由 Worker 调用内部 `MemoryService`。更正、遗忘和监管删除属于独立隐私流程,本接口不临时复用 `memory_conflict`。
### 8.2 解析知识引用
### 8.2 客服画像候选(Phase 2)
客服 Agent 不读取或直接修改正式画像。已登录用户明确陈述长期偏好、约束或目标时,系统
异步生成 `memory_unit.status='candidate'` 候选;访客不会生成候选。候选不进入客服召回,
必须经过用户确认和管理员审核后才能晋升为 `active`。
```text
GET /api/v1/users/me/memory-candidates
POST /api/v1/users/me/memory-candidates/{candidate_id}/decisions
GET /api/v1/admin/customer-profile-candidates
POST /api/v1/admin/customer-profile-candidates/{candidate_id}/reviews
```
用户确认请求体为 `{ "decision": "confirmed" | "rejected" }`,需要
`memory:candidate:confirm`;确认只将状态改为 `verified`。管理员审核请求体复用
`ReviewPayload`,需要管理员角色和 `memory:candidate:review`;`approved` 会在事务内
处理同键旧记忆冲突并将候选改为 `active`,`rejected` 将其改为 `rejected`。接口只返回
结构化候选值,不返回对话证据摘录、密码、验证码或其他原始敏感内容。
### 8.3 解析知识引用
```http
GET /api/v1/knowledge-references/{reference_token}
@@ -931,6 +950,10 @@ GET /internal/metrics
| C007 | `POST /api/v1/conversation-messages/{message_id}/feedback` | `conversation:feedback` | 必须 | `201` | 反馈创建 |
| M001 | `GET /api/v1/users/me/memory-profile` | `memory:read:self` | 否 | `200` | 敏感访问 |
| M002 | `GET /api/v1/customers/{customer_id}/memory-profile` | `memory:read:customer` | 否 | `200` | 敏感访问 |
| M003 | `GET /api/v1/users/me/memory-candidates` | `memory:read:self` | 否 | `200` | 候选查询 |
| M004 | `POST /api/v1/users/me/memory-candidates/{candidate_id}/decisions` | `memory:candidate:confirm` | 必须 | `200` | 用户确认/拒绝 |
| A034 | `GET /api/v1/admin/customer-profile-candidates` | `memory:candidate:review` | 否 | `200` | 候选审核列表 |
| A035 | `POST /api/v1/admin/customer-profile-candidates/{candidate_id}/reviews` | `memory:candidate:review` | 必须 | `200` | 候选审核 |
| K001 | `GET /api/v1/knowledge-references/{reference_token}` | `knowledge:reference:read` | 否 | `200` | 否 |
| A001 | `POST /api/v1/admin/config-releases` | `config:write` | 必须 | `201` | 配置草稿 |
| A002 | `GET /api/v1/admin/config-releases` | `config:read` | 否 | `200` | 否 |
@@ -2,7 +2,7 @@
版本:v1.0
适用分支:`ZSY_develop2`
状态:候选提取链路已实现,确认/审核入口待后续迭代
状态:候选提取、用户确认和管理员审核入口已实现,画像快照同步待后续迭代
## 1. 业务边界
@@ -22,8 +22,10 @@
-> 二次脱敏
-> 受控模型抽取 memory_key/value/type/confidence
-> 写入 memory_unit(status='candidate') + memory_evidence
-> 等待用户确认或管理员审核
-> 后续流程再决定是否晋升为 active/profile_snapshots
-> 用户确认或拒绝
-> 管理员审核
-> 批准后处理同键冲突并晋升为 active
-> 后续流程再生成 profile_snapshots
```
普通公开问答、闲聊、一次性操作问题不触发候选抽取;访客、非客服 Agent、非 self 数据范围
@@ -58,25 +60,45 @@
- 访客候选事件必须被消费者拒绝,不能仅依赖上游路由判断。
- `MemoryRecallService` 只召回 `active` 状态,因此候选不会进入任何 Agent 的长期记忆上下文。
## 5. 当前已实现文件
## 5. 确认与审核接口
用户接口:
- `GET /api/v1/users/me/memory-candidates`:查看自己的 `candidate/verified` 候选。
- `POST /api/v1/users/me/memory-candidates/{candidate_id}/decisions`:提交
`confirmed` 或 `rejected`,需要 `memory:candidate:confirm`。
管理员接口:
- `GET /api/v1/admin/customer-profile-candidates`:查看所有待处理候选,需要管理员角色和
`memory:candidate:review`。
- `POST /api/v1/admin/customer-profile-candidates/{candidate_id}/reviews`:提交
`approved` 或 `rejected`,需要管理员角色和 `memory:candidate:review`。
用户确认只转换为 `verified`,管理员批准才转换为 `active`。批准时同客户同记忆键的旧
`active` 记录会失效,并写入 `memory_conflict`,全流程在一个 MySQL 事务内完成。
## 6. 当前已实现文件
- `app/service/memory_service.py`:支持候选状态写入,并保证候选不覆盖正式记忆。
- `app/worker/memory_extraction_worker.py`:支持事件类型、状态、来源和脱敏策略配置。
- `app/worker/customer_profile_candidate_worker.py`:已登录客服候选专用消费者。
- `app/service/agent_persistence_service.py`:完成客服运行时写入候选 Outbox 事件。
- `app/worker/runtime.py`:候选触发判定与事件处理器。
- `app/service/customer_profile_candidate_service.py`:用户确认、管理员审核、冲突处理和晋升。
- `app/api/controllers/public_platform.py`:用户候选查询和确认接口。
- `app/api/controllers/admin.py`:管理员候选查询和审核接口。
## 6. 尚未实现的后续能力
## 7. 尚未实现的后续能力
1. 用户确认候选的接口和页面。
2. 管理员候选列表、审核、驳回和审计接口。
3. 候选晋升为 `active` 的冲突检测、版本切换和 `profile_snapshots` 生成。
4. 候选撤回、过期、删除和隐私授权管理。
5. 候选流程的 MySQL 集成测试和管理员端到端验收。
1. 前端用户确认页面和管理员审核页面。
2. 候选晋升后的 `profile_snapshots` 版本生成和 `memory_sync_outbox` 同步。
3. 候选撤回、过期、删除和隐私授权管理。
4. 候选流程的 MySQL 集成测试和管理员端到端验收。
在上述能力完成前,禁止把 `candidate` 状态直接作为正式画像对外展示或用于业务决策。
## 7. 验证结果
## 8. 验证结果
- 客服画像候选专项测试:通过。
- 一期单元与契约回归:`683 passed`。
@@ -0,0 +1,144 @@
"""客户画像候选状态机的权限、输出和状态边界测试。"""
from datetime import UTC, datetime
from unittest.mock import AsyncMock
import pytest
from app.core.contracts import RequestContext
from app.core.errors import ForbiddenAgentError, InvalidStateError
from app.model.memory import MemoryUnit
from app.service.customer_profile_candidate_service import (
ADMIN_REVIEW_PERMISSION,
USER_CONFIRM_PERMISSION,
CustomerProfileCandidateService,
)
NOW = datetime.now(UTC).replace(tzinfo=None)
def candidate(*, status: str = "candidate") -> MemoryUnit:
"""构造不含原始证据的候选实体。"""
return MemoryUnit(
id=11, memory_uuid="candidate-11", customer_id=7,
memory_key="preference:risk_level", content="稳健型",
memory_type="preference", source_type="AI对话提取", source_confidence=0.9,
confidence=0.9, evidence_count=1, conflict_count=0, recall_count=0,
status=status, valid_from=NOW, version=1, created_at=NOW, updated_at=NOW,
)
@pytest.mark.asyncio
async def test_customer_confirmation_requires_dedicated_permission(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""用户确认不能借用客服运行权限,必须使用候选专用权限。"""
captured: list[tuple[str, bool]] = []
class RecordingAuthorization:
@staticmethod
async def require(context: RequestContext, permission: str, *, admin: bool = False) -> None:
del context
captured.append((permission, admin))
raise ForbiddenAgentError("stop at gate")
monkeypatch.setattr(
"app.service.customer_profile_candidate_service.AuthorizationService",
RecordingAuthorization,
)
with pytest.raises(ForbiddenAgentError):
await CustomerProfileCandidateService().decide_by_customer(
11, "confirmed", RequestContext(user_id="7", trace_id="candidate-test")
)
assert captured == [(USER_CONFIRM_PERMISSION, False)]
@pytest.mark.asyncio
async def test_admin_review_requires_admin_role_and_permission(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""管理员审核必须同时具备专用权限和管理员角色。"""
captured: list[tuple[str, bool]] = []
class RecordingAuthorization:
@staticmethod
async def require(context: RequestContext, permission: str, *, admin: bool = False) -> None:
del context
captured.append((permission, admin))
raise ForbiddenAgentError("stop at gate")
monkeypatch.setattr(
"app.service.customer_profile_candidate_service.AuthorizationService",
RecordingAuthorization,
)
with pytest.raises(ForbiddenAgentError):
await CustomerProfileCandidateService().review_by_admin(
11, "approved", RequestContext(user_id="9003", trace_id="candidate-admin")
)
assert captured == [(ADMIN_REVIEW_PERMISSION, True)]
def test_candidate_view_excludes_evidence_and_raw_customer_data() -> None:
"""对客和管理列表只返回结构化候选,不暴露证据字段。"""
data = CustomerProfileCandidateService._view(candidate())
assert data["candidate_id"] == 11
assert data["value"] == "稳健型"
assert "evidence_excerpt" not in data
assert "customer_id" in data
@pytest.mark.asyncio
async def test_candidate_transition_rejects_repeated_processing(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""已处理候选不能重复确认或审核。"""
class AllowingAuthorization:
require = AsyncMock()
monkeypatch.setattr(
"app.service.customer_profile_candidate_service.AuthorizationService",
AllowingAuthorization,
)
class Transaction:
async def __aenter__(self) -> None:
return None
async def __aexit__(self, *args: object) -> None:
return None
class FakeSession:
def __init__(self) -> None:
self.target = candidate(status="verified")
async def __aenter__(self) -> "FakeSession":
return self
async def __aexit__(self, *args: object) -> None:
return None
def begin(self) -> Transaction:
return Transaction()
async def scalar(self, statement: object) -> MemoryUnit:
del statement
return self.target
def add(self, item: object) -> None:
del item
async def flush(self) -> None:
return None
class FakeFactory:
def __call__(self) -> FakeSession:
return FakeSession()
monkeypatch.setattr(
"app.service.customer_profile_candidate_service.SessionFactory", FakeFactory()
)
with pytest.raises(InvalidStateError):
await CustomerProfileCandidateService().decide_by_customer(
11, "confirmed", RequestContext(user_id="7", trace_id="candidate-test")
)
+5 -1
View File
@@ -45,10 +45,14 @@ PERMISSIONS: tuple[tuple[int, str, str, str, str], ...] = (
(9016, "config:activate", "config", "activate", "all"),
(9017, "model-endpoint:manage", "model-endpoint", "manage", "all"),
(9018, "handover:read", "handover", "read", "all"),
(9019, "memory:candidate:confirm", "memory", "candidate_confirm", "self"),
(9020, "memory:candidate:review", "memory", "candidate_review", "all"),
)
# 客户:业务侧自助能力(自己的会话、反馈、转人工、自己的记忆画像)。
CUSTOMER_PERMISSIONS = (9001, 9002, 9003, 9004, 9005, 9006, 9007, 9008, 9009, 9011)
CUSTOMER_PERMISSIONS = (
9001, 9002, 9003, 9004, 9005, 9006, 9007, 9008, 9009, 9011, 9019
)
# 风控专员:业务侧只读 + 跨客户记忆 + 审计只读,不含配置写权限。
RISK_PERMISSIONS = (9001, 9002, 9003, 9010, 9011, 9012)
# 平台管理员:管理面全套(配置发布四态 + 模型端点 + 审计)。