mirror of
https://github.com/ThearchyHelios/AgentLab.git
synced 2026-11-15 06:11:58 +00:00
cc608f56ec
「跑完了」和「答得对」之间一直没有任何一层。库里 264 条 succeeded 里, 14 条带着 warn、4 条 agent 步数用满,而交到用户手上的答案对此只字不提。 最糟的一次(run dd9927e6)交出去的干脆是「本轮只执行了第一个工具」—— 一句内部管道文案冒充结论。 出具体系(issuance.py)本来就是干这个的,但它挂在输出节点的可选契约上, 还要配口径卡提供受控指标集;问数据页生成的图基本都是 input → agent → output, 走不到那条路。所以补一层轻的。 **干净的运行一次模型都不调。** 先用规则扫一遍事件流(engine/review.py:scan), 没扫出东西就到此为止。实测 15ms 返回。这是整层设计的前提,测试里用 「monkeypatch 一个会抛异常的模型,断言它没被碰过」来守。 **分类挂在 code 上,不是中文消息串。** 12 处 warn 发射点都带上 code。 这样「流式失败,回退非流式」能归成 benign 直接跳过——它对答案内容零影响, 为它叫一次模型等于每次网络抖一下就多花一笔;而「步数用满」归 broken。 原来这两者在事件流里长得一模一样。工具报错之后同一个工具又跑成了, 算 degraded 不算 broken——否则参数自愈每修一次就报一次「答案不可信」。 **复核不许现编数字。** 它手上只有原答案和一份异常清单,没有任何原始数据, 写出来的新数字只能是凭空来的。直接复用 issuance.trace_numbers,把原答案的 数字当受控指标集,对不上就整条退回原答案、只留说明。硬拦截,不是提示。 **规则管重试资格,模型只有否决权。** 一开始写成两边取并,实测 loop_limit 时模型判 retry=true——而重跑一遍循环还是会撞上限,白烧一次建图加一次跑图。 反过来之后,「检索什么都没查到」这类也不会被模型强推重跑。 **重试带着失败的那张图,不是推倒重来。** 原来传 base_graph=null,模型只看到 「原问题 + 一句失败原因」,很可能原样再建出同一张坏图。而且它连自己上次建了 什么都看不到——会话历史按 status != running 过滤,正在重试的这一轮恰好被 排除在外。现在交回去走后端的改图分支,调大步数、改正参数就是改一个字段。 上限一次。 **答案等复核完成再显示。** 端到端抓到的:答案先摆出来,十秒后说明才追上去—— 这十秒里用户读的是一个没人核对过的结论,正是要修的那件事本身。改成落库立刻 (防关标签页丢结果)、显示等复核,并给复核加 25s 上限。说明排在成果上方, broken 用报警色、degraded 用普通边框——混成一个样子用户就学会了一概忽略。 复核结论跟着轮次落库,刷新后还在;也进下一轮的对话历史,否则模型会拿一个 已知不可信的结论当事实继续往下推。 验证:后端 242 passed(新增 test_review.py 25 条);tsc 干净; check-decode / check-stream / check-ui 全过,check-stream 新增 9 条渲染断言。 两条端到端都在真浏览器 + 真模型上跑通:检索降级那次说明正确排在答案上方; 重试那次建图请求 2 次、第二次 base_graph 带着失败的 3 个节点、第一版没漏出去。 复核结果不是事件流,decode.ts 翻译不到它,所以渲染测试放在 check-stream。
357 lines
13 KiB
Python
357 lines
13 KiB
Python
"""会话:把一串问答串成一次对话。
|
||
|
||
在此之前"对话"只是前端内存里的一个数组——刷新页面就没了,而每一轮又各自
|
||
从零建图,所以"上一轮问过什么"在系统里无处可查。这个模块把它落到库里,
|
||
同时服务两件事:给用户看的历史列表,和给模型看的上下文(`_history_text`
|
||
被 copilot 拿去拼进 prompt)。
|
||
|
||
轮次的写入由前端驱动,分两步:开轮(只有问题)→ 回填(图、run、答案)。
|
||
让 copilot 或 runner 自己去写会更"自动",但建图失败、运行失败、用户中途
|
||
取消这三种半途而废的情况它们各自只知道一半,最后还是要在三个地方各接一次。
|
||
前端本来就完整地知道这一轮走到哪了。
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
from typing import Any
|
||
|
||
from fastapi import APIRouter, Depends, HTTPException
|
||
from pydantic import BaseModel, Field
|
||
from sqlalchemy import delete, func, select
|
||
from sqlalchemy.ext.asyncio import AsyncSession
|
||
|
||
from app.db.base import get_session, utcnow
|
||
from app.db.models import Conversation, ConversationTurn
|
||
|
||
router = APIRouter(prefix="/api/conversations", tags=["conversations"])
|
||
|
||
#: 喂给模型的历史轮数上限。再多意义不大,而 prompt 是要花钱的
|
||
HISTORY_TURNS = 6
|
||
#: 单轮问答在上下文里的截断长度。一次查询结果可能有几千字,整段塞进去
|
||
#: 会把后面几轮挤掉——那还不如少说点、多留几轮
|
||
HISTORY_ANSWER_CHARS = 600
|
||
|
||
|
||
def title_from(question: str, limit: int = 24) -> str:
|
||
"""用第一问当标题。没有标题的会话在列表里是一排"新对话",等于没有列表。"""
|
||
text = " ".join((question or "").split())
|
||
if not text:
|
||
return "新对话"
|
||
return text if len(text) <= limit else text[:limit] + "…"
|
||
|
||
|
||
# --------------------------------------------------------------------------
|
||
# 出入参
|
||
# --------------------------------------------------------------------------
|
||
|
||
|
||
class ConversationIn(BaseModel):
|
||
kind: str = Field(default="chat", pattern="^(chat|canvas)$")
|
||
workflow_id: str | None = None
|
||
title: str = ""
|
||
|
||
|
||
class ConversationPatch(BaseModel):
|
||
title: str | None = None
|
||
archived: bool | None = None
|
||
|
||
|
||
class TurnIn(BaseModel):
|
||
question: str = ""
|
||
|
||
|
||
class TurnPatch(BaseModel):
|
||
"""回填。全是可选——一轮可能在任何一步停下,能填多少填多少。"""
|
||
|
||
question: str | None = None
|
||
answer: str | None = None
|
||
explanation: str | None = None
|
||
graph: dict[str, Any] | None = None
|
||
run_id: str | None = None
|
||
status: str | None = Field(default=None, pattern="^(running|done|error)$")
|
||
error: str | None = None
|
||
review: dict[str, Any] | None = None
|
||
|
||
|
||
class TurnOut(BaseModel):
|
||
id: str
|
||
seq: int
|
||
question: str
|
||
answer: str
|
||
explanation: str
|
||
graph: dict[str, Any] | None = None
|
||
run_id: str | None = None
|
||
status: str
|
||
error: str
|
||
#: 复核结论。None = 这一轮没复核过,和「复核过、没发现问题」不是一回事
|
||
review: dict[str, Any] | None = None
|
||
created_at: Any = None
|
||
|
||
model_config = {"from_attributes": True}
|
||
|
||
|
||
class ConversationOut(BaseModel):
|
||
id: str
|
||
title: str
|
||
kind: str
|
||
workflow_id: str | None = None
|
||
archived: bool
|
||
created_at: Any = None
|
||
last_active_at: Any = None
|
||
turn_count: int = 0
|
||
#: 列表里给个副标题,让人一眼认出是哪次聊天
|
||
last_question: str = ""
|
||
|
||
model_config = {"from_attributes": True}
|
||
|
||
|
||
class ConversationDetail(ConversationOut):
|
||
turns: list[TurnOut] = Field(default_factory=list)
|
||
|
||
# from_attributes 不能直接吃 Conversation:pydantic 会去读同名的 turns
|
||
# 关系属性,而那是个未加载的 lazy relationship,在异步上下文里一读就是
|
||
# MissingGreenlet。turns 一律显式传进来
|
||
model_config = {"from_attributes": False}
|
||
|
||
|
||
def _detail(row: Conversation, turns: list[ConversationTurn]) -> ConversationDetail:
|
||
base = ConversationOut.model_validate(row)
|
||
return ConversationDetail(
|
||
**base.model_dump(),
|
||
turns=[TurnOut.model_validate(t) for t in turns],
|
||
)
|
||
|
||
|
||
# --------------------------------------------------------------------------
|
||
# 会话
|
||
# --------------------------------------------------------------------------
|
||
|
||
|
||
async def _get(session: AsyncSession, conversation_id: str) -> Conversation:
|
||
row = await session.get(Conversation, conversation_id)
|
||
if not row:
|
||
raise HTTPException(404, f"会话 {conversation_id} 不存在")
|
||
return row
|
||
|
||
|
||
@router.get("", response_model=list[ConversationOut])
|
||
async def list_conversations(
|
||
kind: str = "chat",
|
||
workflow_id: str | None = None,
|
||
include_archived: bool = False,
|
||
session: AsyncSession = Depends(get_session),
|
||
) -> list[ConversationOut]:
|
||
query = select(Conversation).where(Conversation.kind == kind)
|
||
if workflow_id:
|
||
query = query.where(Conversation.workflow_id == workflow_id)
|
||
if not include_archived:
|
||
query = query.where(Conversation.archived.is_(False))
|
||
rows = list((await session.execute(
|
||
query.order_by(Conversation.last_active_at.desc())
|
||
)).scalars())
|
||
if not rows:
|
||
return []
|
||
|
||
# 轮数和最后一问一次查完。每条会话各查一次的话,列表长了就是几十次往返
|
||
ids = [r.id for r in rows]
|
||
counts = dict((await session.execute(
|
||
select(ConversationTurn.conversation_id, func.count(ConversationTurn.id))
|
||
.where(ConversationTurn.conversation_id.in_(ids))
|
||
.group_by(ConversationTurn.conversation_id)
|
||
)).all())
|
||
last_seq = dict((await session.execute(
|
||
select(ConversationTurn.conversation_id, func.max(ConversationTurn.seq))
|
||
.where(ConversationTurn.conversation_id.in_(ids))
|
||
.group_by(ConversationTurn.conversation_id)
|
||
)).all())
|
||
last_questions: dict[str, str] = {}
|
||
if last_seq:
|
||
pairs = list(last_seq.items())
|
||
rows_q = list((await session.execute(
|
||
select(ConversationTurn.conversation_id, ConversationTurn.seq, ConversationTurn.question)
|
||
.where(ConversationTurn.conversation_id.in_([c for c, _ in pairs]))
|
||
)).all())
|
||
for conv_id, seq, question in rows_q:
|
||
if last_seq.get(conv_id) == seq:
|
||
last_questions[conv_id] = question or ""
|
||
|
||
out: list[ConversationOut] = []
|
||
for row in rows:
|
||
item = ConversationOut.model_validate(row)
|
||
item.turn_count = int(counts.get(row.id, 0))
|
||
item.last_question = last_questions.get(row.id, "")
|
||
out.append(item)
|
||
return out
|
||
|
||
|
||
@router.post("", response_model=ConversationDetail, status_code=201)
|
||
async def create_conversation(
|
||
payload: ConversationIn, session: AsyncSession = Depends(get_session)
|
||
) -> ConversationDetail:
|
||
row = Conversation(
|
||
kind=payload.kind,
|
||
workflow_id=payload.workflow_id,
|
||
title=payload.title or "新对话",
|
||
)
|
||
session.add(row)
|
||
await session.commit()
|
||
await session.refresh(row)
|
||
return _detail(row, [])
|
||
|
||
|
||
@router.get("/{conversation_id}", response_model=ConversationDetail)
|
||
async def get_conversation(
|
||
conversation_id: str, session: AsyncSession = Depends(get_session)
|
||
) -> ConversationDetail:
|
||
row = await _get(session, conversation_id)
|
||
turns = list((await session.execute(
|
||
select(ConversationTurn)
|
||
.where(ConversationTurn.conversation_id == conversation_id)
|
||
.order_by(ConversationTurn.seq)
|
||
)).scalars())
|
||
out = _detail(row, turns)
|
||
out.turn_count = len(turns)
|
||
out.last_question = turns[-1].question if turns else ""
|
||
return out
|
||
|
||
|
||
@router.patch("/{conversation_id}", response_model=ConversationOut)
|
||
async def patch_conversation(
|
||
conversation_id: str,
|
||
payload: ConversationPatch,
|
||
session: AsyncSession = Depends(get_session),
|
||
) -> ConversationOut:
|
||
row = await _get(session, conversation_id)
|
||
if payload.title is not None:
|
||
row.title = payload.title.strip() or row.title
|
||
if payload.archived is not None:
|
||
row.archived = payload.archived
|
||
await session.commit()
|
||
await session.refresh(row)
|
||
return ConversationOut.model_validate(row)
|
||
|
||
|
||
@router.delete("/{conversation_id}", status_code=204)
|
||
async def delete_conversation(
|
||
conversation_id: str, session: AsyncSession = Depends(get_session)
|
||
) -> None:
|
||
row = await _get(session, conversation_id)
|
||
# 显式删 turns 而不是只靠 ondelete=CASCADE:SQLite 的外键级联要 PRAGMA
|
||
# 打开才生效(base.py 里是打开的),但这条路径不该依赖一个连接级的开关
|
||
await session.execute(
|
||
delete(ConversationTurn).where(ConversationTurn.conversation_id == conversation_id)
|
||
)
|
||
await session.delete(row)
|
||
await session.commit()
|
||
|
||
|
||
# --------------------------------------------------------------------------
|
||
# 轮次
|
||
# --------------------------------------------------------------------------
|
||
|
||
|
||
@router.post("/{conversation_id}/turns", response_model=TurnOut, status_code=201)
|
||
async def create_turn(
|
||
conversation_id: str,
|
||
payload: TurnIn,
|
||
session: AsyncSession = Depends(get_session),
|
||
) -> TurnOut:
|
||
conversation = await _get(session, conversation_id)
|
||
next_seq = int((await session.execute(
|
||
select(func.coalesce(func.max(ConversationTurn.seq), -1))
|
||
.where(ConversationTurn.conversation_id == conversation_id)
|
||
)).scalar_one()) + 1
|
||
|
||
turn = ConversationTurn(
|
||
conversation_id=conversation_id, seq=next_seq,
|
||
question=payload.question, status="running",
|
||
)
|
||
session.add(turn)
|
||
|
||
conversation.last_active_at = utcnow()
|
||
# 第一轮的问题就是这次对话的标题。用户改过名就不要再覆盖回去
|
||
if next_seq == 0 and conversation.title in ("", "新对话"):
|
||
conversation.title = title_from(payload.question)
|
||
|
||
await session.commit()
|
||
await session.refresh(turn)
|
||
return TurnOut.model_validate(turn)
|
||
|
||
|
||
@router.patch("/{conversation_id}/turns/{turn_id}", response_model=TurnOut)
|
||
async def patch_turn(
|
||
conversation_id: str,
|
||
turn_id: str,
|
||
payload: TurnPatch,
|
||
session: AsyncSession = Depends(get_session),
|
||
) -> TurnOut:
|
||
turn = await session.get(ConversationTurn, turn_id)
|
||
if not turn or turn.conversation_id != conversation_id:
|
||
raise HTTPException(404, f"轮次 {turn_id} 不存在")
|
||
|
||
for field in (
|
||
"question", "answer", "explanation", "graph", "run_id", "status", "error", "review",
|
||
):
|
||
value = getattr(payload, field)
|
||
if value is not None:
|
||
setattr(turn, field, value)
|
||
|
||
conversation = await session.get(Conversation, conversation_id)
|
||
if conversation:
|
||
conversation.last_active_at = utcnow()
|
||
await session.commit()
|
||
await session.refresh(turn)
|
||
return TurnOut.model_validate(turn)
|
||
|
||
|
||
# --------------------------------------------------------------------------
|
||
# 给模型看的那一份
|
||
# --------------------------------------------------------------------------
|
||
|
||
|
||
def _answer_of(turn: ConversationTurn) -> str:
|
||
"""这一轮的结果。问数据页是 answer,画布那条路径上只有 explanation。"""
|
||
text = (turn.answer or turn.explanation or "").strip()
|
||
if turn.status == "error" and turn.error:
|
||
return f"(这一轮失败了:{turn.error[:200]})"
|
||
if len(text) > HISTORY_ANSWER_CHARS:
|
||
text = text[:HISTORY_ANSWER_CHARS] + "…(略)"
|
||
# 复核判过不可信的答案,下一轮必须知道。只给正文的话,模型会拿一个已知
|
||
# 有问题的结论当事实继续往下推,错误就这么一轮轮传下去了
|
||
note = (turn.review or {}).get("note", "") if isinstance(turn.review, dict) else ""
|
||
if note:
|
||
text = f"{text}\n(复核提示:{note[:200]})".strip()
|
||
return text
|
||
|
||
|
||
async def recent_turns(
|
||
session: AsyncSession, conversation_id: str, limit: int = HISTORY_TURNS
|
||
) -> list[ConversationTurn]:
|
||
"""最近 limit 轮,按时间正序。当前这一轮(还没有答案)不算。"""
|
||
rows = list((await session.execute(
|
||
select(ConversationTurn)
|
||
.where(ConversationTurn.conversation_id == conversation_id)
|
||
.where(ConversationTurn.status != "running")
|
||
.order_by(ConversationTurn.seq.desc())
|
||
.limit(limit)
|
||
)).scalars())
|
||
return list(reversed(rows))
|
||
|
||
|
||
async def history_text(
|
||
session: AsyncSession, conversation_id: str | None, limit: int = HISTORY_TURNS
|
||
) -> str:
|
||
"""纯文本的问答历史,给运行时的图用(`{{ input.history }}`)。
|
||
|
||
管的是指代:用户问"它们平均多少",图里的 agent 得知道"它们"是上一轮
|
||
那 12 家店。Copilot 侧的上下文解决不了这个——那只影响图长什么样,
|
||
不影响图跑起来的时候模型看到什么。
|
||
"""
|
||
if not conversation_id:
|
||
return ""
|
||
turns = await recent_turns(session, conversation_id, limit)
|
||
if not turns:
|
||
return ""
|
||
lines = [f"问:{t.question}\n答:{_answer_of(t)}" for t in turns if t.question]
|
||
return "\n\n".join(lines)
|