feat: generate approved profile snapshots

This commit is contained in:
张胜宇
2026-09-11 17:58:46 +08:00
parent a769130658
commit 27174b9ce2
4 changed files with 128 additions and 6 deletions
+19
View File
@@ -75,6 +75,25 @@ class MemorySyncOutbox(Base):
processed_at: Mapped[datetime | None] = mapped_column(DateTime) processed_at: Mapped[datetime | None] = mapped_column(DateTime)
class ProfileSnapshot(Base):
"""客户画像版本快照;只有审核通过的候选才能生成当前版本。"""
__tablename__ = "profile_snapshots"
id: Mapped[int] = mapped_column(BigInteger, primary_key=True)
profile_uuid: Mapped[str | None] = mapped_column(String(36), unique=True)
customer_id: Mapped[int] = mapped_column(BigInteger, nullable=False)
version: Mapped[int] = mapped_column(BigInteger, nullable=False)
snapshot: Mapped[dict[str, Any]] = mapped_column(JSON, nullable=False)
generation_basis: Mapped[dict[str, Any] | None] = mapped_column(JSON)
snapshot_hash: Mapped[str | None] = mapped_column(String(64))
is_current: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
current_customer_id: Mapped[int | None] = mapped_column(BigInteger, unique=True)
generated_at: Mapped[datetime | None] = mapped_column(DateTime)
created_at: Mapped[datetime] = mapped_column(DateTime, nullable=False)
updated_at: Mapped[datetime] = mapped_column(DateTime, nullable=False)
class MemoryConflict(Base): class MemoryConflict(Base):
"""记忆冲突;库列名为 `status`,`resolution`/`severity`/`resolved_by` 必须映射。""" """记忆冲突;库列名为 `status`,`resolution`/`severity`/`resolved_by` 必须映射。"""
@@ -4,8 +4,11 @@
调用权限。用户确认只把候选标记为 ``verified``,管理员批准后才切换为 ``active``。 调用权限。用户确认只把候选标记为 ``verified``,管理员批准后才切换为 ``active``。
""" """
import hashlib
import json
from datetime import UTC, datetime from datetime import UTC, datetime
from typing import Any, Literal from typing import Any, Literal
from uuid import uuid4
from sqlalchemy import select from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
@@ -14,7 +17,7 @@ from app.core.contracts import RequestContext
from app.core.errors import GenericResourceNotFoundError, InvalidStateError from app.core.errors import GenericResourceNotFoundError, InvalidStateError
from app.infrastructure.db import SessionFactory from app.infrastructure.db import SessionFactory
from app.model.audit import InteractionAudit from app.model.audit import InteractionAudit
from app.model.memory import MemoryConflict, MemoryUnit from app.model.memory import MemoryConflict, MemorySyncOutbox, MemoryUnit, ProfileSnapshot
from app.service.agent.bootstrap import get_memory_cache_adapter from app.service.agent.bootstrap import get_memory_cache_adapter
from app.service.authorization_service import AuthorizationService from app.service.authorization_service import AuthorizationService
from app.service.memory_service import MemoryService from app.service.memory_service import MemoryService
@@ -142,10 +145,69 @@ class CustomerProfileCandidateService:
candidate.status = "active" candidate.status = "active"
candidate.promoted_at = now candidate.promoted_at = now
candidate.version += 1 candidate.version += 1
await self._write_profile_snapshot(session, candidate, reviewer_id, now)
await MemoryService(session, cache=get_memory_cache_adapter()).invalidate_recall_cache( await MemoryService(session, cache=get_memory_cache_adapter()).invalidate_recall_cache(
int(candidate.customer_id) 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.current_customer_id == candidate.customer_id,
)
.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 = 0
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=1,
current_customer_id=candidate.customer_id, generated_at=now,
created_at=now, updated_at=now,
)
session.add(created)
await session.flush()
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,
},
status="pending", retry_count=0, created_at=now,
))
@staticmethod @staticmethod
def _view(item: MemoryUnit) -> dict[str, Any]: def _view(item: MemoryUnit) -> dict[str, Any]:
"""只返回结构化候选值,不返回对话证据摘录。""" """只返回结构化候选值,不返回对话证据摘录。"""
@@ -2,7 +2,7 @@
版本:v1.0 版本:v1.0
适用分支:`ZSY_develop2` 适用分支:`ZSY_develop2`
状态:候选提取、用户确认和管理员审核入口已实现,画像快照同步待后续迭代 状态:候选提取、用户确认、管理员审核、画像快照生成和投影事件写入已实现
## 1. 业务边界 ## 1. 业务边界
@@ -25,7 +25,7 @@
-> 用户确认或拒绝 -> 用户确认或拒绝
-> 管理员审核 -> 管理员审核
-> 批准后处理同键冲突并晋升为 active -> 批准后处理同键冲突并晋升为 active
-> 后续流程再生成 profile_snapshots -> 生成 profile_snapshots 并写入 memory_sync_outbox
``` ```
普通公开问答、闲聊、一次性操作问题不触发候选抽取;访客、非客服 Agent、非 self 数据范围 普通公开问答、闲聊、一次性操作问题不触发候选抽取;访客、非客服 Agent、非 self 数据范围
@@ -89,10 +89,13 @@
- `app/api/controllers/public_platform.py`:用户候选查询和确认接口。 - `app/api/controllers/public_platform.py`:用户候选查询和确认接口。
- `app/api/controllers/admin.py`:管理员候选查询和审核接口。 - `app/api/controllers/admin.py`:管理员候选查询和审核接口。
管理员批准后,服务会创建新的 `profile_snapshots` 当前版本,并为 `milvus`、`neo4j` 各写入
一条 `memory_sync_outbox` 待投影事件;旧当前版本在同一事务内标记为非当前。
## 7. 尚未实现的后续能力 ## 7. 尚未实现的后续能力
1. 前端用户确认页面和管理员审核页面。 1. 前端用户确认页面和管理员审核页面。
2. 候选晋升后的 `profile_snapshots` 版本生成和 `memory_sync_outbox` 同步。 2. `memory_sync_outbox` 到真实 Milvus/Neo4j 的消费者联调和失败重试验收。
3. 候选撤回、过期、删除和隐私授权管理。 3. 候选撤回、过期、删除和隐私授权管理。
4. 候选流程的 MySQL 集成测试和管理员端到端验收。 4. 候选流程的 MySQL 集成测试和管理员端到端验收。
@@ -101,6 +104,6 @@
## 8. 验证结果 ## 8. 验证结果
- 客服画像候选专项测试:通过。 - 客服画像候选专项测试:通过。
- 一期单元与契约回归:`683 passed`。 - 一期单元与契约回归:`687 passed`。
- Ruff:通过。 - Ruff:通过。
- Mypy:通过。 - Mypy:通过。
@@ -7,7 +7,7 @@ import pytest
from app.core.contracts import RequestContext from app.core.contracts import RequestContext
from app.core.errors import ForbiddenAgentError, InvalidStateError from app.core.errors import ForbiddenAgentError, InvalidStateError
from app.model.memory import MemoryUnit from app.model.memory import MemorySyncOutbox, MemoryUnit, ProfileSnapshot
from app.service.customer_profile_candidate_service import ( from app.service.customer_profile_candidate_service import (
ADMIN_REVIEW_PERMISSION, ADMIN_REVIEW_PERMISSION,
USER_CONFIRM_PERMISSION, USER_CONFIRM_PERMISSION,
@@ -142,3 +142,41 @@ async def test_candidate_transition_rejects_repeated_processing(
await CustomerProfileCandidateService().decide_by_customer( await CustomerProfileCandidateService().decide_by_customer(
11, "confirmed", RequestContext(user_id="7", trace_id="candidate-test") 11, "confirmed", RequestContext(user_id="7", trace_id="candidate-test")
) )
@pytest.mark.asyncio
async def test_profile_snapshot_and_projection_events_are_created_on_promotion(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""批准候选必须生成新画像版本和两个待投影事件。"""
class FakeSession:
def __init__(self) -> None:
self.added: list[object] = []
async def scalar(self, statement: object) -> None:
del statement
return None
def add(self, item: object) -> None:
self.added.append(item)
async def flush(self) -> None:
return None
session = FakeSession()
target = candidate()
monkeypatch.setattr(
"app.service.customer_profile_candidate_service.get_memory_cache_adapter",
lambda: None,
)
await CustomerProfileCandidateService()._promote(session, target, reviewer_id=9003)
assert target.status == "active"
snapshot = next(item for item in session.added if isinstance(item, ProfileSnapshot))
assert snapshot.is_current == 1
assert snapshot.version == 1
value = snapshot.snapshot["customer_service_preferences"]["preference:risk_level"]["value"]
assert value == "稳健型"
events = [item for item in session.added if isinstance(item, MemorySyncOutbox)]
assert {item.target_store for item in events} == {"milvus", "neo4j"}