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
@@ -8,6 +8,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
from app.core.errors import RecoverableAgentError
from app.model.conversation import ConversationMessage
from app.service.memory_extraction_service import ExtractedMemory
from app.worker.customer_profile_candidate_worker import CustomerProfileCandidateWorker
from app.worker.memory_extraction_worker import MemoryExtractionWorker
NOW = datetime(2026, 9, 9, 0, 0, 0, tzinfo=UTC).replace(tzinfo=None)
@@ -194,3 +195,40 @@ async def test_missing_message_is_skipped_without_write() -> None:
assert not await MemoryExtractionWorker(session, extractor=StubExtractor(EXTRACTED)).handle(
{"run_id": "run-1", "message_id": 11, "customer_id": 7}, event_id="event-1")
session.add.assert_not_called()
@pytest.mark.asyncio
async def test_profile_candidate_is_sanitized_and_not_active() -> None:
"""客服画像候选只保存脱敏证据,并且状态必须是 candidate。"""
session = link()
added: list[Any] = []
session.add = Mock(side_effect=added.append)
extractor = StubExtractor(EXTRACTED)
assert await CustomerProfileCandidateWorker(session, extractor=extractor).handle(
{
"run_id": "run-1", "message_id": 11, "customer_id": 7,
"actor_type": "authenticated_customer",
},
event_id="event-candidate",
)
memory = next(item for item in added if type(item).__name__ == "MemoryUnit")
evidence = next(item for item in added if type(item).__name__ == "MemoryEvidence")
assert memory.status == "candidate"
assert memory.source_type == "AI对话提取"
assert evidence.idempotency_key == "customer_profile.candidate_requested:event-candidate"
assert extractor.calls == [USER_FACT]
@pytest.mark.asyncio
async def test_profile_candidate_rejects_visitor_event() -> None:
"""消费者对缺少已登录身份标记的事件失败关闭。"""
session = link()
extractor = StubExtractor(EXTRACTED)
assert not await CustomerProfileCandidateWorker(session, extractor=extractor).handle(
{"run_id": "run-1", "message_id": 11, "customer_id": 7, "actor_type": "visitor"},
event_id="event-visitor",
)
assert extractor.calls == []
@@ -0,0 +1,100 @@
from datetime import datetime
import pytest
from app.model.memory import MemorySyncOutbox
from app.worker.memory_sync_outbox_worker import MemorySyncOutboxWorker
class FakeSession:
def __init__(self, event: MemorySyncOutbox | None) -> None:
self.event = event
self.commits = 0
self.rollbacks = 0
async def __aenter__(self) -> "FakeSession":
return self
async def __aexit__(self, *args: object) -> None:
return None
async def scalar(self, statement: object) -> MemorySyncOutbox | None:
del statement
return self.event
async def commit(self) -> None:
self.commits += 1
async def rollback(self) -> None:
self.rollbacks += 1
def event(*, target: str = "neo4j", retry_count: int = 0) -> MemorySyncOutbox:
return MemorySyncOutbox(
id=1, event_uuid="event-1", aggregate_type="profile_snapshot",
aggregate_uuid="profile-1", aggregate_version=1, target_store=target,
operation="upsert", payload={"customer_id": 7}, status="pending",
retry_count=retry_count, next_retry_at=None, last_error=None,
created_at=datetime(2026, 1, 1), processed_at=None,
)
@pytest.mark.asyncio
async def test_success_marks_event_processed() -> None:
item = event()
session = FakeSession(item)
seen: list[dict[str, object]] = []
async def handler(payload: dict[str, object]) -> None:
seen.append(payload)
worker = MemorySyncOutboxWorker({"neo4j": handler}, session_factory=lambda: session)
assert await worker.run_once() is True
assert seen == [{"customer_id": 7}]
assert item.status == "processed"
assert item.processed_at is not None
assert session.commits == 1
@pytest.mark.asyncio
async def test_handler_failure_uses_backoff_and_keeps_event() -> None:
item = event()
session = FakeSession(item)
async def handler(payload: dict[str, object]) -> None:
del payload
raise TimeoutError
worker = MemorySyncOutboxWorker({"neo4j": handler}, session_factory=lambda: session)
assert await worker.run_once() is True
assert item.status == "failed"
assert item.retry_count == 1
assert item.next_retry_at is not None
assert item.last_error == "TimeoutError"
@pytest.mark.asyncio
async def test_fifth_failure_enters_dead_state() -> None:
item = event(retry_count=4)
session = FakeSession(item)
async def handler(payload: dict[str, object]) -> None:
del payload
raise RuntimeError
worker = MemorySyncOutboxWorker({"neo4j": handler}, session_factory=lambda: session)
await worker.run_once()
assert item.status == "dead"
assert item.retry_count == 5
assert item.next_retry_at is None
@pytest.mark.asyncio
async def test_missing_handler_enters_dead_state_without_external_call() -> None:
item = event(target="milvus")
session = FakeSession(item)
worker = MemorySyncOutboxWorker({"neo4j": lambda _: None}, session_factory=lambda: session)
assert await worker.run_once() is True
assert item.status == "dead"
assert item.last_error == "target_handler_not_configured"
@@ -1,9 +1,13 @@
from contextlib import AbstractAsyncContextManager
from datetime import UTC, datetime
from typing import Any
from unittest.mock import AsyncMock
from unittest.mock import AsyncMock, Mock
import pytest
from app.core.contracts import AgentResult, CoreResult, RequestContext
from app.model.platform import HandoverTicket
from app.service.customer_service_session_memory_service import CustomerServiceSessionTurn
from app.service.memory_recall_service import MemoryRecallService
from app.service.model_gateway import ModelGenerationService
from app.worker.runtime import WorkerRuntime
@@ -14,14 +18,129 @@ PAYLOAD: dict[str, Any] = {
OUTBOX = {
"agent.run_requested",
"memory.extraction_requested",
"customer_profile.candidate_requested",
"agent.run_completed",
"config.cache_invalidate_requested",
# 投影清理事件必须有消费者,否则 memory.invalidated/memory.deleted 永久 pending。
"memory.invalidated",
"memory.deleted",
"conversation.transfer_requested",
}
@pytest.mark.asyncio
async def test_worker_restores_visitor_without_identity_repository_call() -> None:
"""访客异步任务只能恢复最小公开上下文,不能查询正式身份库。"""
runtime = WorkerRuntime.__new__(WorkerRuntime)
runtime.resolve_identity = AsyncMock(side_effect=AssertionError("identity lookup is forbidden"))
context = await runtime.restore_context(
actor_type="visitor", actor_id="visitor:test", trace_id="trace-test"
)
assert context.roles == ("visitor",)
assert context.permissions == ("agent:run", "knowledge:query")
assert context.data_scope == "public"
runtime.resolve_identity.assert_not_awaited()
def test_visitor_does_not_request_memory_extraction() -> None:
"""访客消息即使包含偏好信号,也不能进入客户记忆抽取队列。"""
context = RequestContext(
user_id="visitor:test", trace_id="visitor-trace", roles=("visitor",),
permissions=("agent:run",), data_scope="public",
)
result = AgentResult(run_id="visitor-run", result=CoreResult(text="公开答复"))
requested = WorkerRuntime.should_request_memory_extraction(
agent_type="customer_service", context=context,
message="我的风险偏好是稳健型",
result=result,
business_events=(),
)
assert requested is False
def test_authenticated_customer_service_does_not_request_memory_extraction() -> None:
"""客服消息即使命中长期记忆信号,也不能进入客户画像抽取队列。"""
context = RequestContext(
user_id="7", trace_id="customer-trace", roles=("customer",),
permissions=("agent:run",), data_scope="self",
)
result = AgentResult(run_id="customer-run", result=CoreResult(text="公开答复"))
requested = WorkerRuntime.should_request_memory_extraction(
agent_type="customer_service", context=context, message="我的风险偏好是稳健型",
result=result, business_events=("trade.completed",),
)
assert requested is False
def test_authenticated_customer_service_requests_profile_candidate() -> None:
"""已登录客户明确陈述偏好时只生成候选,不复用正式记忆事件。"""
context = RequestContext(
user_id="7", trace_id="customer-trace", roles=("customer",),
permissions=("agent:run",), data_scope="self",
)
assert WorkerRuntime.should_request_profile_candidate(
agent_type="customer_service", context=context, message="我的风险偏好是稳健型"
) is True
def test_visitor_does_not_request_profile_candidate() -> None:
"""访客即使陈述偏好也不能创建画像候选。"""
context = RequestContext(
user_id="visitor:test", trace_id="visitor-trace", roles=("visitor",),
permissions=("agent:run",), data_scope="public",
)
assert WorkerRuntime.should_request_profile_candidate(
agent_type="customer_service", context=context, message="我的风险偏好是稳健型"
) is False
@pytest.mark.asyncio
async def test_customer_service_short_memory_append_is_available_on_runtime() -> None:
"""Worker 的短期会话依赖与长期画像抽取依赖必须彼此独立。"""
captured: dict[str, Any] = {}
class StubSessionMemory:
async def append(
self, *, actor_id: str, session_id: str,
turns: tuple[CustomerServiceSessionTurn, ...],
) -> bool:
captured["actor_id"] = actor_id
captured["session_id"] = session_id
captured["turns"] = turns
return True
async def read(self, *, actor_id: str, session_id: str) -> Any:
raise AssertionError("not used by worker write path")
runtime = WorkerRuntime(session_memory=StubSessionMemory())
await runtime._append_customer_service_session_memory(
agent_type="customer_service", actor_id="7", session_id="short-memory-session",
request_message="刚才那只基金的费率", response_message="请以产品资料页为准。",
)
await runtime._append_customer_service_session_memory(
agent_type="risk", actor_id="7", session_id="other-agent-session",
request_message="不应写入", response_message="不应写入",
)
assert captured == {
"actor_id": "7",
"session_id": "short-memory-session",
"turns": (
CustomerServiceSessionTurn(role="user", content="刚才那只基金的费率"),
CustomerServiceSessionTurn(role="assistant", content="请以产品资料页为准。"),
),
}
class FakeSession(AbstractAsyncContextManager["FakeSession"]):
def __init__(self) -> None:
self.scalar = AsyncMock(return_value="event-1")
@@ -117,6 +236,54 @@ async def test_incomplete_extraction_payload_is_rejected(
assert captured
@pytest.mark.asyncio
async def test_handover_event_marks_internal_queue_ready_without_mutating_ticket(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""消费成功只意味着管理员队列可查看,不能伪造人工已接单或完成。"""
session = FakeSession()
ticket = HandoverTicket(
id=31,
ticket_no="ticket-worker-test",
session_id="handover-session",
customer_id=7,
source_agent="customer_service",
reason_code="human_handover",
status="pending",
created_at=datetime.now(UTC).replace(tzinfo=None),
updated_at=datetime.now(UTC).replace(tzinfo=None),
)
session.scalar = AsyncMock(return_value=ticket)
session.add = Mock()
session.flush = AsyncMock()
monkeypatch.setattr("app.worker.runtime.SessionFactory", lambda: session)
class FakeOutboxWorker:
def __init__(self, session: Any, handlers: dict[str, Any]) -> None:
self.handlers = handlers
async def publish_one(self, *, aggregate_id: str | None = None) -> bool:
del aggregate_id
await self.handlers["conversation.transfer_requested"]({
"ticket_no": "ticket-worker-test",
})
return True
monkeypatch.setattr("app.worker.runtime.OutboxWorker", FakeOutboxWorker)
assert await WorkerRuntime().dispatch_one()
assert ticket.status == "pending"
audit = session.add.call_args.args[0]
assert audit.action_type == "handover.queue_ready"
assert audit.detail == {
"ticket_no": "ticket-worker-test",
"source_agent": "customer_service",
"reason_code": "human_handover",
"ticket_status": "pending",
}
session.flush.assert_awaited_once()
@pytest.mark.asyncio
async def test_cache_invalidate_deletes_release_and_memory_keys() -> None:
"""失效动作必须打在召回热缓存的**真实**键上(此前手写的 mem:mid:hot 无写入方)。"""