mirror of
https://github.com/ThearchyHelios/AgentLab.git
synced 2026-11-15 00:11:59 +00:00
问数据:被驳回的那次尝试不写「已手动停止」,会话列表标「被驳回」
为什么:一轮的运行被审批人驳回之后点「重新运行本轮」,「之前的尝试」那一行写「已手动停止」,用户其实没有停过它。会话 列表的 last_status 把被驳回的一轮归成 cancelled,左栏不标——在等审批的那一问被驳回之后,看起来像是答完了(以前驳回 记成失败,左栏标的是「失败」)。 做了什么: - store/chat 的 attemptOf:被驳回的那次记 outcome=rejected,摘要写「被驳回:张工 驳回了「查数」:…」;老留档里记成 cancelled 的不改写。ChatPage 的尝试徽标认 rejected。 - api/conversations 的 last_status:前端落库时 meta 里记着 rejected 的、页面关掉没落库而运行是 cancelled + end_reason=rejected 的,都记 rejected;手动停止照旧 cancelled。types 的 last_status 加上 rejected。 - 左栏:打开过的看最后一轮(相位 cancelled 且带着驳回),没打开过的看 last_status,都标「被驳回」(不画异常色)。 测试:test_conversation_status 加一条(两种认法的被驳回、两种手动停止),改之前失败;check-chat 加一节被驳回(刷新后 认得出、补救区的话、左栏两种、重新运行之后的尝试记录),把前端改动退回去回跑,那一节 4 项不通过,改完通过。
This commit is contained in:
@@ -118,7 +118,7 @@ class ConversationOut(BaseModel):
|
||||
turn_count: int = 0
|
||||
#: 列表里给个副标题,让人一眼认出是哪次聊天
|
||||
last_question: str = ""
|
||||
#: 最后一轮停在哪:running | waiting | error | cancelled | suspended | done,
|
||||
#: 最后一轮停在哪:running | waiting | error | cancelled | rejected | suspended | done,
|
||||
#: 没有轮次时为 None。见 last_status()
|
||||
last_status: str | None = None
|
||||
#: 最后一轮的运行。建图阶段就断了的没有
|
||||
@@ -173,18 +173,24 @@ BUILD_STALE = timedelta(minutes=15)
|
||||
|
||||
|
||||
def last_status(
|
||||
turn: Any, run_status: str | None, pending: bool, now: datetime | None = None,
|
||||
turn: Any, run_status: str | None, pending: bool, now: datetime | None = None, end_reason: str | None = None,
|
||||
) -> str:
|
||||
"""一轮停在哪。轮次状态只有 running / done / error 三种,细分要看运行和元数据。
|
||||
|
||||
轮次状态是前端写的:页面关掉、服务重启时它停在 running,这时要以运行的状态为准;
|
||||
运行也没有的,是断在了建图阶段。已经收尾的轮次以轮次自己为准——它写下的就是
|
||||
用户看到的结局(取消、服务重启挂起在 error 里分开记)。
|
||||
|
||||
被审批人驳回结束的(运行 cancelled + end_reason=rejected,05-已知遗留 (a))单独记 rejected,不和手动停止混成
|
||||
cancelled:前端落库时 meta 里记着 rejected(outcome 照旧是 cancelled),页面关掉没落库的看运行的 end_reason。
|
||||
"""
|
||||
outcome = (turn_meta(turn) or {}).get("outcome")
|
||||
meta = turn_meta(turn) or {}
|
||||
outcome = meta.get("outcome")
|
||||
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":
|
||||
@@ -192,7 +198,7 @@ def last_status(
|
||||
live = _from_run(run_status, pending)
|
||||
return live if live in ("running", "waiting") else "suspended"
|
||||
return "error"
|
||||
live = _from_run(run_status, pending)
|
||||
live = _from_run(run_status, pending, end_reason)
|
||||
if live:
|
||||
return live
|
||||
updated = turn.updated_at
|
||||
@@ -202,7 +208,9 @@ def last_status(
|
||||
return "error" if stale else "running"
|
||||
|
||||
|
||||
def _from_run(run_status: str | None, pending: bool) -> str | None:
|
||||
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",
|
||||
@@ -236,11 +244,14 @@ async def _statuses(
|
||||
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:
|
||||
runs = dict((await session.execute(
|
||||
select(Run.id, Run.status).where(Run.id.in_(run_ids))
|
||||
)).all())
|
||||
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(
|
||||
@@ -251,7 +262,8 @@ async def _statuses(
|
||||
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
|
||||
out[cid] = (last_status(turn, runs.get(run_id or ""), run_id in waiting, now),
|
||||
out[cid] = (last_status(turn, runs.get(run_id or ""), run_id in waiting, now,
|
||||
end_reason=ended.get(run_id or "")),
|
||||
run_id)
|
||||
return out
|
||||
|
||||
|
||||
@@ -43,9 +43,9 @@ async def _turn(client, conv_id: str, question: str = "订单数多少", **patch
|
||||
return turn
|
||||
|
||||
|
||||
async def _run(status: str, *, pending: bool = False) -> str:
|
||||
async def _run(status: str, *, pending: bool = False, end_reason: str | None = None) -> str:
|
||||
async with SessionLocal() as session:
|
||||
run = Run(workflow_name="状态测试", status=status)
|
||||
run = Run(workflow_name="状态测试", status=status, end_reason=end_reason)
|
||||
session.add(run)
|
||||
await session.flush()
|
||||
if pending:
|
||||
@@ -162,6 +162,39 @@ async def test_the_list_says_where_each_conversation_last_stopped(client):
|
||||
assert got == cases
|
||||
|
||||
|
||||
async def test_a_rejected_turn_is_listed_as_rejected_not_cancelled(client):
|
||||
"""被审批人驳回结束的一轮(运行 cancelled + end_reason=rejected,05-已知遗留 (a)):列表说「被驳回」,不和手动停止混成
|
||||
cancelled——发问的人不一定知道被驳回了,以前驳回记成失败,列表标的是「失败」。认法两条:前端落库时记下的
|
||||
meta.rejected,和页面关掉、轮次还停在 running 时运行的 end_reason。手动停止照旧是 cancelled。"""
|
||||
cases: dict[str, tuple[str, str | None]] = {}
|
||||
|
||||
conv = await _conversation(client, "驳回:前端落库了")
|
||||
run = await _run("cancelled", end_reason="rejected")
|
||||
await _turn(client, conv, run_id=run, status="error", error="已取消",
|
||||
meta={"v": 1, "runId": run, "outcome": "cancelled", "rejected": {"sub": "张工 驳回了「付款」"}})
|
||||
cases[conv] = ("rejected", run)
|
||||
|
||||
conv = await _conversation(client, "驳回:页面关了")
|
||||
run = await _run("cancelled", end_reason="rejected")
|
||||
await _turn(client, conv, run_id=run)
|
||||
cases[conv] = ("rejected", run)
|
||||
|
||||
conv = await _conversation(client, "手动停止:页面关了")
|
||||
run = await _run("cancelled", end_reason="stopped")
|
||||
await _turn(client, conv, run_id=run)
|
||||
cases[conv] = ("cancelled", run)
|
||||
|
||||
conv = await _conversation(client, "手动停止:前端落库了")
|
||||
run = await _run("cancelled", end_reason="stopped")
|
||||
await _turn(client, conv, run_id=run, status="error", error="已取消", meta={"v": 1, "runId": run, "outcome": "cancelled"})
|
||||
cases[conv] = ("cancelled", run)
|
||||
|
||||
listed = await _listed(client)
|
||||
assert {cid: (listed[cid]["last_status"], listed[cid]["last_run_id"]) for cid in cases} == cases
|
||||
detail = (await client.get(f"/api/conversations/{next(iter(cases))}")).json()
|
||||
assert detail["last_status"] == "rejected"
|
||||
|
||||
|
||||
async def test_only_the_last_turn_counts(client):
|
||||
conv = await _conversation(client, "后来好了")
|
||||
await _turn(client, conv, status="error", error="查询超时")
|
||||
|
||||
@@ -802,7 +802,7 @@ function remedyOf(t: ChatTurn): Remedy | null {
|
||||
}
|
||||
|
||||
const OUTCOME: Record<Attempt['outcome'], string> = {
|
||||
failed: 'failed', cancelled: 'cancelled', suspended: 'suspended',
|
||||
failed: 'failed', cancelled: 'cancelled', rejected: 'rejected', suspended: 'suspended',
|
||||
unusable: 'failed', partial: 'waiting', done: 'succeeded',
|
||||
}
|
||||
|
||||
|
||||
@@ -473,6 +473,9 @@ export async function purgeConversation(c: Conversation, navigate: NavigateFunct
|
||||
/**
|
||||
* 这个会话此刻值得标出来的状态(已经取回内存的)。正常完成的不标:正常态安静,
|
||||
* 异常态才醒目。
|
||||
*
|
||||
* 被驳回的标(不画异常色):是审批人的决定,发问的人不一定知道——在等审批的那一问被驳回之后,不标就像是答完了。以前
|
||||
* 驳回记成失败,标的是「失败」
|
||||
*/
|
||||
function liveStatus(turns: ChatTurn[]): string | null {
|
||||
if (!turns.length) return null
|
||||
@@ -481,12 +484,13 @@ function liveStatus(turns: ChatTurn[]): string | null {
|
||||
const last = turns[turns.length - 1]
|
||||
if (last.phase === 'error') return 'failed'
|
||||
if (last.phase === 'suspended') return 'suspended'
|
||||
if (last.phase === 'cancelled' && last.rejected) return 'rejected'
|
||||
return null
|
||||
}
|
||||
|
||||
/** 没打开过的会话:列表接口带来的最后一轮状态。完成、取消的不标,同上 */
|
||||
/** 没打开过的会话:列表接口带来的最后一轮状态。完成、取消的不标,被驳回的标,同上 */
|
||||
const LIST_STATUS: Partial<Record<NonNullable<Conversation['last_status']>, string>> = {
|
||||
running: 'running', waiting: 'waiting', error: 'failed', suspended: 'suspended',
|
||||
running: 'running', waiting: 'waiting', error: 'failed', suspended: 'suspended', rejected: 'rejected',
|
||||
}
|
||||
const listStatus = (c: Conversation): string | null => (c.last_status && LIST_STATUS[c.last_status]) || null
|
||||
|
||||
|
||||
@@ -106,7 +106,8 @@ export interface ScopeSource {
|
||||
*/
|
||||
export interface Attempt {
|
||||
n: number
|
||||
outcome: 'failed' | 'cancelled' | 'suspended' | 'unusable' | 'partial' | 'done'
|
||||
/** rejected:那一次的运行被人驳回结束(不是手动停止,也不是故障)。老的留档里被驳回的那次记的是 cancelled,不改写 */
|
||||
outcome: 'failed' | 'cancelled' | 'rejected' | 'suspended' | 'unusable' | 'partial' | 'done'
|
||||
summary: string
|
||||
detail?: string
|
||||
runId?: string | null
|
||||
@@ -1181,6 +1182,11 @@ function attemptOf(t: ChatTurn): Attempt {
|
||||
answer: answer && answer.length > ATTEMPT_ANSWER_CAP ? answer.slice(0, ATTEMPT_ANSWER_CAP) + '…' : answer,
|
||||
}
|
||||
const signals = (t.review?.signals ?? []).map((s) => s.detail).join('\n') || undefined
|
||||
// 被驳回的那次照实写谁在哪一步驳回(以前写「已手动停止」,用户其实没有停过它)
|
||||
if (t.phase === 'cancelled' && t.rejected) {
|
||||
const sub = t.rejected.sub && t.rejected.sub !== REJECTED_TEXT.title ? `:${t.rejected.sub}` : ''
|
||||
return { ...base, outcome: 'rejected', summary: `${REJECTED_TEXT.title}${sub}` }
|
||||
}
|
||||
if (t.phase === 'cancelled') return { ...base, outcome: 'cancelled', summary: '已手动停止' }
|
||||
if (t.phase === 'suspended') return { ...base, outcome: 'suspended', summary: '服务重启,运行中断' }
|
||||
if (t.phase === 'error' || t.failure) {
|
||||
|
||||
@@ -1584,10 +1584,10 @@ export interface Conversation {
|
||||
/** 列表里的副标题,让人一眼认出是哪次聊天 */
|
||||
last_question: string
|
||||
/**
|
||||
* 最后一轮停在哪。左栏对这次没打开过的会话也能标出运行中、待审批、失败。
|
||||
* 没有轮次时为 null;老后端不给
|
||||
* 最后一轮停在哪。左栏对这次没打开过的会话也能标出运行中、待审批、失败、被驳回(rejected:运行被审批人驳回结束,
|
||||
* 服务端 cancelled + end_reason=rejected)。没有轮次时为 null;老后端不给
|
||||
*/
|
||||
last_status?: 'running' | 'waiting' | 'error' | 'cancelled' | 'suspended' | 'done' | null
|
||||
last_status?: 'running' | 'waiting' | 'error' | 'cancelled' | 'rejected' | 'suspended' | 'done' | null
|
||||
/** 最后一轮的运行。建图阶段就断了的没有 */
|
||||
last_run_id?: string | null
|
||||
}
|
||||
|
||||
+58
-1
@@ -198,6 +198,15 @@ const GUARD_CONVS = {
|
||||
const FIX_CONVS = {
|
||||
fixZ: conv('c0fixz', '会话 Z:参数定义有误(后端现在的说法)', 1, 60 * 24 * 27),
|
||||
}
|
||||
/**
|
||||
* 只在「被驳回」那段出现(05-已知遗留 (a)):一轮的运行被审批人驳回结束(服务端 cancelled + end_reason=rejected,
|
||||
* 落库时 meta.outcome 是 cancelled、meta.rejected 记着那一句)。rejR 会打开、重新运行一次;rejS 不打开,状态只能从
|
||||
* 列表接口的 last_status 来
|
||||
*/
|
||||
const REJECT_CONVS = {
|
||||
rejR: conv('c0rejr', '会话:退款那一问', 1, 60 * 24 * 35, { last_status: 'rejected', last_run_id: 'run-rej2' }),
|
||||
rejS: conv('c0rejs', '会话:没打开过的那一问', 1, 60 * 24 * 36, { last_status: 'rejected' }),
|
||||
}
|
||||
/** 只在「助手反问」那段出现:库里存着一轮以反问收尾的(answer 列里是助手问的那句话,meta.asked 标着) */
|
||||
const ASKBACK_CONVS = {
|
||||
askBack: conv('c0askbk', '助手反问过的会话', 1, 60 * 24 * 34),
|
||||
@@ -340,6 +349,9 @@ const DETAIL = {
|
||||
// 要改的是工具库里的参数定义,不在这一轮的流程里:先去改,再接着跑(没记 fix 的老写法,按原话认)
|
||||
c0fixy: [turn('ty1', '查一下订单状态', { status: 'error', error: BROKEN_TOOL, graph: GRAPH, run_id: 'run-badtool' })],
|
||||
c0fixz: [turn('tfz1', '查一下订单状态', { status: 'error', error: BROKEN_TOOL_NOW, graph: GRAPH, run_id: 'run-badtool-now' })],
|
||||
c0rejr: [turn('trj1', '这个月的退款有多少', { status: 'error', error: '已取消', graph: GRAPH, run_id: 'run-rej2',
|
||||
meta: { v: 1, runId: 'run-rej2', runStatus: 'cancelled', outcome: 'cancelled', rejected: { sub: '张工 驳回了「查数」:数字对不上' } } })],
|
||||
c0rejs: [],
|
||||
}
|
||||
/**
|
||||
* 成果带逐段证据的一轮(报告撰写节点 + _evidence)。夹具是后端 compose_doc 真跑出来的,只用
|
||||
@@ -364,6 +376,7 @@ const RUNS = {
|
||||
'run-trash-live': { status: 'running', output: {}, error: null },
|
||||
'run-badtool': { status: 'failed', output: {}, error: BROKEN_TOOL },
|
||||
'run-badtool-now': { status: 'failed', output: {}, error: BROKEN_TOOL_NOW },
|
||||
'run-rej2': { status: 'cancelled', end_reason: 'rejected', output: {}, error: '人工驳回:数字对不上' },
|
||||
}
|
||||
const SOURCES = [
|
||||
{ id: 'ds-mig', name: 'shop', kind: 'mysql', description: '' },
|
||||
@@ -402,6 +415,8 @@ const ctl = {
|
||||
sourcesFail: false,
|
||||
/** 数据源列表里多一个别处刚加的库 */
|
||||
extraSource: false,
|
||||
/** 列表里多摆「被驳回」那段的两个会话(REJECT_CONVS) */
|
||||
rejectConvs: false,
|
||||
}
|
||||
/** 会话的归档 / 删除状态。各段各自 resetDb,免得上一段删掉的会话影响下一段 */
|
||||
const db = { archived: new Set(), purged: new Set() }
|
||||
@@ -413,7 +428,7 @@ resetDb()
|
||||
const allConvs = () => [...Object.values(CONVS), ...Object.values(TRASHED), ...(ctl.trashMore ? Object.values(TRASH_MORE) : []),
|
||||
...(ctl.guardConvs ? Object.values(GUARD_CONVS) : []), ...(ctl.askConvs ? Object.values(ASK_CONVS) : []),
|
||||
...(ctl.askBack ? Object.values(ASKBACK_CONVS) : []),
|
||||
...(ctl.fixConvs ? Object.values(FIX_CONVS) : [])]
|
||||
...(ctl.fixConvs ? Object.values(FIX_CONVS) : []), ...(ctl.rejectConvs ? Object.values(REJECT_CONVS) : [])]
|
||||
.filter((c) => !db.purged.has(c.id))
|
||||
.map((c) => ({ ...c, archived: db.archived.has(c.id) }))
|
||||
let runSeq = 0
|
||||
@@ -1403,6 +1418,48 @@ for (const theme of THEMES) {
|
||||
await ctx.close()
|
||||
})
|
||||
|
||||
await section('rejected', '被驳回:补救区和列表写「被驳回」,重新运行后「之前的尝试」不写「已手动停止」', async () => {
|
||||
ctl.rejectConvs = true
|
||||
try {
|
||||
const { page, ctx, errors } = await open(theme)
|
||||
await goto(page, 'c0rejr')
|
||||
await shows(page, '这个月的退款有多少')
|
||||
await page.waitForTimeout(500)
|
||||
const turn0 = (await chatState(page)).byConversation.c0rejr?.[0]
|
||||
check('刷新之后照样认成被驳回(相位 cancelled,带着驳回的那一句)', turn0?.phase === 'cancelled'
|
||||
&& turn0?.rejected?.sub === '张工 驳回了「查数」:数字对不上', JSON.stringify({ phase: turn0?.phase, rejected: turn0?.rejected }))
|
||||
const card = page.locator('[data-remedy="cancelled"]')
|
||||
const cardText = (await card.innerText().catch(() => '')).replace(/\n/g, ' / ')
|
||||
check('补救区说是审批人驳回、谁在哪一步驳回,不说「你已停止本轮」', cardText.includes('被审批人驳回')
|
||||
&& cardText.includes('张工 驳回了「查数」:数字对不上') && !cardText.includes('你已停止本轮'), cardText)
|
||||
check('被驳回的没有「继续运行」', !(await card.getByRole('button', { name: '继续运行' }).count()))
|
||||
const aside = page.locator('aside[aria-label="对话列表"]')
|
||||
const rowText = async (t) => (await aside.locator('div.group', { hasText: t }).innerText().catch(() => '')).replace(/\n/g, ' / ')
|
||||
// 标题里不带「被驳回」:标出来的是状态,不是标题里的字
|
||||
check('列表里打开过的:标「被驳回」', (await rowText('会话:退款那一问')).includes('被驳回'), await rowText('会话:退款那一问'))
|
||||
check('列表里没打开过的:按 last_status 标「被驳回」,不标「失败」', (await rowText('没打开过的那一问')).includes('被驳回')
|
||||
&& !(await rowText('没打开过的那一问')).includes('失败'), await rowText('没打开过的那一问'))
|
||||
|
||||
const startsBefore = log.runStarts.length
|
||||
await card.getByRole('button', { name: '重新运行本轮' }).click()
|
||||
check('重新运行:留下一次尝试', await page.waitForFunction(
|
||||
() => window.__chat.getState().byConversation.c0rejr.at(-1).attempts?.length === 1, null, { timeout: 8000 })
|
||||
.then(() => true, () => false))
|
||||
check('按原图重新发起了运行', log.runStarts.length > startsBefore, `${log.runStarts.length - startsBefore} 次`)
|
||||
const [attempt] = (await chatState(page)).byConversation.c0rejr.at(-1).attempts ?? []
|
||||
check('那一次记成被驳回、写谁在哪一步驳回', attempt?.outcome === 'rejected'
|
||||
&& attempt?.summary === '被驳回:张工 驳回了「查数」:数字对不上', JSON.stringify(attempt))
|
||||
const history = page.locator('[aria-label="之前的尝试"]')
|
||||
const historyText = (await history.innerText().catch(() => '')).replace(/\n/g, ' / ')
|
||||
check('「之前的尝试」写「被驳回」,不写「已手动停止」', historyText.includes('第 1 次尝试')
|
||||
&& historyText.includes('被驳回:张工 驳回了「查数」') && !historyText.includes('已手动停止'), historyText)
|
||||
check('没有运行时报错', errors.length === 0, errors[0] ?? '')
|
||||
await ctx.close()
|
||||
} finally {
|
||||
ctl.rejectConvs = false
|
||||
}
|
||||
})
|
||||
|
||||
await section('fixfirst', '要改的东西在工具库里:先去改参数定义,「接着跑」退成次要', async () => {
|
||||
ctl.fixConvs = true
|
||||
try {
|
||||
|
||||
Reference in New Issue
Block a user