From b6cb5816dbac13d736ccaf415dae521925abb121 Mon Sep 17 00:00:00 2001 From: ThearchyHelios Date: Fri, 16 Oct 2026 12:46:36 +0800 Subject: [PATCH] =?UTF-8?q?=E9=97=AE=E6=95=B0=E6=8D=AE=EF=BC=9A=E8=A2=AB?= =?UTF-8?q?=E9=A9=B3=E5=9B=9E=E7=9A=84=E9=82=A3=E6=AC=A1=E5=B0=9D=E8=AF=95?= =?UTF-8?q?=E4=B8=8D=E5=86=99=E3=80=8C=E5=B7=B2=E6=89=8B=E5=8A=A8=E5=81=9C?= =?UTF-8?q?=E6=AD=A2=E3=80=8D=EF=BC=8C=E4=BC=9A=E8=AF=9D=E5=88=97=E8=A1=A8?= =?UTF-8?q?=E6=A0=87=E3=80=8C=E8=A2=AB=E9=A9=B3=E5=9B=9E=E3=80=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 为什么:一轮的运行被审批人驳回之后点「重新运行本轮」,「之前的尝试」那一行写「已手动停止」,用户其实没有停过它。会话 列表的 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 项不通过,改完通过。 --- backend/app/api/conversations.py | 30 ++++++++---- backend/tests/test_conversation_status.py | 37 +++++++++++++- frontend/src/pages/ChatPage.tsx | 2 +- frontend/src/pages/ConversationList.tsx | 8 ++- frontend/src/store/chat.ts | 8 ++- frontend/src/types.ts | 6 +-- scripts/check-chat.mjs | 59 ++++++++++++++++++++++- 7 files changed, 131 insertions(+), 19 deletions(-) diff --git a/backend/app/api/conversations.py b/backend/app/api/conversations.py index 75e73ebc..b54d7410 100644 --- a/backend/app/api/conversations.py +++ b/backend/app/api/conversations.py @@ -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 diff --git a/backend/tests/test_conversation_status.py b/backend/tests/test_conversation_status.py index 1a9fe345..1f2a9ce1 100644 --- a/backend/tests/test_conversation_status.py +++ b/backend/tests/test_conversation_status.py @@ -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="查询超时") diff --git a/frontend/src/pages/ChatPage.tsx b/frontend/src/pages/ChatPage.tsx index a93803b7..c985665f 100644 --- a/frontend/src/pages/ChatPage.tsx +++ b/frontend/src/pages/ChatPage.tsx @@ -802,7 +802,7 @@ function remedyOf(t: ChatTurn): Remedy | null { } const OUTCOME: Record = { - failed: 'failed', cancelled: 'cancelled', suspended: 'suspended', + failed: 'failed', cancelled: 'cancelled', rejected: 'rejected', suspended: 'suspended', unusable: 'failed', partial: 'waiting', done: 'succeeded', } diff --git a/frontend/src/pages/ConversationList.tsx b/frontend/src/pages/ConversationList.tsx index b26fab62..df648109 100644 --- a/frontend/src/pages/ConversationList.tsx +++ b/frontend/src/pages/ConversationList.tsx @@ -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, 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 diff --git a/frontend/src/store/chat.ts b/frontend/src/store/chat.ts index 7983e5bf..77314bdd 100644 --- a/frontend/src/store/chat.ts +++ b/frontend/src/store/chat.ts @@ -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) { diff --git a/frontend/src/types.ts b/frontend/src/types.ts index 1746e86f..f82843b0 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -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 } diff --git a/scripts/check-chat.mjs b/scripts/check-chat.mjs index 8e1f91f2..d8ece9d8 100644 --- a/scripts/check-chat.mjs +++ b/scripts/check-chat.mjs @@ -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 {