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:
@@ -3,6 +3,9 @@
|
||||
**职责**(架构 §4.1):触发判定、生成编排、版本管理、分享。
|
||||
**不负责**:具体的视觉分析与文案生成(调 AI 能力层)。
|
||||
|
||||
`profile.py` 对外提供「最近一份 succeeded 报告的画像摘要」(Agent 上下文
|
||||
注入用,P0-2);rp_report / rp_input 仍只有本域 repo 可触,外部一律走这里。
|
||||
|
||||
## 待实现
|
||||
|
||||
- 表:`rp_report` `rp_input` `rp_share`
|
||||
@@ -17,3 +20,16 @@
|
||||
(也就不需要用户重新上传)。
|
||||
4. **生成后校验不可跳过**:禁忌违反、色值在板内、维度引用存在、无医疗/贬损表述。
|
||||
最多重试 2 次,仍失败降级为规则直出模板文案,**绝不放行违规内容**。
|
||||
|
||||
## 结论反馈(T-P3-21 最小可用版,迭代 3 落地)
|
||||
|
||||
`feedback_service.py` + `rp_feedback`:维度级「准/不准 + 可选修正」,同维度
|
||||
重复反馈 = 覆盖更新(upsert)。**有意裁剪**:原文要求的「修正后置信度置 1.0
|
||||
并即时写 L1 记忆」未做——L1 记忆系统未建,本迭代只落表,认同率/修正率
|
||||
两项指标可从 SQL 直接算出,不回写报告结论。
|
||||
|
||||
## 预约留资(T-P3-22,迭代 3 落地)
|
||||
|
||||
`reservation_service.py` + `rp_reservation`:topic 预留通用性(首期
|
||||
full_report)。指标口径:**预约率 = rp_reservation(topic='full_report') 行数
|
||||
÷ 埋点 full_report.card_view 去重曝光数**;决策门槛 ≥8%(产品方案 §2.2.3)。
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
"""报告结论反馈服务(T-P3-21 最小可用版)。
|
||||
|
||||
有意裁剪(防误解):T-P3-21 原文要求「修正后置信度置 1.0 并即时写 L1 记忆」
|
||||
——L1 记忆系统未建,本迭代只落 rp_feedback 表,保证认同率/修正率两项指标
|
||||
可从 SQL 直接算出,**不回写报告结论**。
|
||||
|
||||
归属口径:他人报告与不存在同口径 40420(不透出报告是否存在的额外信息)。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from packages.domain.ids import next_id
|
||||
from packages.report import repo
|
||||
from packages.report.schemas import FeedbackVerdict
|
||||
from packages.shared.errors import AppError, ErrorCode
|
||||
from packages.shared.models import RpFeedback, RpReport
|
||||
|
||||
DIMENSION_STATUS_OK = "ok"
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class FeedbackUpsert:
|
||||
feedback: RpFeedback
|
||||
updated: bool # true=同维度已有反馈被覆盖;false=新建
|
||||
|
||||
|
||||
def ok_dimension_keys(report_result: dict[str, Any]) -> set[str]:
|
||||
"""报告产物中可反馈的维度集合:存在于 diagnosis.dimensions 且 status=ok。
|
||||
|
||||
纯函数(不碰 I/O),路由层校验与单测共用同一口径。
|
||||
"""
|
||||
diagnosis = report_result.get("diagnosis")
|
||||
if not isinstance(diagnosis, dict):
|
||||
return set()
|
||||
dimensions = diagnosis.get("dimensions")
|
||||
if not isinstance(dimensions, list):
|
||||
return set()
|
||||
keys: set[str] = set()
|
||||
for item in dimensions:
|
||||
if (
|
||||
isinstance(item, dict)
|
||||
and item.get("status") == DIMENSION_STATUS_OK
|
||||
and isinstance(item.get("key"), str)
|
||||
):
|
||||
keys.add(item["key"])
|
||||
return keys
|
||||
|
||||
|
||||
class FeedbackService:
|
||||
def __init__(self, session: AsyncSession) -> None:
|
||||
self._session = session
|
||||
|
||||
async def _get_owned_report(self, *, user_id: int, report_id: int) -> RpReport:
|
||||
report = await repo.get_report(self._session, report_id)
|
||||
# 归属校验沿用 get_owned 语义,但对外同口径 40420:
|
||||
# 不存在与他人报告不可区分,不透出报告是否真实存在。
|
||||
if report is None or report.user_id != user_id:
|
||||
raise AppError(ErrorCode.REPORT_NOT_FOUND)
|
||||
return report
|
||||
|
||||
async def upsert_feedback(
|
||||
self,
|
||||
*,
|
||||
user_id: int,
|
||||
report_id: int,
|
||||
dimension_key: str,
|
||||
verdict: FeedbackVerdict,
|
||||
correction: str | None,
|
||||
) -> FeedbackUpsert:
|
||||
"""同维度重复反馈 = 覆盖更新(uq_rp_feedback_report_dimension 兜底)。"""
|
||||
report = await self._get_owned_report(user_id=user_id, report_id=report_id)
|
||||
if dimension_key not in ok_dimension_keys(report.result or {}):
|
||||
raise AppError(
|
||||
ErrorCode.VALIDATION_FAILED,
|
||||
message="该维度没有可反馈的结论",
|
||||
hint="只能对报告中已生成的维度结论反馈",
|
||||
)
|
||||
correction = correction.strip() if correction is not None else None
|
||||
if correction == "":
|
||||
correction = None
|
||||
existing = await repo.get_feedback(
|
||||
self._session, report_id=report_id, dimension_key=dimension_key
|
||||
)
|
||||
if existing is not None:
|
||||
existing.verdict = verdict
|
||||
existing.correction = correction
|
||||
await self._session.flush()
|
||||
return FeedbackUpsert(feedback=existing, updated=True)
|
||||
row = RpFeedback(
|
||||
feedback_id=next_id(),
|
||||
report_id=report_id,
|
||||
user_id=user_id,
|
||||
dimension_key=dimension_key,
|
||||
verdict=verdict,
|
||||
correction=correction,
|
||||
)
|
||||
self._session.add(row)
|
||||
await self._session.flush()
|
||||
return FeedbackUpsert(feedback=row, updated=False)
|
||||
|
||||
async def list_feedback(self, *, user_id: int, report_id: int) -> list[RpFeedback]:
|
||||
await self._get_owned_report(user_id=user_id, report_id=report_id)
|
||||
return await repo.list_feedback(self._session, report_id=report_id)
|
||||
@@ -0,0 +1,50 @@
|
||||
"""对外(同层以上)提供「最近一份报告的画像摘要」,供 Agent 上下文注入。
|
||||
|
||||
只取结论摘要而非报告全文:素材走 ``copy.compose_prompt_material``——与
|
||||
REPORT_COPY 场景同一套裁剪纪律(skipped 维度不进素材防幻觉、超长按优先级
|
||||
裁、硬结论永不裁),只是预算从报告的 6000 字符收紧到调用方给的额度。
|
||||
|
||||
归属纪律不变:rp_report / rp_input 只有 report 域内部可触(repo.py 头部
|
||||
约定),外部要画像只能走这里。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from packages.report import repo
|
||||
from packages.report.copy import compose_prompt_material
|
||||
from packages.shared.logging import get_logger
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
#: 往前翻几份报告找可用的诊断产物。正常用户只有几份;留余量是为了容错
|
||||
#: 「最新一份 failed/processing 而上一份 succeeded」的场景。
|
||||
_SCAN_LIMIT = 5
|
||||
|
||||
|
||||
async def latest_profile_summary(
|
||||
session: AsyncSession, *, user_id: int, max_chars: int
|
||||
) -> str | None:
|
||||
"""该用户最近一份带诊断结论的 succeeded 报告 → 紧凑 JSON 素材;无则 None。
|
||||
|
||||
NFR-SEC-03:摘要内容不进日志,这里只记「命中第几份报告」。
|
||||
"""
|
||||
reports = await repo.list_reports(session, user_id=user_id, limit=_SCAN_LIMIT)
|
||||
for index, report in enumerate(reports):
|
||||
if report.status != repo.STATUS_SUCCEEDED:
|
||||
continue
|
||||
diagnosis: Any = (report.result or {}).get("diagnosis")
|
||||
if not isinstance(diagnosis, dict) or not diagnosis.get("dimensions"):
|
||||
continue
|
||||
material, dropped = compose_prompt_material(diagnosis, max_chars=max_chars)
|
||||
logger.info(
|
||||
"report_profile_summary_built",
|
||||
report_id=report.report_id,
|
||||
scan_depth=index,
|
||||
dropped_keys=len(dropped),
|
||||
)
|
||||
return material
|
||||
return None
|
||||
+54
-2
@@ -1,11 +1,14 @@
|
||||
"""rp_report / rp_input 的数据访问。**只有本模块可以触这两张表**(rp_share 归后续任务)。"""
|
||||
"""rp_report / rp_input / rp_feedback / rp_reservation 的数据访问。
|
||||
|
||||
**只有本模块可以触这四张表**(rp_share 归后续任务)。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from packages.shared.models import RpInput, RpReport
|
||||
from packages.shared.models import RpFeedback, RpInput, RpReport, RpReservation
|
||||
|
||||
STATUS_SUCCEEDED = "succeeded"
|
||||
TIER_BASIC = "basic"
|
||||
@@ -45,3 +48,52 @@ async def list_reports(session: AsyncSession, *, user_id: int, limit: int = 20)
|
||||
async def get_input(session: AsyncSession, report_id: int) -> RpInput | None:
|
||||
result = await session.execute(select(RpInput).where(RpInput.report_id == report_id))
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
# -- rp_feedback(T-P3-21:同维度覆盖更新,upsert 语义) -----------------------
|
||||
|
||||
|
||||
async def get_feedback(
|
||||
session: AsyncSession, *, report_id: int, dimension_key: str
|
||||
) -> RpFeedback | None:
|
||||
result = await session.execute(
|
||||
select(RpFeedback).where(
|
||||
RpFeedback.report_id == report_id,
|
||||
RpFeedback.dimension_key == dimension_key,
|
||||
)
|
||||
)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def list_feedback(session: AsyncSession, *, report_id: int) -> list[RpFeedback]:
|
||||
result = await session.execute(
|
||||
select(RpFeedback)
|
||||
.where(RpFeedback.report_id == report_id)
|
||||
.order_by(RpFeedback.created_at.asc(), RpFeedback.feedback_id.asc())
|
||||
)
|
||||
return list(result.scalars().all())
|
||||
|
||||
|
||||
# -- rp_reservation(T-P3-22:重复预约幂等) ------------------------------------
|
||||
|
||||
|
||||
async def get_reservation(
|
||||
session: AsyncSession, *, user_id: int, topic: str
|
||||
) -> RpReservation | None:
|
||||
result = await session.execute(
|
||||
select(RpReservation).where(
|
||||
RpReservation.user_id == user_id,
|
||||
RpReservation.topic == topic,
|
||||
)
|
||||
)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def list_reservation_topics(
|
||||
session: AsyncSession, *, user_id: int, topic: str | None = None
|
||||
) -> list[str]:
|
||||
stmt = select(RpReservation.topic).where(RpReservation.user_id == user_id)
|
||||
if topic is not None:
|
||||
stmt = stmt.where(RpReservation.topic == topic)
|
||||
result = await session.execute(stmt.order_by(RpReservation.topic.asc()))
|
||||
return [str(row) for row in result.scalars().all()]
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
"""预约留资服务(T-P3-22)。
|
||||
|
||||
topic 预留通用性,首期只有 full_report(全面报告上线提醒)。
|
||||
幂等口径:UniqueConstraint(user_id, topic)——重复预约返回已有记录
|
||||
(created=false,HTTP 仍 200),绝不堆行。
|
||||
|
||||
指标口径:预约率 = rp_reservation(topic='full_report') 行数 ÷ 埋点
|
||||
full_report.card_view 去重曝光数;决策门槛 ≥8%(产品方案 §2.2.3)。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from packages.domain.ids import next_id
|
||||
from packages.report import repo
|
||||
from packages.shared.models import RpReservation
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class ReservationResult:
|
||||
reservation: RpReservation
|
||||
created: bool # false=幂等命中已有记录
|
||||
|
||||
|
||||
class ReservationService:
|
||||
def __init__(self, session: AsyncSession) -> None:
|
||||
self._session = session
|
||||
|
||||
async def reserve(self, *, user_id: int, topic: str) -> ReservationResult:
|
||||
"""幂等预约:同 user+topic 已存在即返回原记录。"""
|
||||
existing = await repo.get_reservation(self._session, user_id=user_id, topic=topic)
|
||||
if existing is not None:
|
||||
return ReservationResult(reservation=existing, created=False)
|
||||
row = RpReservation(reservation_id=next_id(), user_id=user_id, topic=topic)
|
||||
self._session.add(row)
|
||||
await self._session.flush()
|
||||
return ReservationResult(reservation=row, created=True)
|
||||
|
||||
async def list_topics(self, *, user_id: int, topic: str | None = None) -> list[str]:
|
||||
return await repo.list_reservation_topics(self._session, user_id=user_id, topic=topic)
|
||||
@@ -7,6 +7,7 @@ Wire 响应统一走 ok() 信封(架构文档 §9.2),这里的模型只描
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Literal
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
@@ -57,3 +58,49 @@ class ReportItem(BaseModel):
|
||||
)
|
||||
created_at: datetime
|
||||
completed_at: datetime | None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# T-P3-21:维度级结论反馈
|
||||
# ---------------------------------------------------------------------------
|
||||
FeedbackVerdict = Literal["accurate", "inaccurate"]
|
||||
|
||||
|
||||
class FeedbackCreateRequest(BaseModel):
|
||||
dimension_key: str = Field(
|
||||
min_length=1, max_length=64, description="报告 result.diagnosis.dimensions 的 key"
|
||||
)
|
||||
verdict: FeedbackVerdict = Field(description="准(accurate)/ 不准(inaccurate)")
|
||||
correction: str | None = Field(
|
||||
default=None, max_length=200, description="verdict=inaccurate 时的可选修正,≤200 字"
|
||||
)
|
||||
|
||||
|
||||
class FeedbackUpsertResponse(BaseModel):
|
||||
dimension_key: str
|
||||
verdict: str
|
||||
updated: bool = Field(description="true=同维度已有反馈被覆盖更新;false=新建")
|
||||
|
||||
|
||||
class FeedbackItem(BaseModel):
|
||||
dimension_key: str
|
||||
verdict: str
|
||||
correction: str | None
|
||||
created_at: datetime
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# T-P3-22:预约留资(topic 预留通用性,首期 full_report)
|
||||
# ---------------------------------------------------------------------------
|
||||
class ReservationCreateRequest(BaseModel):
|
||||
topic: str = Field(min_length=1, max_length=32)
|
||||
|
||||
|
||||
class ReservationResponse(BaseModel):
|
||||
topic: str
|
||||
created: bool = Field(description="false=已存在(幂等命中,HTTP 仍 200)")
|
||||
created_at: datetime
|
||||
|
||||
|
||||
class ReservationListResponse(BaseModel):
|
||||
topics: list[str]
|
||||
|
||||
Reference in New Issue
Block a user