diff --git a/backend/app/engine/review.py b/backend/app/engine/review.py index 7b4cc70b..c497c95e 100644 --- a/backend/app/engine/review.py +++ b/backend/app/engine/review.py @@ -128,6 +128,9 @@ WARN_CODES: dict[str, tuple[str, str]] = { # 核查 agent 自己触顶、按已有结果收尾:多半和节点自己的 *_limit 说的是同一次触顶,照样是缺口,记成它自己的 kind, # 不落成笼统的 warn——步数上限(max_steps)只有它说 "verify_limit": ("verify_limit", DEGRADED), + # 核查 agent 的输出到了上限被截断(推理模型的思考也算在额度里):没交成结果的由节点按无法复算、未能反查记缺口,交了的 + # 可能不完整。同 verify_limit,是缺口不是断裂,记成它自己的 kind:说得清是输出额度的问题,换个模型就好 + "verify_truncated": ("verify_truncated", DEGRADED), } # 这些 kind 重跑一次大概率能好:原因说得清,且不是「问题本身没问明白」。 diff --git a/backend/tests/test_verify_truncation.py b/backend/tests/test_verify_truncation.py index 26cf8d7e..09397ee3 100644 --- a/backend/tests/test_verify_truncation.py +++ b/backend/tests/test_verify_truncation.py @@ -27,6 +27,7 @@ from openai.types.chat import ChatCompletion from app.core.events import EventType from app.engine import judge as judging +from app.engine import review as rv from app.engine import verify_agent from app.engine.inquiry import _error_words from app.engine.nodes.challenge import _claim_of @@ -330,5 +331,15 @@ async def test_the_reason_reaches_dual_check_challenge_and_inquiry(monkeypatch, assert _error_words(out, timeout_s=60, daily_max_usd=1.0) == expected +def test_the_review_layer_knows_the_truncation_log(): + """跑完之后的复核层(engine/review.py)认得 verify_truncated:同 verify_limit,是缺口不是断裂,kind 是它自己, + 不落成笼统的 warn;不触发「重跑一次大概率能好」——同样的模型再跑一遍还是撞同一堵墙。""" + assert rv.WARN_CODES["verify_truncated"] == ("verify_truncated", rv.DEGRADED) + event = {"type": str(EventType.LOG), "node_id": "dc", + "data": {"level": "warn", "code": "verify_truncated", "message": f"{CUT_16K},核查未能完成。{ADVICE}"}} + [signal] = rv.scan([event], {"结论": "本周订单 120 单。"}) + assert (signal.kind, signal.severity) == ("verify_truncated", rv.DEGRADED) and not rv.should_retry([signal]) + + def test_the_reply_shapes_used_here_are_messages(): assert isinstance(cut_reply(), BaseMessage) and isinstance(anthropic_cut()["raw"], BaseMessage) diff --git a/frontend/src/lib/terms.ts b/frontend/src/lib/terms.ts index 8ea7b9fb..cdd1af77 100644 --- a/frontend/src/lib/terms.ts +++ b/frontend/src/lib/terms.ts @@ -1039,6 +1039,19 @@ export const AGENT_GUARD_TEXT = { unlimitedShort: '不限', } as const +/** + * 时间线上「核查模型的输出到了上限被截断」那一行(engine/verify_agent.py 的 verify_truncated 日志,双盲复算、反方审稿的 + * 核查 Agent 发)。推理模型的思考也算在输出额度里,额度用完时回复是空的或只写了一半:照实说是输出额度不够,不说成 + * 「没查到」「格式不对」,下一步指到换模型。delivered:截断发生在查询过程中、已按截断前查到的结果收尾(结果可能不完整); + * undelivered:这次核查没有给出结果,节点按无法复算、未能反查处理 + */ +export const VERIFY_TRUNCATED_TEXT = { + title: (maxTokens?: number) => `核查模型的输出达到上限${maxTokens ? `(${formatNumber(maxTokens)} token)` : ''}被截断`, + delivered: '已按截断前查到的结果收尾,结果可能不完整', + undelivered: '这次核查未能完成', + next: '通常是推理占满了输出额度。请在节点上指定不带推理的模型,或在「设置 → 偏好设置 → 核查」中更换核查模型', +} as const + /** * 上传表格:数据页「表格」标签的上传弹窗、导入回执和卡片。种类的说法和后端 data/tabular.py 的 * CONVERSION_KINDS、SHAPE_KINDS 对得上;后端以后多出来的种类,界面上只显示它给的那句话 diff --git a/frontend/src/run/decode.ts b/frontend/src/run/decode.ts index f8799259..8a3a92ce 100644 --- a/frontend/src/run/decode.ts +++ b/frontend/src/run/decode.ts @@ -3,7 +3,7 @@ import type { NodeState, RunPhase } from './trace' import { formatDuration, formatNumber } from '../lib/format' import { ABSENCE_CLAIM_TEXT, CATALOG_DRIFT_TEXT, CATALOG_PATCH_TEXT, COPILOT_CONTEXT_TEXT, JUDGE_TEXT, MERGE_TEXT, RUN_SQL_CHECK_TEXT, SCHEMA_PARTIAL_TEXT, SQL_CHECK_TEXT, TYPE_LABEL, - CATALOG_TABLE_FIELD_LABEL, CHALLENGE_TEXT, DUAL_CHECK_TEXT, INQUIRY_TEXT, NODE_TYPE_LABEL, + CATALOG_TABLE_FIELD_LABEL, CHALLENGE_TEXT, DUAL_CHECK_TEXT, INQUIRY_TEXT, NODE_TYPE_LABEL, VERIFY_TRUNCATED_TEXT, claimTally, evidenceTally, issuanceLabel, nodeTypeLabel, } from '../lib/terms' @@ -765,17 +765,19 @@ function gateTitle(tool: string, verdict: 'allow' | 'escalate' | 'team', reason: * 双盲复算、反方审稿的日志代码(期 3 前后端共用的约定)。这几条按 code 和结构化字段解读,不看原话;后端发成 info 的 * (一致、裁决、记入数据目录、审稿完成)也上时间线,不然这两种节点就是一个跑了几十秒、什么都没说的黑盒。 * 无法复算(dual_check_unchecked)、未能反查(challenge_unreviewed)、只查了部分数据源(challenge_sources_lost)是 - * 期 4 补上的:以前认不得,只能照原话显示 + * 期 4 补上的:以前认不得,只能照原话显示。核查模型的输出到了上限被截断(verify_truncated,两种节点共用的核查 Agent 发) + * 是期 5 验收前补上的 */ const VERIFY_LOG_CODES = new Set([ 'dual_check_skipped', 'dual_check_same_model', 'dual_check_agreed', 'dual_check_disagreed', 'dual_check_resolved', 'dual_check_catalog', 'dual_check_limit', 'dual_check_unchecked', 'challenge_skipped', 'challenge_same_model', 'challenge_done', 'challenge_refuted', 'challenge_limit', - 'challenge_unreviewed', 'challenge_sources_lost', + 'challenge_unreviewed', 'challenge_sources_lost', 'verify_truncated', ]) const DC = DUAL_CHECK_TEXT const CL = CHALLENGE_TEXT.log +const VT = VERIFY_TRUNCATED_TEXT /** * 出具缺口里哪几条是双盲复算、反方审稿报上来的:出具契约给上游节点报的缺口加了「节点类型「节点名」:」的前缀 @@ -1214,6 +1216,19 @@ function explainLog(code: string | undefined, message: string, d: Record 0 ? d.max_tokens : undefined + return { + title: VT.title(limit), + sub: d.delivered === true ? VT.delivered : d.delivered === false ? VT.undelivered : undefined, + next: VT.next, + fix: 'canvas', + fixField: 'model', + level: 'warn', + } + } default: return null } diff --git a/scripts/check-decode.mjs b/scripts/check-decode.mjs index 68f283cd..3804887b 100644 --- a/scripts/check-decode.mjs +++ b/scripts/check-decode.mjs @@ -2319,6 +2319,52 @@ await section('核查类日志与出具缺口(期 4):无法复算、未能 rows.map((r) => `${r?.title}|${r?.sub ?? ''}`).join(' ; ')) }) +await section('核查模型的输出到了上限被截断(verify_truncated):说清额度、交没交出结果,下一步指到换模型', async () => { + // 推理模型的思考也算在输出额度里:核查 Agent 的一次调用 finish_reason=length、没有工具调用也没有 JSON 时,以前双盲复算 + // 记成「盲算没有查到这个数」、反方审稿记成「核查模型没有按结构交结果」。现在核查 Agent 另发 verify_truncated(max_tokens、 + // delivered、purpose),时间线按结构化字段说人话,原话留在展开区。先核对后端源码还是这么发的 + const verifyPy = readFileSync(`${root}backend/app/engine/verify_agent.py`, 'utf8') + check('后端发截断日志的格式还是夹具里这一种', verifyPy.includes('code="verify_truncated"') && verifyPy.includes('max_tokens=limit') + && verifyPy.includes('delivered=delivered') && verifyPy.includes('purpose=purpose') + && verifyPy.includes('TRUNCATED_ERROR = "{cut},核查未能完成。{advice}"'), + '改了 verify_agent.py 的字段或说法就同步改 decode.ts 的 explainLog 和这里的夹具') + const E = (seq, type, node_id, data = {}) => ({ seq, type, node_id, ts: 1790000000 + seq, data }) + const ADVICE = '通常是推理占满了输出额度,可在「设置 → 偏好设置 → 核查」中换用不带推理的模型' + const FAILED = `核查模型的输出达到上限(16384 token)被截断,核查未能完成。${ADVICE}` + const SETTLED = `核查模型的输出达到上限(16384 token)被截断,已按截断前查到的结果收尾,结果可能不完整。${ADVICE}` + const all = flatten(mod.decodeRun([ + E(1, 'run.started', null, { total: 2 }), + E(2, 'node.started', 'dc', { node_type: 'dual_check', label: '复核销售额' }), + E(3, 'log', 'dc', { level: 'warn', code: 'verify_truncated', purpose: 'verify', max_tokens: 16384, delivered: false, message: FAILED }), + E(4, 'node.finished', 'dc', { duration_ms: 20 }), + E(5, 'node.started', 'ch', { node_type: 'challenge', label: '审月报' }), + E(6, 'log', 'ch', { level: 'warn', code: 'verify_truncated', purpose: 'verify_result', max_tokens: 4096, delivered: true, + message: SETTLED }), + // 缺字段、字段类型不对(别的服务端版本):只给不带数的标题,不瞎填 + E(7, 'log', 'ch', { level: 'warn', code: 'verify_truncated', max_tokens: '16384', delivered: 'no', message: FAILED }), + E(8, 'node.finished', 'ch', { duration_ms: 30 }), + E(9, 'run.finished', null, { status: 'succeeded' }), + ])) + const rows = all.filter((s) => s.code === 'verify_truncated') + const nodes = all.filter((s) => s.kind === 'node') + check('每条一行,挂在自己的节点下', rows.length === 3 && nodes.find((s) => s.nodeId === 'dc')?.children?.filter((x) => x.code === 'verify_truncated').length === 1 + && nodes.find((s) => s.nodeId === 'ch')?.children?.filter((x) => x.code === 'verify_truncated').length === 2, `${rows.length} 行`) + const [failed, settled, bare] = rows + check('没交出结果:标题说输出到了上限被截断、带额度,副标题说核查未能完成', failed?.title === '核查模型的输出达到上限(16,384 token)被截断' + && failed.sub === '这次核查未能完成', `${failed?.title} / ${failed?.sub}`) + check('交了结果:额度照实写(退回小额度之后是 4,096),副标题说按截断前查到的结果收尾、可能不完整', + settled?.title === '核查模型的输出达到上限(4,096 token)被截断' && settled.sub === '已按截断前查到的结果收尾,结果可能不完整', + `${settled?.title} / ${settled?.sub}`) + check('……下一步指到换不带推理的模型,入口落到节点的模型,提醒色', rows.every((s) => !!s.next?.includes('不带推理的模型') + && s.next.includes('设置 → 偏好设置 → 核查') && s.fix === 'canvas' && s.fixField === 'model' && s.level === 'warn'), + `${failed?.next} / ${failed?.fix} / ${failed?.fixField} / ${failed?.level}`) + check('……原话留在展开区', failed?.detail === FAILED && settled?.detail === SETTLED) + check('缺字段、类型不对:标题不带数,不写副标题,不出 undefined、NaN', bare?.title === '核查模型的输出达到上限被截断' && bare.sub === undefined + && !/undefined|NaN/.test(`${bare?.title}${bare?.next ?? ''}`), `${bare?.title} / ${bare?.sub}`) + check('标题、副标题里没有内部代码', !rows.some((s) => /verify_truncated|max_tokens|delivered/.test(`${s.title}${s.sub ?? ''}${s.next ?? ''}`))) + check('节点照常完成', nodes.every((s) => s.status === 'done')) +}) + await section('审批质询 approval.inquiry(期 4):一行「审批人质询」,标题带问题前 40 字,副标题说回答是否完成、查了几次', async () => { // 前后端共用的约定:运行停在审批上时服务端追加 approval.inquiry,node_id 为空;data 里有问题、划选的原文、回答正文、 // 回答文档、成没成、触顶原因、查询(queries 和 inquiry_artifacts 两份)。以前这类事件落进「未识别的事件」