feat: generate approved profile snapshots
This commit is contained in:
@@ -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"}
|
||||||
|
|||||||
Reference in New Issue
Block a user