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
@@ -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"]