feat(agent,report): 会话持久化与画像注入、报告反馈与预约留资——PM/开发双Agent迭代1-3
CI / lint / typecheck / import-linter / tests / secrets (push) Canceled after 0s

- 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 验证
  画像注入与历史携带
This commit is contained in:
xsl
2026-09-11 21:50:58 +08:00
parent 00ef56b08b
commit 7bcbb8d40b
25 changed files with 1860 additions and 25 deletions
+67
View File
@@ -0,0 +1,67 @@
"""上下文组装(架构 §7.2 步骤③)的纯逻辑测试:画像注入与历史截尾。"""
from __future__ import annotations
from packages.agent.context import (
PROFILE_SUMMARY_MAX_CHARS,
build_context_messages,
build_profile_message,
)
from packages.llm.types import Message
class TestBuildContextMessages:
def test_profile_first_then_history(self) -> None:
history = [
Message(role="user", content="第一句"),
Message(role="assistant", content="第一答"),
]
messages = build_context_messages(
profile_summary='{"dimensions":[]}', history=history, history_turns=10
)
assert len(messages) == 3
assert messages[0].role == "system"
assert '{"dimensions":[]}' in messages[0].content
assert [m.role for m in messages[1:]] == ["user", "assistant"]
assert [m.content for m in messages[1:]] == ["第一句", "第一答"]
def test_no_profile_degrades_to_history_only(self) -> None:
"""无画像用户(FR-AGT-10 降级):不注入画像消息,历史照常携带。"""
history = [Message(role="user", content="hi")]
messages = build_context_messages(profile_summary=None, history=history, history_turns=10)
assert len(messages) == 1
assert messages[0].role == "user"
def test_empty_profile_string_treated_as_absent(self) -> None:
messages = build_context_messages(profile_summary="", history=[], history_turns=10)
assert messages == []
def test_history_tail_kept_by_turns(self) -> None:
"""一轮 = user+assistant 两条;N=2 时只留最近 4 条,截头不截尾。"""
history: list[Message] = []
for i in range(5):
history.append(Message(role="user", content=f"u{i}"))
history.append(Message(role="assistant", content=f"a{i}"))
messages = build_context_messages(profile_summary=None, history=history, history_turns=2)
assert [m.content for m in messages] == ["u3", "a3", "u4", "a4"]
def test_zero_turns_drops_history(self) -> None:
history = [Message(role="user", content="u0")]
messages = build_context_messages(profile_summary=None, history=history, history_turns=0)
assert messages == []
def test_history_message_content_capped(self) -> None:
history = [Message(role="user", content="x" * 5000)]
messages = build_context_messages(profile_summary=None, history=history, history_turns=10)
assert len(messages[0].content) == 2000
class TestBuildProfileMessage:
def test_wraps_summary_as_system_message(self) -> None:
message = build_profile_message("{}")
assert message.role == "system"
assert message.content.endswith("{}")
def test_budget_constant_within_token_budget(self) -> None:
"""画像摘要预算 ≤ 500 token(README 预算表),按 ~1.6 字符/token 折算。"""
assert PROFILE_SUMMARY_MAX_CHARS <= 500 * 1.6
+331
View File
@@ -0,0 +1,331 @@
"""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)
+69
View File
@@ -0,0 +1,69 @@
"""报告结论反馈的纯函数与 schema 校验(T-P3-21 最小可用版,不连库)。
覆盖:ok_dimension_keys 的可反馈维度集合口径(status=ok 才可选)、
FeedbackCreateRequest 的 verdict 枚举与 correction 长度上限。
"""
from __future__ import annotations
import pytest
from pydantic import ValidationError
from packages.report.feedback_service import ok_dimension_keys
from packages.report.schemas import FeedbackCreateRequest
def _result(dimensions: list[dict[str, object]]) -> dict[str, object]:
return {"diagnosis": {"dimensions": dimensions, "coverage": {"total": len(dimensions)}}}
class TestOkDimensionKeys:
def test_only_status_ok_dimensions_are_feedbackable(self) -> None:
result = _result(
[
{"key": "body_shape", "status": "ok"},
{"key": "color_season", "status": "skipped"},
{"key": "style_type", "status": "ok"},
]
)
assert ok_dimension_keys(result) == {"body_shape", "style_type"}
def test_empty_or_missing_diagnosis_yields_empty(self) -> None:
assert ok_dimension_keys({}) == set()
assert ok_dimension_keys({"diagnosis": {}}) == set()
assert ok_dimension_keys({"diagnosis": {"dimensions": "not-a-list"}}) == set()
def test_malformed_items_are_ignored(self) -> None:
result = _result(
[
"garbage",
{"status": "ok"}, # 缺 key
{"key": 123, "status": "ok"}, # key 非字符串
{"key": "face_shape", "status": "ok"},
]
)
assert ok_dimension_keys(result) == {"face_shape"}
class TestFeedbackCreateRequest:
def test_verdict_enum_rejects_unknown_value(self) -> None:
with pytest.raises(ValidationError):
FeedbackCreateRequest.model_validate(
{"dimension_key": "body_shape", "verdict": "kind-of"}
)
def test_correction_max_200_chars(self) -> None:
with pytest.raises(ValidationError):
FeedbackCreateRequest.model_validate(
{
"dimension_key": "body_shape",
"verdict": "inaccurate",
"correction": "长" * 201,
}
)
def test_minimal_valid_payload(self) -> None:
req = FeedbackCreateRequest.model_validate(
{"dimension_key": "body_shape", "verdict": "accurate"}
)
assert req.correction is None
+83
View File
@@ -0,0 +1,83 @@
"""report.profile.latest_profile_summary:repo 打桩,验证摘要选取纪律。"""
from __future__ import annotations
import json
from types import SimpleNamespace
from typing import Any
import pytest
from packages.report import repo
from packages.report.profile import latest_profile_summary
_DIAGNOSIS = {
"dimensions": [
{
"key": "body_shape",
"label": "体型",
"status": "ok",
"value": "梨形",
"confidence": 0.9,
"evidence": ["问卷自报肩窄胯宽"],
},
{"key": "style_impression", "status": "skipped", "value": None},
],
"rule_hits": [
{"rule_key": "pear_lower_dark", "dimension": "body_shape", "suggestion": "下装选深色"}
],
}
def _report(report_id: int, *, status: str, result: dict[str, Any]) -> Any:
return SimpleNamespace(report_id=report_id, status=status, result=result)
@pytest.fixture
def stub_list(monkeypatch: pytest.MonkeyPatch) -> dict[str, Any]:
state: dict[str, Any] = {"reports": []}
async def fake_list(session: Any, *, user_id: int, limit: int = 20) -> list[Any]:
state["limit"] = limit
return state["reports"]
monkeypatch.setattr(repo, "list_reports", fake_list)
return state
class TestLatestProfileSummary:
async def test_no_reports_returns_none(self, stub_list: dict[str, Any]) -> None:
assert await latest_profile_summary(None, user_id=1, max_chars=800) is None # type: ignore[arg-type]
async def test_succeeded_report_with_diagnosis(self, stub_list: dict[str, Any]) -> None:
stub_list["reports"] = [_report(1, status="succeeded", result={"diagnosis": _DIAGNOSIS})]
summary = await latest_profile_summary(None, user_id=1, max_chars=800) # type: ignore[arg-type]
assert summary is not None
payload = json.loads(summary)
values = {d["key"]: d["value"] for d in payload["dimensions"]}
assert values["body_shape"] == "梨形"
# skipped 维度不进素材(防幻觉第一道闸)
assert "style_impression" not in values
assert payload["rule_hits"][0]["rule_key"] == "pear_lower_dark"
async def test_latest_failed_falls_back_to_earlier_succeeded(
self, stub_list: dict[str, Any]
) -> None:
stub_list["reports"] = [
_report(2, status="failed", result={}),
_report(1, status="succeeded", result={"diagnosis": _DIAGNOSIS}),
]
summary = await latest_profile_summary(None, user_id=1, max_chars=800) # type: ignore[arg-type]
assert summary is not None and "梨形" in summary
async def test_succeeded_without_diagnosis_is_skipped(self, stub_list: dict[str, Any]) -> None:
stub_list["reports"] = [_report(1, status="succeeded", result={"vision": {}})]
assert await latest_profile_summary(None, user_id=1, max_chars=800) is None # type: ignore[arg-type]
async def test_budget_is_forwarded(self, stub_list: dict[str, Any]) -> None:
"""max_chars 收紧时低优先级维度被裁、硬结论保留(复用 copy 的裁剪纪律)。"""
stub_list["reports"] = [_report(1, status="succeeded", result={"diagnosis": _DIAGNOSIS})]
summary = await latest_profile_summary(None, user_id=1, max_chars=60) # type: ignore[arg-type]
assert summary is not None
assert len(summary) > 60 # 硬结论永不裁,载荷可超预算(copy.py 既定语义)
assert "梨形" in summary