feat: add profile candidate review workflow
This commit is contained in:
@@ -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
|
||||
)
|
||||
|
||||
@@ -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
@@ -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")
|
||||
)
|
||||
@@ -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)
|
||||
# 平台管理员:管理面全套(配置发布四态 + 模型端点 + 审计)。
|
||||
|
||||
Reference in New Issue
Block a user