feat(agent,report): 会话持久化与画像注入、报告反馈与预约留资——PM/开发双Agent迭代1-3
CI / lint / typecheck / import-linter / tests / secrets (push) Canceled after 0s
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:
@@ -0,0 +1,211 @@
|
||||
"""结论反馈 + 预约留资的端到端断言(T-P3-21 / T-P3-22,迭代 3)。
|
||||
|
||||
覆盖验收点:反馈 upsert 幂等(同维度覆盖更新不堆行)、归属校验他人报告
|
||||
40420、dimension_key 校验(不存在/skipped → 40001)、预约重复 created=false。
|
||||
报告行直接 INSERT(不走流水线)——反馈/预约断言的是路由与服务口径,
|
||||
不是生成链路(生成链路由 test_report_flow 覆盖)。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import uuid
|
||||
from collections.abc import AsyncIterator
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
def _infra_available() -> bool:
|
||||
return bool(os.getenv("MEIDA_RUN_INTEGRATION"))
|
||||
|
||||
|
||||
pytestmark = [
|
||||
pytest.mark.integration,
|
||||
pytest.mark.skipif(
|
||||
not _infra_available(),
|
||||
reason="set MEIDA_RUN_INTEGRATION=1 with PostgreSQL and Redis running",
|
||||
),
|
||||
]
|
||||
|
||||
_OK_DIM = "body_shape"
|
||||
_SKIPPED_DIM = "color_season"
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def client() -> AsyncIterator[Any]:
|
||||
import httpx
|
||||
|
||||
from apps.api.main import create_app
|
||||
from packages.shared.config import get_settings
|
||||
|
||||
app = create_app(get_settings())
|
||||
transport = httpx.ASGITransport(app=app)
|
||||
async with (
|
||||
httpx.AsyncClient(transport=transport, base_url="http://test") as http,
|
||||
app.router.lifespan_context(app),
|
||||
):
|
||||
yield http
|
||||
|
||||
|
||||
async def _login(client: Any) -> tuple[dict[str, str], int]:
|
||||
resp = await client.post(
|
||||
"/api/v1/auth/dev-token", json={"device_key": f"fb-it-{uuid.uuid4().hex[:8]}"}
|
||||
)
|
||||
assert resp.json()["code"] == 0
|
||||
data = resp.json()["data"]
|
||||
return {"Authorization": f"Bearer {data['access_token']}"}, int(data["user_id"])
|
||||
|
||||
|
||||
async def _insert_report(user_id: int) -> int:
|
||||
"""直接落一份 succeeded 报告:两个维度,一个 ok 一个 skipped。"""
|
||||
from packages.domain.ids import next_id
|
||||
from packages.shared.db import session_scope
|
||||
from packages.shared.models import RpReport
|
||||
|
||||
report_id = next_id()
|
||||
async with session_scope() as session:
|
||||
session.add(
|
||||
RpReport(
|
||||
report_id=report_id,
|
||||
user_id=user_id,
|
||||
tier="basic",
|
||||
status="succeeded",
|
||||
version=1,
|
||||
result={
|
||||
"diagnosis": {
|
||||
"dimensions": [
|
||||
{"number": 5, "key": _OK_DIM, "status": "ok", "value": "X"},
|
||||
{"number": 19, "key": _SKIPPED_DIM, "status": "skipped", "value": None},
|
||||
],
|
||||
"coverage": {"basic_full": False, "total": 2},
|
||||
}
|
||||
},
|
||||
)
|
||||
)
|
||||
return report_id
|
||||
|
||||
|
||||
async def _count_feedback(report_id: int) -> int:
|
||||
from sqlalchemy import func, select
|
||||
|
||||
from packages.shared.db import session_scope
|
||||
from packages.shared.models import RpFeedback
|
||||
|
||||
async with session_scope() as session:
|
||||
result = await session.execute(
|
||||
select(func.count()).select_from(RpFeedback).where(RpFeedback.report_id == report_id)
|
||||
)
|
||||
return int(result.scalar_one())
|
||||
|
||||
|
||||
# --- 反馈:upsert 幂等 --------------------------------------------------------
|
||||
|
||||
|
||||
async def test_feedback_upsert_overwrites_same_dimension(client: Any) -> None:
|
||||
headers, user_id = await _login(client)
|
||||
report_id = await _insert_report(user_id)
|
||||
|
||||
first = await client.post(
|
||||
f"/api/v1/reports/{report_id}/feedback",
|
||||
headers=headers,
|
||||
json={"dimension_key": _OK_DIM, "verdict": "accurate"},
|
||||
)
|
||||
assert first.status_code == 200
|
||||
assert first.json()["data"]["updated"] is False
|
||||
|
||||
# 改判:同维度再反馈 = 覆盖更新(updated=true),不是堆行
|
||||
second = await client.post(
|
||||
f"/api/v1/reports/{report_id}/feedback",
|
||||
headers=headers,
|
||||
json={"dimension_key": _OK_DIM, "verdict": "inaccurate", "correction": "其实我是梨形"},
|
||||
)
|
||||
assert second.status_code == 200
|
||||
assert second.json()["data"]["updated"] is True
|
||||
assert await _count_feedback(report_id) == 1
|
||||
|
||||
listing = (await client.get(f"/api/v1/reports/{report_id}/feedback", headers=headers)).json()[
|
||||
"data"
|
||||
]
|
||||
assert len(listing) == 1
|
||||
assert listing[0]["dimension_key"] == _OK_DIM
|
||||
assert listing[0]["verdict"] == "inaccurate"
|
||||
assert listing[0]["correction"] == "其实我是梨形"
|
||||
|
||||
|
||||
# --- 反馈:归属与维度校验 ------------------------------------------------------
|
||||
|
||||
|
||||
async def test_feedback_on_others_report_returns_40420(client: Any) -> None:
|
||||
headers, _ = await _login(client)
|
||||
_, other_user_id = await _login(client)
|
||||
report_id = await _insert_report(other_user_id)
|
||||
|
||||
resp = await client.post(
|
||||
f"/api/v1/reports/{report_id}/feedback",
|
||||
headers=headers,
|
||||
json={"dimension_key": _OK_DIM, "verdict": "accurate"},
|
||||
)
|
||||
assert resp.json()["code"] == 40420
|
||||
|
||||
get_resp = await client.get(f"/api/v1/reports/{report_id}/feedback", headers=headers)
|
||||
assert get_resp.json()["code"] == 40420
|
||||
|
||||
|
||||
async def test_feedback_dimension_must_exist_and_be_ok(client: Any) -> None:
|
||||
headers, user_id = await _login(client)
|
||||
report_id = await _insert_report(user_id)
|
||||
|
||||
# 不存在的维度
|
||||
resp = await client.post(
|
||||
f"/api/v1/reports/{report_id}/feedback",
|
||||
headers=headers,
|
||||
json={"dimension_key": "no_such_dim", "verdict": "accurate"},
|
||||
)
|
||||
assert resp.json()["code"] == 40001
|
||||
|
||||
# skipped 维度没有结论,不可反馈
|
||||
resp2 = await client.post(
|
||||
f"/api/v1/reports/{report_id}/feedback",
|
||||
headers=headers,
|
||||
json={"dimension_key": _SKIPPED_DIM, "verdict": "accurate"},
|
||||
)
|
||||
assert resp2.json()["code"] == 40001
|
||||
|
||||
# verdict 枚举外 → 40001(pydantic 422 → 统一信封 VALIDATION_FAILED)
|
||||
resp3 = await client.post(
|
||||
f"/api/v1/reports/{report_id}/feedback",
|
||||
headers=headers,
|
||||
json={"dimension_key": _OK_DIM, "verdict": "kind-of"},
|
||||
)
|
||||
assert resp3.json()["code"] == 40001
|
||||
|
||||
assert await _count_feedback(report_id) == 0
|
||||
|
||||
|
||||
# --- 预约:幂等 ---------------------------------------------------------------
|
||||
|
||||
|
||||
async def test_reservation_duplicate_returns_created_false(client: Any) -> None:
|
||||
headers, _ = await _login(client)
|
||||
|
||||
first = await client.post(
|
||||
"/api/v1/reservations", headers=headers, json={"topic": "full_report"}
|
||||
)
|
||||
assert first.status_code == 200
|
||||
assert first.json()["data"]["created"] is True
|
||||
|
||||
again = await client.post(
|
||||
"/api/v1/reservations", headers=headers, json={"topic": "full_report"}
|
||||
)
|
||||
assert again.status_code == 200
|
||||
assert again.json()["data"]["created"] is False
|
||||
|
||||
listing = (await client.get("/api/v1/reservations?topic=full_report", headers=headers)).json()[
|
||||
"data"
|
||||
]
|
||||
assert listing["topics"] == ["full_report"]
|
||||
|
||||
# topic 参数缺省返回全部
|
||||
all_topics = (await client.get("/api/v1/reservations", headers=headers)).json()["data"]
|
||||
assert "full_report" in all_topics["topics"]
|
||||
Reference in New Issue
Block a user