merge: integrate ZSY customer service and profile capabilities
This commit is contained in:
@@ -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()}"
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user