From 536c9de1167ef5c150690bb0c21cdeca5c01b70a Mon Sep 17 00:00:00 2001 From: ThearchyHelios Date: Sun, 4 Oct 2026 12:28:07 +0800 Subject: [PATCH] =?UTF-8?q?=E8=AF=81=E6=8D=AE=E7=9B=AE=E5=BD=95=E7=9A=84?= =?UTF-8?q?=E7=9C=81=E7=95=A5=E8=AD=A6=E5=91=8A=E7=85=A7=E5=AE=9E=E8=AF=B4?= =?UTF-8?q?=E7=9C=81=E7=95=A5=E4=BA=86=E4=BB=80=E4=B9=88=EF=BC=9A=E6=94=BE?= =?UTF-8?q?=E4=B8=8D=E4=B8=8B=E4=B8=8D=E4=B8=80=E5=AE=9A=E7=9C=81=E7=95=A5?= =?UTF-8?q?=E4=BA=86=E6=9F=A5=E8=AF=A2=E3=80=82=E7=9C=9F=E5=AE=9E=E8=BF=90?= =?UTF-8?q?=E8=A1=8C=E9=87=8C=E6=9F=A5=E8=AF=A2=E5=85=A8=E9=83=A8=E5=88=97?= =?UTF-8?q?=E5=87=BA=E3=80=81=E5=8F=AA=E6=98=AF=E6=9C=89=E7=9A=84=E5=8F=AA?= =?UTF-8?q?=E5=88=97=E5=89=8D=E5=87=A0=E8=A1=8C=EF=BC=8C=E8=AD=A6=E5=91=8A?= =?UTF-8?q?=E5=8D=B4=E5=86=99=E3=80=8C0=20=E6=AC=A1=E5=8F=AA=E5=88=97?= =?UTF-8?q?=E7=BC=96=E5=8F=B7=E5=92=8C=E6=91=98=E8=A6=81=EF=BC=88=EF=BC=89?= =?UTF-8?q?=E3=80=82=E5=86=99=E4=BD=9C=E6=A8=A1=E5=9E=8B=E7=9C=8B=E4=B8=8D?= =?UTF-8?q?=E5=88=B0=E8=BF=99=E4=BA=9B=E6=9F=A5=E8=AF=A2=E7=9A=84=E6=98=8E?= =?UTF-8?q?=E7=BB=86=E3=80=8D=E3=80=82=E7=8E=B0=E5=9C=A8=E5=88=86=E4=B8=89?= =?UTF-8?q?=E7=A7=8D=E5=86=99=EF=BC=9A=E6=9C=89=E6=9F=A5=E8=AF=A2=E5=8F=AA?= =?UTF-8?q?=E5=88=97=E7=BC=96=E5=8F=B7=E5=92=8C=E6=91=98=E8=A6=81=E7=9A=84?= =?UTF-8?q?=E7=85=A7=E6=97=A7=EF=BC=9B=E6=9F=A5=E8=AF=A2=E9=83=BD=E5=88=97?= =?UTF-8?q?=E5=87=BA=E3=80=81=E6=9C=89=E7=9A=84=E5=8F=AA=E5=88=97=E5=89=8D?= =?UTF-8?q?=E5=87=A0=E8=A1=8C=E7=9A=84=E5=86=99=E3=80=8CN=20=E6=AC=A1?= =?UTF-8?q?=E6=9F=A5=E8=AF=A2=E9=83=BD=E5=88=97=E5=87=BA=E4=BA=86=E6=95=B0?= =?UTF-8?q?=E6=8D=AE=EF=BC=8C=E5=85=B6=E4=B8=AD=20M=20=E6=AC=A1=E5=8F=AA?= =?UTF-8?q?=E5=88=97=E5=89=8D=203=20=E8=A1=8C=E3=80=8D=E5=B9=B6=E6=8C=89?= =?UTF-8?q?=E6=9D=A5=E6=BA=90=E5=88=97=E6=AC=A1=E6=95=B0=EF=BC=9B=E5=8F=AA?= =?UTF-8?q?=E7=9C=81=E7=95=A5=E4=BA=86=E8=A1=A8=E5=92=8C=E5=AD=97=E6=AE=B5?= =?UTF-8?q?=E8=AF=B4=E6=98=8E=E7=9A=84=E7=85=A7=E5=AE=9E=E8=AF=B4=E3=80=82?= =?UTF-8?q?=E4=BA=8B=E4=BB=B6=E5=A4=9A=E5=B8=A6=20compact=E3=80=81compact?= =?UTF-8?q?=5Frows=EF=BC=8C=E5=90=84=E6=9D=A5=E6=BA=90=E5=B8=A6=20compact?= =?UTF-8?q?=EF=BC=9B=E6=97=B6=E9=97=B4=E7=BA=BF=E6=8C=89=E5=90=8C=E6=A0=B7?= =?UTF-8?q?=E4=B8=89=E7=A7=8D=E5=86=99=E6=A0=87=E9=A2=98=E3=80=82=E5=8F=A6?= =?UTF-8?q?=E5=A4=96=E6=8A=8A=E6=B5=8B=E8=AF=95=E5=A4=B9=E5=85=B7=E3=80=81?= =?UTF-8?q?=E6=B3=A8=E9=87=8A=E5=92=8C=20check-decode=20=E9=87=8C=E6=B2=BF?= =?UTF-8?q?=E7=94=A8=E7=9A=84=E9=82=A3=E6=AC=A1=E8=BF=90=E8=A1=8C=E7=9A=84?= =?UTF-8?q?=E7=9C=9F=E5=AE=9E=E6=9F=A5=E8=AF=A2=E6=AC=A1=E6=95=B0=E6=8D=A2?= =?UTF-8?q?=E6=88=90=E8=99=9A=E6=9E=84=E7=9A=84=E6=AC=A1=E6=95=B0=E6=88=96?= =?UTF-8?q?=E6=A6=82=E6=8B=AC=E8=AF=B4=E6=B3=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/app/engine/evidence.py | 7 ++++ backend/app/engine/nodes/report.py | 24 ++++++++---- backend/tests/test_report_catalog_budget.py | 42 +++++++++++++++++++++ frontend/src/run/decode.ts | 15 ++++++++ scripts/check-decode.mjs | 13 +++++++ 5 files changed, 94 insertions(+), 7 deletions(-) diff --git a/backend/app/engine/evidence.py b/backend/app/engine/evidence.py index e7fa5237..407ac600 100644 --- a/backend/app/engine/evidence.py +++ b/backend/app/engine/evidence.py @@ -3277,6 +3277,7 @@ class WriterCatalog: - trimmed:放不下、有省略;nodes 是各来源的查询数 [{node_id, title, total, full, compact, omitted}], full 列出前 20 行、compact 只列前几行、omitted 只列编号和摘要;omitted 是只列摘要的查询编号 - empty_sources:结构化结论为空的来源(Agent 的名字) + - compact_rows:只列前几行的查询列了几行 清单有省略、或有来源的结构化结论为空时,报告节点加写作规则(ABSENCE_RULE);有省略时组装文档另查缺失断言 """ @@ -3287,6 +3288,12 @@ class WriterCatalog: nodes: list[dict[str, Any]] = field(default_factory=list) omitted: list[str] = field(default_factory=list) empty_sources: list[str] = field(default_factory=list) + compact_rows: int = _PROMPT_COMPACT_ROWS + + @property + def compact(self) -> int: + """列出了数据、但只列了前 compact_rows 行的查询次数。""" + return sum(int(r.get("compact") or 0) for r in self.nodes) @property def guarded(self) -> bool: diff --git a/backend/app/engine/nodes/report.py b/backend/app/engine/nodes/report.py index 920930bc..37be9efa 100644 --- a/backend/app/engine/nodes/report.py +++ b/backend/app/engine/nodes/report.py @@ -189,12 +189,21 @@ async def model_window(session: Any, spec: ModelSpec, model_id: str) -> int | No def _trimmed_message(writer: WriterCatalog) -> str: - """写作目录有省略时的那条警告(给人看):各来源省略了几次,写作模型看不到哪些明细。""" - per = "、".join(f"{r['title']}省略 {r['omitted']} 次" for r in writer.nodes if r["omitted"]) - shown = writer.queries - len(writer.omitted) - return (f"证据目录超出篇幅(约 {writer.budget} 字,按写作模型的上下文窗口确定):{writer.queries} 次查询中 " - f"{shown} 次列出了数据,{len(writer.omitted)} 次只列编号和摘要({per})。写作模型看不到这些查询的明细," - "报告不得据此断言没有数据") + """写作目录有省略时的那条警告(给人看):省略的是什么、各来源省略了几次,写作模型看不到哪些明细。 + + 放不下不一定省略了查询:查询全都列出、只是有的只列前几行,或者只省略了表和字段的说明。以前一律按「省略查询」写, + 全部列出时成了「0 次只列编号和摘要()。写作模型看不到这些查询的明细」,说的不是实情。""" + head = f"证据目录超出篇幅(约 {writer.budget} 字,按写作模型的上下文窗口确定):" + if writer.omitted: + per = "、".join(f"{r['title']}省略 {r['omitted']} 次" for r in writer.nodes if r["omitted"]) + shown = writer.queries - len(writer.omitted) + return (f"{head}{writer.queries} 次查询中 {shown} 次列出了数据,{len(writer.omitted)} 次只列编号和摘要({per})。" + "写作模型看不到这些查询的明细,报告不得据此断言没有数据") + if writer.compact: + per = "、".join(f"{r['title']} {r['compact']} 次" for r in writer.nodes if r.get("compact")) + return (f"{head}{writer.queries} 次查询都列出了数据,其中 {writer.compact} 次只列前 {writer.compact_rows} 行" + f"({per})。写作模型看不到这些查询其余的行,报告不得据此断言没有数据") + return f"{head}{writer.queries} 次查询都列出了数据,表和字段、知识库检索的说明有省略" def report_entities(spec: GraphSpec, node: GraphNode) -> str: @@ -475,7 +484,8 @@ async def run_report(state: GraphState, ctx: NodeContext) -> dict[str, Any]: if writer.trimmed: ctx.emit(EventType.LOG, level="warn", code="report_catalog_trimmed", message=_trimmed_message(writer), budget=writer.budget, window=window, queries=writer.queries, omitted=len(writer.omitted), - nodes=[{k: r[k] for k in ("node_id", "title", "total", "omitted")} for r in writer.nodes]) + compact=writer.compact, compact_rows=writer.compact_rows, + nodes=[{k: r[k] for k in ("node_id", "title", "total", "omitted", "compact")} for r in writer.nodes]) messages = _messages(ctx, state, writer, claims) started = time.perf_counter() spent: list[dict[str, Any]] = [] diff --git a/backend/tests/test_report_catalog_budget.py b/backend/tests/test_report_catalog_budget.py index 3ea3da91..b02241a9 100644 --- a/backend/tests/test_report_catalog_budget.py +++ b/backend/tests/test_report_catalog_budget.py @@ -207,3 +207,45 @@ async def test_the_window_comes_from_the_provider_entry_then_the_catalog(): finally: await session.execute(delete(Provider).where(Provider.id == row.id)) await session.commit() + + +# ---------------------------------------------------------------- 警告说的是实情 + + +def _writer(nodes, omitted=()): + from app.engine.evidence import WriterCatalog + + return WriterCatalog(text="", budget=51200, queries=sum(r["total"] for r in nodes), trimmed=True, nodes=nodes, + omitted=list(omitted)) + + +def _row(title, total, *, full=0, compact=0, omitted=0): + return {"node_id": title, "title": title, "total": total, "full": full, "compact": compact, "omitted": omitted} + + +def test_the_warning_names_omitted_queries_when_there_are_some(): + from app.engine.nodes.report import _trimmed_message + + text = _trimmed_message(_writer([_row("会员专项", 26, full=26), _row("卡券核销", 79, full=20, omitted=59)], + omitted=[f"Q{i}" for i in range(1, 60)])) + assert "105 次查询中 46 次列出了数据,59 次只列编号和摘要(卡券核销省略 59 次)" in text + assert "看不到这些查询的明细" in text + + +def test_all_queries_listed_but_some_only_first_rows_is_not_called_omitted(): + """放不下但查询全都列出,只是有的只列前几行:不写「0 次只列编号和摘要()」,写清是行数有省略。""" + from app.engine.nodes.report import _trimmed_message + + writer = _writer([_row("会员专项", 26, full=26), _row("卡券核销", 79, full=30, compact=49)]) + text = _trimmed_message(writer) + assert writer.compact == 49 + assert f"105 次查询都列出了数据,其中 49 次只列前 {writer.compact_rows} 行(卡券核销 49 次)" in text + assert "看不到这些查询其余的行" in text + assert "只列编号和摘要" not in text and "()" not in text + + +def test_only_the_table_notes_were_cut(): + from app.engine.nodes.report import _trimmed_message + + text = _trimmed_message(_writer([_row("会员专项", 26, full=26)])) + assert "26 次查询都列出了数据,表和字段、知识库检索的说明有省略" in text and "()" not in text diff --git a/frontend/src/run/decode.ts b/frontend/src/run/decode.ts index 51a83b4c..051a01ed 100644 --- a/frontend/src/run/decode.ts +++ b/frontend/src/run/decode.ts @@ -773,9 +773,24 @@ function explainLog(code: string | undefined, message: string, d: Record typeof r?.omitted === 'number' && r.omitted > 0 && str(r?.title)) .map((r) => `${r.title} 省略 ${formatNumber(r.omitted)}/${formatNumber(r.total)} 次`) + const all = total != null ? `${formatNumber(total)} 次查询都列出了数据` : '查询都列出了数据' + if (omitted === 0) { + // 放不下但一次查询都没省略:有的只列了前几行,或者只省略了表和字段的说明。不写「0 次只列了编号和摘要」 + const rowsPer = rows.filter((r) => typeof r?.compact === 'number' && r.compact > 0 && str(r?.title)) + .map((r) => `${r.title} ${formatNumber(r.compact)}/${formatNumber(r.total)} 次只列前几行`) + return { + title: compact ? `证据目录超出篇幅,${all},${formatNumber(compact)} 次只列了前几行` + : compact === 0 ? `证据目录超出篇幅,${all},表和字段的说明有省略` + : `证据目录超出篇幅,${all},部分明细有省略`, + sub: rowsPer.length ? rowsPer.join('、') : undefined, + next: '写作模型看到了每次查询,但有的只看到前几行;其余的行仍在证据台账里,报告不得据此断言没有数据。' + + '需要完整明细时,请换用上下文窗口更大的写作模型,或让 Agent 在 SQL 中先汇总再交出结果', + } + } return { title: omitted != null ? `证据目录超出篇幅,${total != null ? `${formatNumber(total)} 次查询中 ` : ''}${formatNumber(omitted)} 次只列了编号和摘要` diff --git a/scripts/check-decode.mjs b/scripts/check-decode.mjs index f6fdaaaf..d6f1a459 100644 --- a/scripts/check-decode.mjs +++ b/scripts/check-decode.mjs @@ -1336,6 +1336,19 @@ await section('报告核对 report.checked、口径卡的台账(可点击证 { node_id: 'c', title: '活动内容', total: 42, omitted: 22 }] })])).find((s) => s.code === 'report_catalog_trimmed') check('report_catalog_trimmed:标题说几次查询只列了编号和摘要', trimmed?.title === '证据目录超出篇幅,164 次查询中 74 次只列了编号和摘要', trimmed?.title) check('……副标题按来源列省略了几次(没省略的来源不列)', trimmed?.sub === '卡券核销 省略 52/79 次、活动内容 省略 22/42 次', trimmed?.sub) + // 放不下但查询全都列出、只是有的只列前几行:不写「0 次只列了编号和摘要」 + const rowsOnly = flatten(mod.decodeRun([E(1, 'node.started', 'w', { node_type: 'report', label: '写报告' }), + E(2, 'log', 'w', { level: 'warn', code: 'report_catalog_trimmed', message: '证据目录超出篇幅', budget: 51200, window: null, + queries: 140, omitted: 0, compact: 38, compact_rows: 3, nodes: [{ node_id: 'a', title: '会员专项', total: 60, omitted: 0, compact: 0 }, + { node_id: 'b', title: '卡券核销', total: 80, omitted: 0, compact: 38 }] })])).find((s) => s.code === 'report_catalog_trimmed') + check('……查询全都列出、有的只列前几行:标题照实说,不写「0 次只列了编号和摘要」', + rowsOnly?.title === '证据目录超出篇幅,140 次查询都列出了数据,38 次只列了前几行' && !rowsOnly?.title?.includes('编号和摘要'), rowsOnly?.title) + check('……副标题按来源列只列了前几行的次数', rowsOnly?.sub === '卡券核销 38/80 次只列前几行', rowsOnly?.sub) + check('……下一步说其余的行仍在台账里', !!rowsOnly?.next?.includes('只看到前几行') && !!rowsOnly?.next?.includes('不得据此断言没有数据'), rowsOnly?.next) + const notesOnly = flatten(mod.decodeRun([E(1, 'node.started', 'w', { node_type: 'report', label: '写报告' }), + E(2, 'log', 'w', { level: 'warn', code: 'report_catalog_trimmed', message: '证据目录超出篇幅', queries: 12, omitted: 0, compact: 0, + nodes: [{ node_id: 'a', title: '会员专项', total: 12, omitted: 0, compact: 0 }] })])).find((s) => s.code === 'report_catalog_trimmed') + check('……只省略了表和字段的说明:照实说', notesOnly?.title === '证据目录超出篇幅,12 次查询都列出了数据,表和字段的说明有省略', notesOnly?.title) check('……下一步说被省略的仍在台账里、不得据此断言没有数据,以及怎么看全', !!trimmed?.next?.includes('不得据此断言没有数据') && !!trimmed?.next?.includes('上下文窗口更大') && trimmed?.level === 'warn' && trimmed?.detail === TRIMMED, trimmed?.next) const trimmedBare = flatten(mod.decodeRun([E(1, 'log', 'w', { level: 'warn', code: 'report_catalog_trimmed', message: '目录有省略' })]))