Files
AgentLab/backend/app/api/conversations.py
T
ThearchyHelios 0d9f1d9339 用户看得到的列表补决胜键(复核 R-a9):按名称、时间等可能并列的排序后面补上同一个模型的 id,并列时两种库排出来一样
- 实测 /api/evals/calibers、/api/schedules/trigger-options 按 Workflow.name 排序遇同名并列时 SQLite、PostgreSQL 顺序不同。用 AST 逐个过了一遍 order_by:最后一个排序键是 created_at / updated_at / last_active_at / at / name / 各种 *_at 之类、不唯一的列,又不带 DISTINCT、GROUP BY 的,补「, 模型.id」(降序补 id.desc()),共 104 处,分布在 api、apps、auth、blueprints、chat、data、engine、eval、ledger、look、memory、process、providers、scheduler(runs.py 里的三处在上一个提交里)
- 按唯一列(seq、version、唯一的 name)排的不动;按聚合、coalesce 之类表达式排的说明见 db/postgres
2026-10-19 12:07:06 +08:00

675 lines
30 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""会话:把一串问答串成一次对话。
在此之前"对话"只是前端内存里的一个数组——刷新页面就没了,而每一轮又各自
从零建图,所以"上一轮问过什么"在系统里无处可查。这个模块把它落到库里,
同时服务两件事:给用户看的历史列表,和给模型看的上下文(`_history_text`
被 copilot 拿去拼进 prompt)。
问数据的轮次由服务端写(docs/design/orchestration-round §1):一轮是一个后台任务(app/chat/job.py),建图、运行、复核、
按复核重来、落库都在服务端,这一行的状态、运行 id、答案、复核、meta 由它写(app/chat/record.py 说明了每一项);页面只发起
(POST /api/chat/turns)、只看。以前这一行是页面写的(开轮 → 回填),页面一关就没人收尾。开轮、回填这两个接口留着:画布助手的
对话和老页面还在用,回填也用来改标题这类事,但问数据的一轮不再依赖它们。每一轮带着它的任务现在怎样(TurnOut.job),列表判
「还在跑」先看任务,不再只靠 15 分钟没动静来猜。
对话按人隔离(D-d):新建时记下建立人(created_by = actor_of)。开登录后 user 角色只列得出、读得到、改得动、删得掉
自己建的对话;created_by 为空的老对话(关登录时建的)只有 builder 以上看得到。应用编辑器的助手对话(kind=app)另要
这个应用的 manage。接受 conversation_id 的别的接口(助手的生成流、应用润色、从运行提取)按同一套规则核对:
ensure_conversation_access。关登录时一切照旧。
"""
from __future__ import annotations
from datetime import datetime, timedelta, timezone
from typing import Any
from fastapi import APIRouter, Depends, Header, HTTPException
from pydantic import BaseModel, Field
from sqlalchemy import and_, delete, func, select, update
from sqlalchemy.ext.asyncio import AsyncSession
from app.api.deps import actor_of, current_principal
from app.db.base import get_session, utcnow
from app.db.models import Approval, Conversation, ConversationTurn, Run
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
#: 可信度元数据,前端整块写、整块读,后端不解读它的内容(列表状态除外,见 last_status)
meta: 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
#: 可信度元数据。老轮次挂在 review.meta 下,读出来时统一放到这里
meta: dict[str, Any] | None = None
created_at: Any = None
#: 最后一次落库。列表判「建图断了」按它算(BUILD_STALE),前端用同一只钟
updated_at: Any = None
#: 这一轮在服务端的任务现在怎样(app/chat/job.job_view:status、stage、last_seq、attempt_seq……)。老轮次没有任务,为 None
job: dict[str, Any] | None = None
model_config = {"from_attributes": True}
class ConversationOut(BaseModel):
id: str
title: str
kind: str
workflow_id: str | None = None
#: 应用编辑器的助手对话(kind=app)归属的应用
app_id: str | None = None
#: 谁建的。关登录时是自报的署名;老对话为空
created_by: str | None = None
archived: bool
created_at: Any = None
last_active_at: Any = None
turn_count: int = 0
#: 列表里给个副标题,让人一眼认出是哪次聊天
last_question: str = ""
#: 最后一轮停在哪:running | waiting | error | cancelled | rejected | suspended | done,
#: 没有轮次时为 None。见 last_status()
last_status: str | None = None
#: 最后一轮的运行。建图阶段就断了的没有
last_run_id: str | None = None
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 turn_meta(turn: Any) -> dict[str, Any] | None:
"""这一轮的元数据:meta 列优先,老数据退回 review.meta。"""
if isinstance(turn.meta, dict):
return turn.meta
bridged = turn.review.get("meta") if isinstance(turn.review, dict) else None
return bridged if isinstance(bridged, dict) else None
def turn_run_id(turn: Any) -> str | None:
"""这一轮现在的运行。meta 里写了 runId 就以它为准,哪怕是 null:重试会换一次运行,
run_id 列却清不掉(回填里 None 表示「不改」),还指着上一次的。老轮次没有这个键,看列。"""
meta = turn_meta(turn) or {}
if "runId" in meta:
return meta["runId"] or None
return turn.run_id or None
def _turn_out(turn: ConversationTurn, job: Any = None) -> TurnOut:
from app.chat.job import job_view
out = TurnOut.model_validate(turn)
out.meta = turn_meta(turn)
out.job = job_view(job) if job is not None else None
return out
async def _jobs(session: AsyncSession, turn_ids: list[str]) -> dict[str, Any]:
"""这几轮在服务端的任务(一次查完)。"""
from app.db.models_chat import ChatTurnJob
if not turn_ids:
return {}
rows = (await session.execute(select(ChatTurnJob).where(ChatTurnJob.id.in_(turn_ids)))).scalars()
return {j.id: j for j in rows}
async def turn_out(session: AsyncSession, turn: ConversationTurn) -> TurnOut:
"""一轮(带着它的任务),同 GET /api/conversations/{id} 里的一轮:服务端跑的一轮收尾时把它放进 end 事件。"""
return _turn_out(turn, (await _jobs(session, [turn.id])).get(turn.id))
def _detail(row: Conversation, turns: list[ConversationTurn], jobs: dict[str, Any] | None = None) -> ConversationDetail:
base = ConversationOut.model_validate(row)
return ConversationDetail(
**base.model_dump(),
turns=[_turn_out(t, (jobs or {}).get(t.id)) for t in turns],
)
#: 还没有运行的轮次停在 running 超过这么久,就当建图断了。建图跟着浏览器那条流走,
#: 页面一关就停,轮次却一直是 running;最长的正常情况是模型超时再加两轮自查修正
BUILD_STALE = timedelta(minutes=15)
def last_status(
turn: Any, run_status: str | None, pending: bool, now: datetime | None = None, end_reason: str | None = None,
job_stage: str | None = None,
) -> str:
"""一轮停在哪。轮次状态只有 running / done / error 三种,细分要看运行和元数据。
job_stage:这一轮在服务端的任务还在跑时它在哪一步(app/chat/job.py;没有任务、任务不在跑时为 None)。任务在跑就是在跑
(停在审批上的是 waiting),不按 15 分钟没动静去猜——建图慢的一轮,那一行要到建完才改。
没有任务的老轮次是页面写的:页面关掉、服务重启时它停在 running,这时要以运行的状态为准;
运行也没有的,是断在了建图阶段。已经收尾的轮次以轮次自己为准——它写下的就是
用户看到的结局(取消、服务重启挂起在 error 里分开记)。
被审批人驳回结束的(运行 cancelled + end_reason=rejected,05-已知遗留 (a))单独记 rejected,不和手动停止混成
cancelled:前端落库时 meta 里记着 rejected(outcome 照旧是 cancelled),页面关掉没落库的看运行的 end_reason。
"""
meta = turn_meta(turn) or {}
outcome = meta.get("outcome")
if job_stage is not None and turn.status == "running":
return "waiting" if job_stage == "waiting" else "running"
if turn.status == "done":
return "done"
if turn.status == "error":
if outcome == "cancelled" and meta.get("rejected"):
return "rejected"
if outcome == "cancelled" or (turn.error or "").strip() == "已取消":
return "cancelled"
if outcome == "suspended":
# 挂起之后可能已经在别处接着跑了
live = _from_run(run_status, pending)
return live if live in ("running", "waiting") else "suspended"
return "error"
live = _from_run(run_status, pending, end_reason)
if live:
return live
updated = turn.updated_at
if updated is not None and updated.tzinfo is None:
updated = updated.replace(tzinfo=timezone.utc)
stale = updated is not None and (now or utcnow()) - updated > BUILD_STALE
return "error" if stale else "running"
def _from_run(run_status: str | None, pending: bool, end_reason: str | None = None) -> str | None:
if run_status == "cancelled" and end_reason == "rejected":
return "rejected"
return {
"queued": "running", "running": "running",
"interrupted": "waiting" if pending else "suspended",
"succeeded": "done", "failed": "error", "cancelled": "cancelled",
}.get(run_status or "")
async def _last_turns(
session: AsyncSession, ids: list[str]
) -> dict[str, ConversationTurn]:
"""每个会话 seq 最大的那一轮。一次查完,列表长了也只是一次往返。"""
latest = (
select(ConversationTurn.conversation_id, func.max(ConversationTurn.seq).label("seq"))
.where(ConversationTurn.conversation_id.in_(ids))
.group_by(ConversationTurn.conversation_id)
.subquery()
)
rows = (await session.execute(
select(ConversationTurn).join(latest, and_(
ConversationTurn.conversation_id == latest.c.conversation_id,
ConversationTurn.seq == latest.c.seq,
))
)).scalars()
return {t.conversation_id: t for t in rows}
async def _statuses(
session: AsyncSession, turns: dict[str, ConversationTurn]
) -> dict[str, tuple[str, str | None]]:
"""会话 id → (last_status, last_run_id)。运行状态和待审批各一次查询。"""
run_of = {cid: turn_run_id(t) for cid, t in turns.items()}
run_ids = {r for r in run_of.values() if r}
runs: dict[str, str] = {}
ended: dict[str, str | None] = {}
waiting: set[str] = set()
if run_ids:
rows = (await session.execute(
select(Run.id, Run.status, Run.end_reason).where(Run.id.in_(run_ids))
)).all()
runs = {r: st for r, st, _ in rows}
ended = {r: reason for r, _, reason in rows}
stopped = [r for r, st in runs.items() if st == "interrupted"]
if stopped:
waiting = set((await session.execute(
select(Approval.run_id).where(
Approval.run_id.in_(stopped), Approval.status == "pending")
)).scalars())
from app.core.job_runtime import ACTIVE
jobs = await _jobs(session, [t.id for t in turns.values()])
now = utcnow()
out: dict[str, tuple[str, str | None]] = {}
for cid, turn in turns.items():
run_id = run_of[cid] if run_of[cid] in runs else None
job = jobs.get(turn.id)
stage = (job.stage or "build") if job is not None and job.status in ACTIVE else None
out[cid] = (last_status(turn, runs.get(run_id or ""), run_id in waiting, now,
end_reason=ended.get(run_id or ""), job_stage=stage),
run_id)
return out
# --------------------------------------------------------------------------
# 会话
# --------------------------------------------------------------------------
#: 对话按人隔离时拒绝的说法
NOT_YOURS = "没有权限查看这段对话:开启登录后,使用者只能查看自己建立的对话"
APP_MANAGE_ONLY = "没有权限查看这段对话:应用编辑器的助手对话只对能管理这个应用的人开放"
def _isolated() -> str | None:
"""开登录后的 user 角色:只看得到自己建的对话,交回它的用户名;关登录、builder 以上为 None(不按人收窄)。"""
from app.api.deps import restricted
return (current_principal().username or "") if restricted() else None
async def _manages_app(session: AsyncSession, app_id: str) -> bool:
"""这个人能不能管理这个应用(app/apps/access.py:关登录、admin 都能;builder 要是建立人或有 manage 授权)。应用已经
删掉的,只有 admin 看得到它留下的对话。"""
from app.apps.access import app_role, at_least
from app.db.models import App
principal = current_principal()
if principal.mode == "off" or principal.role == "admin":
return True
app = await session.get(App, app_id)
return app is not None and at_least(await app_role(session, principal, app), "manage")
async def _check_access(session: AsyncSession, row: Conversation) -> None:
"""这段对话这个人能不能用,不能用抛 403。蓝图对话跟着蓝图走(blueprints/store.ensure_access):别人的个人蓝图的对话,
搭建者、管理员也看不到(§3.12:别人的个人应用内容谁都不看),当它不存在(404,同蓝图)。"""
mine = _isolated()
if mine is not None and (not row.created_by or row.created_by != mine):
raise HTTPException(403, NOT_YOURS)
if row.kind == "app" and row.app_id and not await _manages_app(session, row.app_id):
raise HTTPException(403, APP_MANAGE_ONLY)
if row.kind == "blueprint":
from app.blueprints.store import BLUEPRINT_GONE, ensure_access
from app.db.models_blueprints import Blueprint
bp = (await session.execute(select(Blueprint).where(Blueprint.conversation_id == row.id))).scalars().first()
if bp is None:
raise HTTPException(404, BLUEPRINT_GONE)
await ensure_access(session, bp)
async def ensure_conversation_access(session: AsyncSession, conversation_id: str | None) -> Conversation | None:
"""接受 conversation_id 的接口(助手的 generate-stream、generate、app-stream、from-run……)在读历史、记来源之前调。
能用交回对话;没给 id、对话不存在交回 None——和加这道核对之前一样当没有历史,前端带着一个刚删掉的对话 id 也不至于
整轮失败;存在但这个人不能用(开登录后 user 角色碰别人的或老对话,没有 manage 的人碰应用的助手对话)回 403,免得
别人的问答经「历史」拼进这一轮的提示、再从回答里带出来。关登录时只要存在就能用。"""
if not conversation_id:
return None
row = await session.get(Conversation, conversation_id)
if row is None:
return None
await _check_access(session, row)
return row
async def _get(session: AsyncSession, conversation_id: str) -> Conversation:
row = await session.get(Conversation, conversation_id)
if not row:
raise HTTPException(404, "对话不存在,可能已被删除")
await _check_access(session, row)
return row
@router.get("", response_model=list[ConversationOut])
async def list_conversations(
kind: str = "chat",
workflow_id: str | None = None,
app_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 app_id:
query = query.where(Conversation.app_id == app_id)
if not include_archived:
query = query.where(Conversation.archived.is_(False))
if (mine := _isolated()) is not None:
# 开登录后的 user 角色:只列自己建的;建立人为空的老对话不列
query = query.where(Conversation.created_by == mine)
rows = list((await session.execute(
query.order_by(Conversation.last_active_at.desc(), Conversation.id.desc())
)).scalars())
# 应用的助手对话只列能管理的应用的(一个应用一次判断)
managed: dict[str, bool] = {}
kept = []
for row in rows:
if row.kind == "app" and row.app_id:
if row.app_id not in managed:
managed[row.app_id] = await _manages_app(session, row.app_id)
if not managed[row.app_id]:
continue
if row.kind == "blueprint":
# 蓝图对话跟着蓝图走:别人的个人蓝图的对话不列
try:
await _check_access(session, row)
except HTTPException:
continue
kept.append(row)
rows = kept
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 = await _last_turns(session, ids)
status = await _statuses(session, last)
out: list[ConversationOut] = []
for row in rows:
item = ConversationOut.model_validate(row)
item.turn_count = int(counts.get(row.id, 0))
if row.id in last:
item.last_question = last[row.id].question or ""
item.last_status, item.last_run_id = status[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),
x_actor: str | None = Header(default=None),
) -> ConversationDetail:
# 应用编辑器的助手对话不从这里建:GET /api/apps/{id}/assistant-conversation 先核对 manage,再取或建
row = Conversation(
kind=payload.kind,
workflow_id=payload.workflow_id,
title=payload.title or "新对话",
created_by=actor_of(x_actor),
)
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:
return await conversation_detail(session, await _get(session, conversation_id))
async def conversation_detail(session: AsyncSession, row: Conversation) -> ConversationDetail:
"""一段对话连同各轮(权限由调用方先判过)。蓝图对话另经 GET /api/blueprints/{id}/conversation 取:那条路按蓝图判,
没开问数据的使用者也读得到自己蓝图的对话。"""
conversation_id = row.id
turns = list((await session.execute(
select(ConversationTurn)
.where(ConversationTurn.conversation_id == conversation_id)
.order_by(ConversationTurn.seq)
)).scalars())
out = _detail(row, turns, await _jobs(session, [t.id for t in turns]))
out.turn_count = len(turns)
out.last_question = turns[-1].question if turns else ""
if turns:
out.last_status, out.last_run_id = (await _statuses(session, {row.id: turns[-1]}))[row.id]
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:
from app.chat import job as chat_job
row = await _get(session, conversation_id)
# 正在服务端跑的几轮先停下(取消各自的运行):不然删掉之后它还在跑、还在计费,收尾时又找不到那一行
await chat_job.stop_conversation(conversation_id)
# 显式删 turns 而不是只靠 ondelete=CASCADE:SQLite 的外键级联要 PRAGMA
# 打开才生效(base.py 里是打开的),但这条路径不该依赖一个连接级的开关。任务和事件日志同理
await chat_job.forget(session, conversation_id)
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)
turn = await new_turn(session, conversation, payload.question)
await session.commit()
await session.refresh(turn)
return _turn_out(turn)
async def new_turn(session: AsyncSession, conversation: Conversation, question: str) -> ConversationTurn:
"""开一轮(status=running,只有问题),不提交:开轮接口和服务端发起的一轮(app/chat/job.start)共用。
先记活跃时间、再数下一轮排第几:记活跃时间是这个事务的第一句写,占住这段对话(SQLite 上拿到写锁、PostgreSQL 上锁住
这一行),同时开的两轮一个接一个数,不会都拿到同一个 seq。"""
now = utcnow()
await session.execute(update(Conversation).where(Conversation.id == conversation.id).values(last_active_at=now)
.execution_options(synchronize_session=False))
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=question, status="running",
)
session.add(turn)
conversation.last_active_at = now
# 第一轮的问题就是这次对话的标题。用户改过名就不要再覆盖回去
if next_seq == 0 and conversation.title in ("", "新对话"):
conversation.title = title_from(question)
await session.flush()
return turn
async def turn_access(session: AsyncSession, turn_id: str) -> tuple[Conversation, ConversationTurn]:
"""这一轮这个人能不能用(按它所属的对话判,规则同 _get:对话按人隔离)。不存在 404,不能用 403。"""
turn = await session.get(ConversationTurn, turn_id)
if turn is None:
raise HTTPException(404, "该轮对话不存在,可能已被删除")
return await _get(session, turn.conversation_id), 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:
await _get(session, conversation_id)
turn = await session.get(ConversationTurn, turn_id)
if not turn or turn.conversation_id != conversation_id:
raise HTTPException(404, "该轮对话不存在,可能已被删除")
for field in (
"question", "answer", "explanation", "graph", "run_id", "status", "error", "review", "meta",
):
value = getattr(payload, field)
if value is not None:
setattr(turn, field, value)
# 活跃时间只认人在这里开始做事:改问题、按下运行、重试(状态回到 running)。
# 打开会话时前端的回源核对也走这里——补全截断的答案、记下运行已删、把断掉的
# 建图写回 error、补交付错过的结果——这些不算,否则旧会话一打开就顶到列表最上面
if payload.question is not None or payload.run_id is not None or payload.status == "running":
conversation = await session.get(Conversation, conversation_id)
if conversation:
conversation.last_active_at = utcnow()
await session.commit()
await session.refresh(turn)
return _turn_out(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))
#: 运行入口的 history 里每轮的答案截到多少字(同以前问数据页 historyOf 的 slice(0, 600))
RUN_HISTORY_CHARS = 600
async def run_history(session: AsyncSession, conversation_id: str | None, *, before_seq: int,
limit: int = HISTORY_TURNS) -> str:
"""问数据一轮发起运行时填进入口的 history(`{{ input.history }}`):只取这一轮之前的(seq < before_seq),照以前问数据页
historyOf 的规则——答案没有就用说明,都没有的跳过,每轮截 600 字,最多 limit 轮,「问:…\n答:…」之间空一行。
以前这一份是页面按它内存里的轮次拼的;一轮搬到服务端之后在这里拼。和 history_text 不一样的地方是只看这一轮之前的:
重试、重新运行较早的一轮时,后面接着问过的几轮不该进它的入口——那时候它还没被问过。"""
if not conversation_id:
return ""
rows = list((await session.execute(
select(ConversationTurn.question, ConversationTurn.answer, ConversationTurn.explanation)
.where(ConversationTurn.conversation_id == conversation_id, ConversationTurn.seq < before_seq)
.order_by(ConversationTurn.seq)
)).all())
lines = []
for question, answer, explanation in rows:
text = answer or explanation or ""
if question and text:
lines.append(f"问:{question}\n答:{text[:RUN_HISTORY_CHARS]}")
return "\n\n".join(lines[-limit:])
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)