merge: integrate ZSY customer service and profile capabilities

This commit is contained in:
张胜宇
2026-09-11 22:31:51 +08:00
94 changed files with 7933 additions and 77 deletions
@@ -51,6 +51,48 @@ async def test_accept_is_idempotent_and_persists_outbox() -> None:
await session.commit()
@pytest.mark.asyncio
async def test_customer_service_accept_redacts_sensitive_message_before_persistence() -> None:
"""客服原始凭据只能存在于请求瞬间,数据库会话中必须是脱敏文本。"""
session_id = f"privacy-{uuid4()}"
key = f"privacy-key-{uuid4()}"
raw_message = "验证码 123456,银行卡 6222021234567890123,登录密码: Secret123"
context = RequestContext(
user_id="1", trace_id=str(uuid4()), roles=("customer",), permissions=("agent:run",)
)
request = AgentRequest(
agent_type="customer_service", message=raw_message, session_id=session_id,
idempotency_key=key,
)
run_id = ""
async with SessionFactory() as session:
try:
accepted = await AgentRunApplicationService(session).accept(request, context)
run_id = accepted.run_id
message = await session.scalar(select(ConversationMessage).where(
ConversationMessage.session_id == session_id,
ConversationMessage.role == "user",
))
assert message is not None
assert "123456" not in message.content
assert "6222021234567890123" not in message.content
assert "Secret123" not in message.content
assert "验证码" in message.content
assert "银行卡号已隐藏" in message.content
finally:
if run_id:
run = await session.scalar(select(AgentRun).where(AgentRun.run_id == run_id))
if run is not None:
await session.execute(delete(AgentRun).where(AgentRun.id == run.id))
await session.execute(delete(RequestIdempotency).where(
RequestIdempotency.id == run.idempotency_id
))
await session.execute(delete(ConversationMessage).where(
ConversationMessage.session_id == session_id
))
await session.commit()
@pytest.mark.asyncio
async def test_concurrent_same_key_creates_at_most_one_run() -> None:
session_id = f"concurrent-{uuid4()}"
+69
View File
@@ -10,6 +10,8 @@ from app.infrastructure.db import SessionFactory
from app.model.audit import InteractionAudit
from app.model.conversation import ConversationMessage
from app.model.platform import AgentRun, DomainEventOutbox, RequestIdempotency
from app.model.risk import RiskUser
from app.model.session import ConversationSession
from app.service.agent_persistence_service import AgentPersistenceService
@@ -177,3 +179,70 @@ async def test_complete_run_rolls_back_every_write_on_outbox_conflict(monkeypatc
delete(ConversationMessage).where(ConversationMessage.session_id == session_id)
)
await session.commit()
@pytest.mark.integration
@pytest.mark.asyncio
async def test_customer_service_clarification_advances_real_session_round() -> None:
"""澄清计数必须在客服运行成功持久化的同一 MySQL 事务内递增。"""
now = datetime.now(UTC).replace(tzinfo=None)
session_id, trace_id, run_id = f"clarify-{uuid4()}", str(uuid4()), str(uuid4())
idem_id = 0
async with SessionFactory() as session:
# 会话表有真实的 sys_user 外键;测试只复用本地已有用户,绝不为此功能伪造账号。
user_id = await session.scalar(select(RiskUser.id).limit(1))
if user_id is None:
pytest.skip("本地 MySQL 没有可用 sys_user,无法验证会话外键链路")
session.add(ConversationSession(
session_id=session_id, user_id=user_id, agent_type="customer_service", portal="api",
status="active", clarification_round=0,
))
idem = RequestIdempotency(
user_id=user_id, session_id=session_id, agent_type="customer_service",
idempotency_key=f"clarify-key-{uuid4()}", request_hash="d" * 64,
trace_id=trace_id, status="processing", expire_at=now,
created_at=now, updated_at=now,
)
user_message = ConversationMessage(
session_id=session_id, customer_id=user_id, portal="api", role="user",
content="它的费率是多少", trace_id=trace_id, created_at=now,
)
session.add_all([idem, user_message])
await session.flush()
idem_id = idem.id
session.add(AgentRun(
run_id=run_id, idempotency_id=idem.id, session_id=session_id, user_id=user_id,
agent_type="customer_service", trace_id=trace_id,
request_message_id=user_message.id, created_at=now, updated_at=now,
))
await session.commit()
try:
async with SessionFactory() as session:
await AgentPersistenceService(session).complete_run(
run_id,
AgentResult(
run_id=run_id,
result=CoreResult(text="请提供产品名称或代码。", clarification_required=True),
),
memory_extraction_requested=False,
)
row = await session.scalar(select(ConversationSession).where(
ConversationSession.session_id == session_id
))
assert row is not None and row.clarification_round == 1
finally:
async with SessionFactory() as session:
await session.execute(
delete(DomainEventOutbox).where(DomainEventOutbox.aggregate_id == run_id)
)
await session.execute(delete(AgentRun).where(AgentRun.run_id == run_id))
await session.execute(
delete(RequestIdempotency).where(RequestIdempotency.id == idem_id)
)
await session.execute(
delete(ConversationMessage).where(ConversationMessage.session_id == session_id)
)
await session.execute(
delete(ConversationSession).where(ConversationSession.session_id == session_id)
)
await session.commit()
@@ -0,0 +1,88 @@
"""客户画像候选批准到画像快照的真实 MySQL 集成验证。"""
from datetime import UTC, datetime
from uuid import uuid4
import pytest
from sqlalchemy import delete, select
from app.infrastructure.db import SessionFactory
from app.model.memory import MemoryConflict, MemorySyncOutbox, MemoryUnit, ProfileSnapshot
from app.service.customer_profile_candidate_service import CustomerProfileCandidateService
pytestmark = pytest.mark.integration
@pytest.mark.asyncio
async def test_approved_candidate_creates_current_snapshot_and_outbox(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""批准候选必须原子生成当前画像快照和两个投影事件。"""
customer_id = uuid4().int % 10**15 + 10**15
now = datetime.now(UTC).replace(tzinfo=None)
async with SessionFactory() as session, session.begin():
candidate = MemoryUnit(
memory_uuid=str(uuid4()), customer_id=customer_id,
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="verified", valid_from=now,
version=1, created_at=now, updated_at=now,
)
session.add(candidate)
await session.flush()
candidate_id = int(candidate.id)
monkeypatch.setattr(
"app.service.customer_profile_candidate_service.get_memory_cache_adapter",
lambda: None,
)
try:
async with SessionFactory() as session, session.begin():
target = await session.scalar(
select(MemoryUnit).where(MemoryUnit.id == candidate_id).with_for_update()
)
assert target is not None
await CustomerProfileCandidateService()._promote(session, target, reviewer_id=9003)
async with SessionFactory() as session:
snapshot = await session.scalar(
select(ProfileSnapshot).where(
ProfileSnapshot.customer_id == customer_id,
ProfileSnapshot.current_customer_id == customer_id,
)
)
assert snapshot is not None
assert snapshot.version == 1
assert snapshot.snapshot["customer_service_preferences"][
"preference:risk_level"
]["value"] == "稳健型"
events = list(await session.scalars(
select(MemorySyncOutbox).where(
MemorySyncOutbox.aggregate_uuid == snapshot.profile_uuid
)
))
assert {event.target_store for event in events} == {"milvus", "neo4j"}
finally:
async with SessionFactory() as session, session.begin():
# Outbox uses the snapshot UUID as an aggregate reference; remove it first.
await session.execute(delete(MemorySyncOutbox).where(
MemorySyncOutbox.aggregate_uuid.in_(
select(ProfileSnapshot.profile_uuid).where(
ProfileSnapshot.customer_id == customer_id
)
)
))
# Delete snapshots by customer directly to avoid MySQL error 1093
# (target table referenced by its own subquery).
await session.execute(delete(ProfileSnapshot).where(
ProfileSnapshot.customer_id == customer_id
))
await session.execute(delete(MemoryConflict).where(
MemoryConflict.left_memory_id.in_(
select(MemoryUnit.id).where(MemoryUnit.customer_id == customer_id)
)
| MemoryConflict.right_memory_id.in_(
select(MemoryUnit.id).where(MemoryUnit.customer_id == customer_id)
)
))
await session.execute(delete(MemoryUnit).where(MemoryUnit.customer_id == customer_id))
@@ -0,0 +1,151 @@
"""转人工 Worker 消费与管理员只读查看的真实 MySQL 闭环回归。"""
import asyncio
from datetime import UTC, datetime
from typing import Any
from uuid import uuid4
import pytest
from fastapi.testclient import TestClient
from sqlalchemy import delete, select
from app.api.dependencies.auth import build_request_context
from app.core.contracts import RequestContext
from app.infrastructure.db import SessionFactory
from app.main import app
from app.model.audit import InteractionAudit
from app.model.platform import DomainEventOutbox, HandoverTicket, OutboxDelivery
from app.worker.runtime import WorkerRuntime
ADMIN_LIST_PATH = "/api/v1/admin/customer-service/handover-tickets"
ADMIN_DETAIL_PATH = "/api/v1/admin/customer-service/handover-tickets/{ticket_no}"
@pytest.mark.integration
def test_handover_outbox_is_consumed_and_admin_reads_only_sanitized_ticket() -> None:
"""事件消费保留 pending;管理面只给已脱敏转接信息,不回放原始会话。"""
now = datetime.now(UTC).replace(tzinfo=None)
session_id = f"it-handover-admin-{uuid4().hex}"
ticket_no = f"ticket-{uuid4().hex[:24]}"
event_id = str(uuid4())
async def prepare() -> None:
async with SessionFactory() as db:
db.add(HandoverTicket(
ticket_no=ticket_no,
session_id=session_id,
customer_id=None,
source_agent="customer_service",
intent="human_handover",
confidence=0.5,
priority="P1",
reason_code="human_handover",
reason_detail="验证码 123456,请人工联系",
conversation_summary="用户:银行卡 6222020202020202;助手:已转人工",
source_references=[{
"source_type": "knowledge",
"source_id": "FAQ-TEST",
"title": "公开测试知识",
"score": 0.9,
"internal_payload": "must-not-leak",
}],
status="pending",
created_at=now,
updated_at=now,
))
db.add(DomainEventOutbox(
event_id=event_id,
event_type="conversation.transfer_requested",
aggregate_type="conversation",
aggregate_id=session_id,
trace_id="handover-admin-integration-trace",
payload={"ticket_no": ticket_no},
occurred_at=now,
created_at=now,
updated_at=now,
))
await db.commit()
async def verify() -> tuple[HandoverTicket | None, DomainEventOutbox | None,
OutboxDelivery | None, list[InteractionAudit]]:
async with SessionFactory() as db:
ticket = await db.scalar(
select(HandoverTicket).where(HandoverTicket.ticket_no == ticket_no)
)
event = await db.scalar(
select(DomainEventOutbox).where(DomainEventOutbox.event_id == event_id)
)
delivery = await db.scalar(
select(OutboxDelivery).where(OutboxDelivery.event_id == event_id)
)
audits = list(await db.scalars(
select(InteractionAudit).where(
InteractionAudit.session_id == session_id,
InteractionAudit.action_type == "handover.queue_ready",
)
))
return ticket, event, delivery, audits
async def context() -> RequestContext:
return RequestContext(
user_id="9003",
trace_id="handover-admin-http-trace",
roles=("admin",),
permissions=("handover:read",),
)
asyncio.run(prepare())
app.dependency_overrides[build_request_context] = context
try:
assert asyncio.run(WorkerRuntime().dispatch_one(run_id=session_id))
ticket, event, delivery, audits = asyncio.run(verify())
assert ticket is not None and ticket.status == "pending"
assert event is not None and event.status == "published"
assert delivery is not None
assert delivery.consumer_name == "conversation.transfer_requested"
assert len(audits) == 1
assert audits[0].detail["ticket_status"] == "pending"
with TestClient(app) as client:
list_response = client.get(ADMIN_LIST_PATH)
detail_response = client.get(ADMIN_DETAIL_PATH.format(ticket_no=ticket_no))
assert list_response.status_code == 200, list_response.text
listed = next(
item for item in list_response.json()["data"] if item["ticket_no"] == ticket_no
)
assert listed["status"] == "pending"
assert "conversation_summary" not in listed
assert detail_response.status_code == 200, detail_response.text
detail: dict[str, Any] = detail_response.json()["data"]
assert detail["status"] == "pending"
assert detail["reason_detail"] == "验证码[已隐藏],请人工联系"
assert detail["conversation_summary"] == "用户:银行卡 [银行卡号已隐藏];助手:已转人工"
assert detail["source_references"] == [{
"source_type": "knowledge",
"source_id": "FAQ-TEST",
"title": "公开测试知识",
"score": 0.9,
}]
assert "customer_id" not in detail
assert "internal_payload" not in str(detail)
finally:
app.dependency_overrides.clear()
async def cleanup() -> None:
async with SessionFactory() as db:
await db.execute(delete(OutboxDelivery).where(OutboxDelivery.event_id == event_id))
await db.execute(
delete(DomainEventOutbox).where(DomainEventOutbox.event_id == event_id)
)
await db.execute(delete(InteractionAudit).where(
InteractionAudit.session_id == session_id,
InteractionAudit.action_type == "handover.queue_ready",
))
await db.execute(
delete(HandoverTicket).where(HandoverTicket.ticket_no == ticket_no)
)
await db.commit()
asyncio.run(cleanup())
@@ -36,8 +36,8 @@ async def test_http_accept_worker_commit_query_and_repeat(acceptance_registry, r
async with httpx.AsyncClient(transport=httpx.ASGITransport(app=app),
base_url="http://test") as client:
try:
# 消息必须命中显式记忆信号:P2 之后触发判定改为事件/事实驱动,
# 无信号的普通消息不再产生 memory.extraction_requested,而下方断言依赖该事件。
# 该消息故意带有偏好信号,验证客服仍不进入长期记忆抽取;客服当前仅允许
# 使用经过脱敏的会话短期上下文,不能沉淀为画像或跨会话偏好。
response = await client.post("/api/v1/agent-runs", json={
"agent_type": "customer_service",
"message": "hello runtime,我的风险偏好是稳健型",
@@ -65,7 +65,7 @@ async def test_http_accept_worker_commit_query_and_repeat(acceptance_registry, r
events = list(await session.scalars(select(DomainEventOutbox).where(
DomainEventOutbox.aggregate_id == run_id,
DomainEventOutbox.event_type == "memory.extraction_requested")))
assert len(events) == (0 if revoked else 1)
assert events == []
finally:
await cleanup(session_id, run_id)