共同祖先 bbf623a;主干 54 个提交、118 个文件;本线 25 个文件;9 个冲突文件。 主干这次把 **ZSY 的整条投影实现合进来了(PR #7)**,而本线此前的提交正是 移植并修正同一套代码 —— 因此冲突的本质是"同一功能两份实现并存",取舍错了会把 已修好的缺陷又带回来。逐项取舍与理由见 `docs/39-主干合并对策记录.md`。 ## 取舍(9 个冲突) 取本线: - `app/infrastructure/milvus_profile_projection.py` —— 主干是 ZSY 原版,含两处必炸点: ① `customer_id` 要求 int 而本仓所有生产者都写 `str` ⇒ 每个事件必然失败; ② 不可投影的 `memory_key` 直接 raise ⇒ 一条 `constraint:` 记忆毒死整客户整批。 本线版已放宽为「接受纯数字字符串」与「跳过并留痕」。 - `memory_sync_outbox_worker.py` / `conversation_privacy.py` / `risk_questionnaire.py` —— 代码逐行一致,仅注释与说明文字详略不同(`risk_questionnaire.py` 两边**独立做了 完全相同的修复**,都改成 re-export `app.model.profile`)。 - 两个投影测试文件 —— 本线是他那份的**超集**(4→10、4→5 例,包含他全部用例)。 两边合并: - `app/worker/runtime.py`:`__init__` 两边各加一个参数,都要。 - `app/service/agent/implementations/customer_service.py`:import 取并集; `COMPANY` 取主干的「奶龙基金责任有限公司」("奶龙"是本项目实际品牌名,主干多处出现), `HOTLINE`/`SERVICE_HOURS` **取本线的修复**(主干仍是占位符 `400-XXX-XXXX`, 本线已改为引用 `customer_service_rules` 的唯一来源 —— 这是 A1 缺陷修复, 否则同一客服给客户两个不同号码)。 - `AGENTS.md`:表数/Agent 清单取主干(90/89、7 个 Agent),本线的 `-X utf8` 与两条 outbox 易错点保留,测试基线按合并后实测重算。 ## 消费端只保留一套(本次最重要的一处) 合并后曾出现**两套消费者读同一个 `memory_sync_outbox`**:`__main__.py`(PR #7) 与 `runtime.consume_profile_projections()`(本线),而**两者的 neo4j handler 不同** —— 前者用 ZSY 的 `Neo4jProfileProjection`(按客户各建私有节点), 后者用主干 `ProfileGraphProjectionService`(共享 tag 节点、只投影已确认事实)。 同一事件被谁领到结果不定,等于"同一事实在图里有两种说法",正是**方案 A 要避免的状态**。 现只保留 runtime 那一套(带 `memory_sources` 兜底、neo4j 复用主干服务), 删除 `__main__.py` 的重复接线;装配入口职责仍在该文件(注入 `relationships` / `projection_cleaner`),Milvus 客户端由 `bootstrap` 工厂惰性构造、缺配置时显式降级。 副作用:`app/infrastructure/neo4j_profile_projection.py` 不再被生产代码引用,成为 **死代码**(本线未删,属架构师线,其单测仍在)—— 待架构师决定删或明确分工。 ## 顺带修掉的 3 个继承缺陷(主干同样存在,PR #7 后未整套复跑故未发现) 1. `tools/seed_test_rbac.py` **少建 `review_t`(9004)账号** —— 两个集成测试都依赖它 ("账号存在但无权限应返回 200 空集而非 404"、`PLACEHOLDER_ACCOUNTS`)。 同时把用户↔角色绑定从 `zip(..., strict=True)` 改为**显式配对表**:原写法隐含 "USERS 与 ROLES 一一对应",一加不绑角色的账号就 ValueError、整个种子跑不完 (commit 在最后,外部表现是"什么都没发生")。 2. `CustomerProfileCandidateService._write_profile_snapshot` **漏写 `current_customer_id`** —— 该列不是生成列而是普通可空列 + 唯一键 `uk_profile_snapshot_current`, 不写则唯一键形同虚设(多个 NULL 不冲突),且旧当前版本也没清该列、补写就会撞键。 现旧值置 None、新值显式写入(与 `ProfileGenerationService._clear_current` 一致)。 3. 集成测试前置未记录 —— 13 个登录/RBAC 用例因 401 而红,实为"测试账号不存在", 跑 `seed_test_rbac.py` + `set_user_password.py` 后转绿;已在 `AGENTS.md` 记明, 避免被误判成代码缺陷。 ## 文档 - 新增 `docs/39-主干合并对策记录.md`(逐文件取舍 + 理由 + 遗留) - `docs/37` 订正一处过时说法:曾写 `current_customer_id` 无人使用且故意不映射, 实际 `app/model/profile.py` 已映射且有人使用(详见该文档 §6.2 的订正块) - 文档编号:主干已占 29–36,本线两份文档让号至 `docs/37`、`docs/38` ## 验证(合并后实测) - `pytest tests`(全量)→ `2 failed, 1396 passed, 2 skipped` - `pytest tests/integration` → `102 passed, 1 skipped`(修上述 1、2 后从 15 failed 归零) - `mypy app` → `Success: no issues found in 245 source files` - `tools/audit_schema.py` → 89 张业务表无缺失/意外(未改动任何表结构) - `tools/check_authoritative_docs.py` → 52 份文档无编号冲突 - `tools/check_rbac_seed_consistency.py` → 通过 那 2 个失败是既有环境项(`test_offsite_document_recognition_adapter.py` 断言请求体 中文原文而 httpx 序列化成 `\uXXXX`),与本次合并无关。
270 lines
12 KiB
Python
270 lines
12 KiB
Python
"""客户画像候选的确认、审核与晋升服务。
|
|
|
|
候选与正式记忆共用 ``memory_unit``,但状态转换必须经过本服务;客服 Agent 不具备
|
|
调用权限。用户确认只把候选标记为 ``verified``,管理员批准后才切换为 ``active``。
|
|
"""
|
|
|
|
import hashlib
|
|
import json
|
|
from datetime import UTC, datetime
|
|
from typing import Any, Literal
|
|
from uuid import uuid4
|
|
|
|
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, MemorySyncOutbox, MemoryUnit
|
|
from app.model.profile import ProfileSnapshot
|
|
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 self._write_profile_snapshot(session, candidate, reviewer_id, now)
|
|
await MemoryService(session, cache=get_memory_cache_adapter()).invalidate_recall_cache(
|
|
int(candidate.customer_id)
|
|
)
|
|
|
|
async def _write_profile_snapshot(
|
|
self, session: AsyncSession, candidate: MemoryUnit, reviewer_id: int, now: datetime
|
|
) -> None:
|
|
"""生成新的当前画像版本,并为两个派生存储写入可靠同步事件。"""
|
|
current = await session.scalar(
|
|
select(ProfileSnapshot)
|
|
.where(
|
|
ProfileSnapshot.customer_id == candidate.customer_id,
|
|
ProfileSnapshot.is_current.is_(True),
|
|
)
|
|
.with_for_update()
|
|
)
|
|
previous = dict(current.snapshot) if current is not None else {}
|
|
preferences = dict(previous.get("customer_service_preferences", {}))
|
|
preferences[candidate.memory_key] = {
|
|
"value": candidate.content,
|
|
"memory_type": candidate.memory_type,
|
|
"confidence": float(candidate.confidence),
|
|
}
|
|
snapshot = {**previous, "customer_service_preferences": preferences}
|
|
version = (int(current.version) + 1) if current is not None else 1
|
|
profile_uuid = str(uuid4())
|
|
snapshot_hash = hashlib.sha256(
|
|
json.dumps(snapshot, ensure_ascii=False, sort_keys=True, separators=(",", ":"))
|
|
.encode("utf-8")
|
|
).hexdigest()
|
|
if current is not None:
|
|
current.is_current = False
|
|
# 必须**同时清空** `current_customer_id`:唯一键 `uk_profile_snapshot_current`
|
|
# 建在这一列上(不是 `is_current`),旧当前版本不清就会与新版本撞键。
|
|
# 与 `ProfileGenerationService._SQL_CLEAR_CURRENT` 的做法一致。
|
|
current.current_customer_id = None
|
|
current.updated_at = now
|
|
created = ProfileSnapshot(
|
|
profile_uuid=profile_uuid, customer_id=candidate.customer_id,
|
|
version=version, snapshot=snapshot,
|
|
generation_basis={
|
|
"source": "customer_profile_candidate",
|
|
"candidate_id": candidate.id,
|
|
"reviewer_id": reviewer_id,
|
|
},
|
|
snapshot_hash=snapshot_hash, is_current=True,
|
|
# 当前版本必须**显式写入**客户 ID(历史版本为 NULL),见 `app/model/profile.py`
|
|
# 的模块 docstring 第 2 条:该列不是生成列,不显式写就形同虚设,
|
|
# 「每个客户最多一条当前快照」这条不变式会失效。
|
|
current_customer_id=candidate.customer_id,
|
|
generated_at=now,
|
|
created_at=now, updated_at=now,
|
|
)
|
|
session.add(created)
|
|
await session.flush()
|
|
active_memories = list(await session.scalars(
|
|
select(MemoryUnit).where(
|
|
MemoryUnit.customer_id == candidate.customer_id,
|
|
MemoryUnit.status == "active",
|
|
)
|
|
))
|
|
memory_sources = [
|
|
{
|
|
"memory_uuid": item.memory_uuid,
|
|
"memory_key": item.memory_key,
|
|
"content": item.content,
|
|
"memory_type": item.memory_type,
|
|
"confidence": float(item.confidence),
|
|
"version": int(item.version),
|
|
"valid_until": item.valid_until.isoformat() if item.valid_until else None,
|
|
}
|
|
for item in active_memories
|
|
]
|
|
for target_store in ("milvus", "neo4j"):
|
|
session.add(MemorySyncOutbox(
|
|
event_uuid=str(uuid4()), aggregate_type="profile_snapshot",
|
|
aggregate_uuid=profile_uuid, aggregate_version=version,
|
|
target_store=target_store, operation="upsert",
|
|
payload={
|
|
"customer_id": candidate.customer_id,
|
|
"profile_uuid": profile_uuid,
|
|
"profile_version": version,
|
|
"snapshot": snapshot,
|
|
"memory_sources": memory_sources,
|
|
},
|
|
status="pending", retry_count=0, created_at=now,
|
|
))
|
|
|
|
@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)
|