Files
meida_backend/tests/unit/test_agent_service.py
T
xsl 7bcbb8d40b
CI / lint / typecheck / import-linter / tests / secrets (push) Canceled after 0s
feat(agent,report): 会话持久化与画像注入、报告反馈与预约留资——PM/开发双Agent迭代1-3
- agent: ag_conversation/ag_message 表(迁移0007),上下文组装注入最近报告画像摘要
  (report.profile.latest_profile_summary 经 apps 层闭包注入,同层不互调),历史带最近
  AGENT_HISTORY_TURNS=10 轮;POST/GET /agent/conversations 与消息历史接口
- agent 修复:ChatRequest.conversation_id 改 str(雪花ID超2^53,JS Number丢精度);
  _build_context 挪进 reserve commit 后同一 try,异常走 refund 兜底
- report: rp_feedback(维度反馈 upsert,认同率/修正率 SQL 可算,T-P3-21 最小版——
  L1 记忆未建,不回写结论)+ rp_reservation(预约留资幂等,T-P3-22,预约率口径
  行数/full_report.card_view 去重曝光,门槛≥8%),迁移0008
- 测试:全量 606 passed;ruff/mypy/lint-imports 全绿;E2E 真实 deepseek-v4-flash 验证
  画像注入与历史携带
2026-09-11 21:50:58 +08:00

332 lines
12 KiB
Python

"""AgentService 编排测试:repo 层打桩 + 假网关/假计费,不连库不发网。"""
from __future__ import annotations
from decimal import Decimal
from typing import Any
import pytest
from packages.agent import repo
from packages.agent.service import AgentService
from packages.billing.service import Reservation
from packages.llm.types import Completion, Scene, Usage
from packages.shared.errors import NotFoundError
from packages.shared.models import AgConversation
USER_ID = 7001
CONVERSATION_ID = 9001
class FakeSession:
def __init__(self) -> None:
self.commits = 0
self.rollbacks = 0
async def commit(self) -> None:
self.commits += 1
async def rollback(self) -> None:
self.rollbacks += 1
class FakeGateway:
def __init__(self) -> None:
self.calls: list[dict[str, Any]] = []
async def complete(self, **kwargs: Any) -> Completion:
self.calls.append(kwargs)
return Completion(
text="可以试试白衬衫加深蓝直筒裤。",
scene=Scene.AGENT_CHAT,
provider="deepseek",
model="deepseek-v4-flash",
prompt_id="agent_chat",
prompt_version="v1",
usage=Usage(tokens_in=100, tokens_out=50, tokens_cached=0, tokens_reasoning=0),
latency_ms=800,
cost_cents=Decimal("0.05"),
call_id=555,
)
class FailingGateway(FakeGateway):
async def complete(self, **kwargs: Any) -> Completion:
raise RuntimeError("provider exploded")
class FakeBilling:
def __init__(self) -> None:
self.refunds: list[str] = []
async def reserve(self, **kwargs: Any) -> Reservation:
return Reservation(
tx_id=1,
user_id=kwargs["user_id"],
quota_key=kwargs["quota_key"],
period_key="day:2026-09-11",
amount=1,
remaining=9,
idempotency_key="k",
)
async def commit(self, reservation: Reservation) -> None:
pass
async def refund(self, reservation: Reservation, *, reason: str) -> None:
self.refunds.append(reason)
@pytest.fixture
def store(monkeypatch: pytest.MonkeyPatch) -> dict[str, Any]:
"""repo 层全打桩:会话与消息落进内存 dict,断言编排逻辑而非 SQL。"""
state: dict[str, Any] = {
"conversations": {
CONVERSATION_ID: AgConversation(
conversation_id=str(CONVERSATION_ID),
user_id=USER_ID,
title="",
message_count=0,
last_message_at=None,
)
},
"messages": [],
"history": [],
"next_id": 100,
}
def _next_id() -> int:
state["next_id"] += 1
return state["next_id"]
async def fake_create(session: Any, *, conversation_id: int, user_id: int) -> AgConversation:
conv = AgConversation(
conversation_id=conversation_id,
user_id=user_id,
title="",
message_count=0,
last_message_at=None,
)
state["conversations"][conversation_id] = conv
return conv
async def fake_get(session: Any, conversation_id: int) -> AgConversation | None:
return state["conversations"].get(conversation_id)
async def fake_recent(session: Any, *, conversation_id: int, limit: int) -> list[Any]:
return state["history"]
async def fake_append(
session: Any,
*,
message_id: int,
conversation: AgConversation,
role: str,
content: str,
**kwargs: Any,
) -> Any:
state["messages"].append({"role": role, "content": content, **kwargs})
conversation.message_count += 1
if not conversation.title and role == "user":
conversation.title = content[:20]
return None
monkeypatch.setattr(repo, "create_conversation", fake_create)
monkeypatch.setattr(repo, "get_conversation", fake_get)
monkeypatch.setattr(repo, "list_recent_messages", fake_recent)
monkeypatch.setattr(repo, "append_message", fake_append)
monkeypatch.setattr("packages.agent.service.next_id", _next_id)
return state
def _service(store: dict[str, Any], **overrides: Any) -> AgentService:
kwargs: dict[str, Any] = {
"gateway": FakeGateway(),
"billing": FakeBilling(),
"profile_provider": None,
"history_turns": 10,
}
kwargs.update(overrides)
return AgentService(FakeSession(), **kwargs) # type: ignore[arg-type]
class TestChat:
async def test_implicit_conversation_created_and_messages_persisted(
self, store: dict[str, Any]
) -> None:
service = _service(store)
result = await service.chat(user_id=USER_ID, message="面试怎么穿", request_key="k1")
# 隐式建会话,响应带回会话 ID
assert result.conversation_id in store["conversations"]
assert result.conversation_id != CONVERSATION_ID
# 用户消息 + assistant 消息双行落库,assistant 行带复盘字段
assert [m["role"] for m in store["messages"]] == ["user", "assistant"]
assistant = store["messages"][1]
assert assistant["call_id"] == 555
assert assistant["model"] == "deepseek-v4-flash"
assert assistant["cost_cents"] == Decimal("0.05")
# 会话标题取首条用户消息
conv = store["conversations"][result.conversation_id]
assert conv.title == "面试怎么穿"
assert result.quota_remaining == 9
async def test_existing_conversation_carries_history(self, store: dict[str, Any]) -> None:
from packages.llm.types import Message
conv = store["conversations"][CONVERSATION_ID]
conv.message_count = 2
store["history"] = [
_msg("user", "之前的问题"),
_msg("assistant", "之前的回答"),
]
service = _service(store)
await service.chat(
user_id=USER_ID,
message="那约会呢",
request_key="k2",
conversation_id=str(CONVERSATION_ID),
)
sent = service._gateway.calls[0]["messages"] # type: ignore[attr-defined]
assert [m.content for m in sent] == ["之前的问题", "之前的回答"]
assert isinstance(sent[0], Message)
async def test_profile_injected_as_leading_system_message(self, store: dict[str, Any]) -> None:
async def profile_provider(user_id: int) -> str | None:
assert user_id == USER_ID
return '{"dimensions":[{"key":"body_shape","value":"梨形"}]}'
service = _service(store, profile_provider=profile_provider)
await service.chat(
user_id=USER_ID,
message="推荐裤子",
request_key="k3",
conversation_id=str(CONVERSATION_ID),
)
sent = service._gateway.calls[0]["messages"] # type: ignore[attr-defined]
assert sent[0].role == "system"
assert "梨形" in sent[0].content
async def test_profile_failure_degrades_silently(self, store: dict[str, Any]) -> None:
async def broken_provider(user_id: int) -> str | None:
raise RuntimeError("report table down")
service = _service(store, profile_provider=broken_provider)
result = await service.chat(
user_id=USER_ID,
message="hi",
request_key="k4",
conversation_id=str(CONVERSATION_ID),
)
assert result.reply # 画像失败不阻断对话
sent = service._gateway.calls[0]["messages"] # type: ignore[attr-defined]
assert all(m.role != "system" for m in sent)
async def test_other_users_conversation_not_found(self, store: dict[str, Any]) -> None:
"""他人会话与不存在同口径 404,不暴露存在性。"""
service = _service(store)
with pytest.raises(NotFoundError):
await service.chat(
user_id=9999,
message="hi",
request_key="k5",
conversation_id=str(CONVERSATION_ID),
)
async def test_llm_failure_refunds_and_persists_nothing(self, store: dict[str, Any]) -> None:
billing = FakeBilling()
service = _service(store, gateway=FailingGateway(), billing=billing)
with pytest.raises(RuntimeError):
await service.chat(
user_id=USER_ID,
message="hi",
request_key="k6",
conversation_id=str(CONVERSATION_ID),
)
assert billing.refunds == ["RuntimeError"]
assert store["messages"] == []
async def test_large_snowflake_id_as_string(self, store: dict[str, Any]) -> None:
"""超 2^53 的雪花 ID 以字符串传入,无精度丢失地落到归属校验。"""
big = 223843017600012288 # > 2**53,JS Number 无法精确表示
store["conversations"][big] = AgConversation(
conversation_id=big,
user_id=USER_ID,
title="",
message_count=0,
last_message_at=None,
)
service = _service(store)
result = await service.chat(
user_id=USER_ID,
message="hi",
request_key="k7",
conversation_id=str(big),
)
assert result.conversation_id == big
async def test_invalid_conversation_id_same_404(self, store: dict[str, Any]) -> None:
"""非法字符串与「不存在」同口径 40400,不给遍历者额外信号。"""
service = _service(store)
with pytest.raises(NotFoundError):
await service.chat(
user_id=USER_ID,
message="hi",
request_key="k8",
conversation_id="not-a-number",
)
async def test_context_build_failure_refunds(
self, store: dict[str, Any], monkeypatch: pytest.MonkeyPatch
) -> None:
"""预扣 commit 之后,上下文组装(历史查询)抛异常也必须退还额度。"""
async def broken_recent(session: Any, *, conversation_id: int, limit: int) -> list[Any]:
raise RuntimeError("db hiccup")
monkeypatch.setattr(repo, "list_recent_messages", broken_recent)
conv = store["conversations"][CONVERSATION_ID]
conv.message_count = 2 # 触发历史查询路径
billing = FakeBilling()
service = _service(store, billing=billing)
with pytest.raises(RuntimeError):
await service.chat(
user_id=USER_ID,
message="hi",
request_key="k9",
conversation_id=str(CONVERSATION_ID),
)
assert billing.refunds == ["RuntimeError"]
assert store["messages"] == []
class TestConversationApis:
async def test_create_and_get_owned(self, store: dict[str, Any]) -> None:
service = _service(store)
conversation = await service.create_conversation(user_id=USER_ID)
assert conversation.user_id == USER_ID
owned = await service.get_owned_conversation(
user_id=USER_ID, conversation_id=conversation.conversation_id
)
assert owned.conversation_id == conversation.conversation_id
with pytest.raises(NotFoundError):
await service.get_owned_conversation(user_id=USER_ID, conversation_id=404)
async def test_list_conversations_scoped_to_user(
self, store: dict[str, Any], monkeypatch: pytest.MonkeyPatch
) -> None:
async def fake_list(session: Any, *, user_id: int, limit: int = 20) -> list[Any]:
return [c for c in store["conversations"].values() if c.user_id == user_id]
monkeypatch.setattr(repo, "list_conversations", fake_list)
service = _service(store)
mine = await service.list_conversations(user_id=USER_ID)
other = await service.list_conversations(user_id=9999)
assert len(mine) == 1
assert other == []
def _msg(role: str, content: str) -> Any:
from packages.llm.types import Message
return Message(role=role, content=content)