From fcdcc30d4ef958f79538993e613dcff347ebc1d7 Mon Sep 17 00:00:00 2001 From: ThearchyHelios Date: Mon, 28 Sep 2026 21:20:04 +0800 Subject: [PATCH] =?UTF-8?q?=E5=8F=AF=E7=82=B9=E5=87=BB=E8=AF=81=E6=8D=AE?= =?UTF-8?q?=E7=AC=AC=E4=BA=8C=E6=9C=9F=EF=BC=9A=E5=8F=96=E6=95=B0=E9=93=BE?= =?UTF-8?q?=E8=B7=AF=E6=89=93=E9=80=9A=E3=80=81=E9=97=AE=E6=95=B0=E6=8D=AE?= =?UTF-8?q?=E7=9A=84=E7=AD=94=E6=A1=88=E9=80=90=E6=AE=B5=E5=8F=AF=E7=82=B9?= =?UTF-8?q?=EF=BC=9B=E5=8F=91=E5=B8=83=E5=89=8D=E8=87=AA=E5=8A=A8=E4=BF=AE?= =?UTF-8?q?=E5=A4=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 一、可点击证据第二期 取数链路 - 智能体、工具节点、代码节点、知识检索取数时都记进证据台账;报告目录按台账编出 Q1… / K1… - 报告可以引用快照里的一格([[v:Q1.r0.gmv]]),也可以由系统从快照生成整张表([[table:Q1 cols=… rows=0-4]]), 表里每一格都是可点的片段;取值一律以快照为准,取回快照时复验哈希 - 口径卡表达式新增 cell(x, row, column),lineage 精确到单元格 - 智能体开 cite_fields(配合 output_schema)后,循环结束做一次结构化抽取,每个字段标出取自哪次查询的哪一格, 再到快照里核对:一致记 verified;不一致以快照为准并告警;查不到的记为空值,永远不兜底成 0 - 代码节点新增 evidence_role(source / compute)和 code_sha;沙箱算出来的数不能被报告直接引用,要进口径卡 - 查询快照记下列类型,文本列按文本渲染(年份 "2026" 不再显示成「2,026」) - 升级前发起的运行(停在审批上的)一律走旧逻辑:新的 task 只追加在循环之后,恢复时不会错号 证据接口与面板 - 片段链接上查询步骤:SQL、被引用的行加前后各 2 行、高亮到格;数据源可设 mask_columns 遮罩列, 遮罩在查询时记进快照,数据源事后改名或删除也照样遮罩(在有身份体系之前,遮罩只减少暴露,不是安全边界) - 输入来源写明核对结果(与快照一致 / 模型报的值和快照不一致 / 没查到记为空);口径卡缺输入时也给出完整的链 - 受管出具要在契约里声明 cells 才能引用单元格;受管与否记在所钉版本上,改画布不再让受管版本的正式运行降级 - 口径卡可以用 caliber_from 钉住另一个已发布工作流里的口径卡,上游有新版本时按 upgrade_policy 处置, 和子工作流同一套规则;子工作流节点也补上了升版处置的配置项 Copilot、校验与报错 - 问数据的答案里有数字时,搭 input → 智能体 → 报告 → 出口,报告直接引用查过的单元格 - 写 SQL 前核对数据源的表结构,SQL 里出现表结构里没有的列会被打回 - 不再搭「智能体文字 → 转换节点解析 JSON」这种图;转换节点的 JSON 报错分清是上游模型写坏了、 模板里裸插了模型文字(给出 | json 的改法),还是模板本身写错了 - 工具节点遇到「查询失败」「SQL 被拒绝」判为失败;发起运行前检查绑定的工具是否存在,不存在时直接拒绝并给出入口 二、发布前自动修复 - 问题带上机器编号(code)和修复 id;新增只读接口 POST /api/workflows/{id}/publish-check 和 /autofix - 修复分三类:答案唯一、不降低要求的一键修复;要人拿主意的给选项(不替人选);结构性问题交给 Copilot - 一律先预览,不自动保存、不自动发布;删节点、删契约、放宽审批、关掉 strict、改级别等降低要求的改动一律拒绝, Copilot 的方案也要过这一关;应用后重跑检查,错误必须变少且不能冒出新的 - 发布弹窗打开就先检查;问题面板新增「发布前检查」,平时就能看到、就地修掉 - 门禁修正:契约有 report_from 时不再要求 narrative(模板⑨可以发成受管);全图默认审批为「全部放行」时同样拦下 - 修掉画布保存会抹掉全图默认(defaults)的老问题:以前任何一次保存都会把它清空,审批 always 因此被悄悄放宽 验证(沙箱):tsc 干净,生产构建通过,pytest 2268 passed(两遍),check-all 17 项检查、3915 项全过 (新增 check-publish);关键行为做了变异验证、全部抓到。 --- backend/app/api/copilot.py | 389 +- backend/app/api/datasources.py | 21 +- backend/app/api/evidence.py | 251 +- backend/app/api/runs.py | 107 +- backend/app/api/workflows.py | 143 +- backend/app/data/engine.py | 89 +- backend/app/db/base.py | 2 + backend/app/db/models.py | 3 + backend/app/engine/autofix.py | 762 +++ backend/app/engine/evidence.py | 546 +- backend/app/engine/expressions.py | 168 +- backend/app/engine/governance.py | 136 +- backend/app/engine/nodes/io.py | 212 +- backend/app/engine/nodes/knowledge.py | 5 + backend/app/engine/nodes/llm.py | 464 +- backend/app/engine/nodes/metrics.py | 239 +- backend/app/engine/nodes/report.py | 62 +- backend/app/engine/nodes/tools.py | 46 +- backend/app/engine/review.py | 4 + backend/app/engine/schema.py | 281 +- backend/app/seed.py | 3 +- backend/app/tools/datasource.py | 8 +- backend/tests/test_agent_cited_fields.py | 560 ++ backend/tests/test_autofix_assist.py | 278 + backend/tests/test_autofix_graph_defaults.py | 251 + backend/tests/test_autofix_rules.py | 517 ++ backend/tests/test_caliber_from.py | 260 + backend/tests/test_code_evidence_role.py | 159 + backend/tests/test_contract_cells.py | 328 + backend/tests/test_copilot_evidence_guide.py | 280 +- backend/tests/test_evidence_markers.py | 2 +- backend/tests/test_evidence_query_chain.py | 442 ++ backend/tests/test_evidence_validate.py | 102 + .../tests/test_lint_contract_report_from.py | 103 + backend/tests/test_publish_check_api.py | 257 + backend/tests/test_query_column_types.py | 49 + backend/tests/test_report_cells.py | 415 ++ backend/tests/test_review.py | 11 + backend/tests/test_run_tool_precheck.py | 139 + backend/tests/test_segment_missing_input.py | 143 + backend/tests/test_text_columns.py | 190 + backend/tests/test_tool_evidence.py | 488 ++ backend/tests/test_transform_json_error.py | 271 + backend/tests/test_version_level.py | 158 + frontend/src/api/client.ts | 19 +- frontend/src/canvas/Inspector.tsx | 207 +- frontend/src/canvas/NodeCard.tsx | 10 +- frontend/src/canvas/ProblemsPanel.tsx | 54 +- frontend/src/canvas/PublishDialog.tsx | 707 ++- frontend/src/canvas/issues.ts | 229 +- frontend/src/canvas/nodeDefs.ts | 62 +- frontend/src/dev/ui-harness.tsx | 17 + frontend/src/lib/evidence.ts | 209 +- frontend/src/lib/explain.ts | 98 + frontend/src/lib/terms.ts | 128 + frontend/src/pages/DataSourcesTab.tsx | 213 +- frontend/src/pages/runs/RunDetailView.tsx | 15 +- frontend/src/run/AssistantStream.tsx | 115 +- frontend/src/run/EvidenceDoc.tsx | 54 +- frontend/src/run/EvidencePanel.tsx | 330 +- frontend/src/run/RunControl.tsx | 26 +- .../src/run/__tests__/evidence-query.json | 5614 +++++++++++++++++ frontend/src/run/decode.ts | 43 +- frontend/src/store/chat.ts | 25 +- frontend/src/store/studio.ts | 50 + frontend/src/types.ts | 157 +- scripts/check-all.mjs | 5 +- scripts/check-chat.mjs | 252 +- scripts/check-decode.mjs | 81 + scripts/check-evidence.mjs | 641 +- scripts/check-manage.mjs | 104 + scripts/check-publish.mjs | 825 +++ scripts/check-runs.mjs | 22 + scripts/check-studio.mjs | 96 + 74 files changed, 19372 insertions(+), 380 deletions(-) create mode 100644 backend/app/engine/autofix.py create mode 100644 backend/tests/test_agent_cited_fields.py create mode 100644 backend/tests/test_autofix_assist.py create mode 100644 backend/tests/test_autofix_graph_defaults.py create mode 100644 backend/tests/test_autofix_rules.py create mode 100644 backend/tests/test_caliber_from.py create mode 100644 backend/tests/test_code_evidence_role.py create mode 100644 backend/tests/test_contract_cells.py create mode 100644 backend/tests/test_evidence_query_chain.py create mode 100644 backend/tests/test_evidence_validate.py create mode 100644 backend/tests/test_lint_contract_report_from.py create mode 100644 backend/tests/test_publish_check_api.py create mode 100644 backend/tests/test_query_column_types.py create mode 100644 backend/tests/test_report_cells.py create mode 100644 backend/tests/test_run_tool_precheck.py create mode 100644 backend/tests/test_segment_missing_input.py create mode 100644 backend/tests/test_text_columns.py create mode 100644 backend/tests/test_tool_evidence.py create mode 100644 backend/tests/test_transform_json_error.py create mode 100644 backend/tests/test_version_level.py create mode 100644 frontend/src/run/__tests__/evidence-query.json create mode 100644 scripts/check-publish.mjs diff --git a/backend/app/api/copilot.py b/backend/app/api/copilot.py index 003dacef..ed79a3d9 100644 --- a/backend/app/api/copilot.py +++ b/backend/app/api/copilot.py @@ -18,7 +18,7 @@ from app.core.errors import explain as explain_error, graph_error, not_configure from app.db.base import get_session from app.db.models import Workflow from app.engine.layout import CORRIDOR_MIN, MIN_ROW_GAP, NODE_W, _height, auto_layout -from app.engine.schema import GraphSpec, NodeType, ValidationIssue, validate_graph +from app.engine.schema import GraphSpec, NodeType, ValidationIssue, text_parse_issues, validate_graph from app.engine.state import message_text from app.providers.factory import ModelSpec, ProviderNotConfigured, get_chat_model from app.tools.registry import all_specs @@ -130,7 +130,11 @@ def _datasource_section(rows: list[Any]) -> str: "- 认准方言:Oracle 用 FETCH FIRST n ROWS ONLY,不是 LIMIT\n" "- 一次只写一条语句;分号拼接会被拒\n" "- 字段拿不准就在图里先放一个 db_schema 工具节点,别猜字段名\n" - "- 取数结果要进口径卡(metrics 节点)才能被报告撰写节点(report)引用,别让 llm 节点直接对数字做算术\n" + # 真实踩过:列名是照着业务说法猜的,跑到查库那一步才「no such column」 + "- 列名只照上面结构里列出来的写;结构里只列了表名时先用 db_schema 查字段。搭完自查会拿 SQL 里的列名" + "去对结构,对不上会打回来让你改\n" + "- 比率、增幅、占比这类派生计算进口径卡(metrics 节点);查询结果里原样的数,报告撰写节点(report)" + "可以直接用 [[v:Q1.r0.列名]] 引用。别让 llm 节点直接对数字做算术\n" ) @@ -139,7 +143,12 @@ NODE_REFERENCE = """\ - input:入口。config.fields = [{name, required, default, description}] - output:出口,收集最终成果。config.fields = [{name, value}],value 里写模板引用 - llm:单次模型调用。config: {system, prompt, model, temperature, max_tokens, assign_to, output_schema} -- agent:带工具循环的 agent。config: {system, prompt, tools:[工具名], approval, assign_to} +- agent:带工具循环的 agent。config: {system, prompt, tools:[工具名], approval, assign_to, output_schema, cite_fields} + **agent 要把查到的数交给口径卡或报告时**,config 加 output_schema(JSON Schema,写要交的字段和类型)和 + cite_fields: true:循环结束后系统按 Schema 抽一次字段,每个字段都核对到它查过的那一格——以查询快照为准, + 查不到的记空值、不兜底成 0。assign_to 拿到的就是核对过的对象,口径卡直接写 vars..字段。 + 只写 output_schema 不开 cite_fields 不生效。**不要用 transform 解析 agent / llm 的文字**:模型写的 JSON + 常夹着没转义的引号,解析一失败整个节点就失败,也核对不了出处 **默认不要写 max_steps、budget_tokens、budget_usd**。平台用的是护栏而不是固定步数: 步数只是很高的兜底(跟随设置,默认 100),重复调用、连续几步没有新信息、预算用完、 上下文快满时,它会按查到的部分收尾。写死一个小数字(见过 8)会让它查完表结构就没额度回答了。 @@ -154,6 +163,8 @@ NODE_REFERENCE = """\ 成员停不下来等人:需要审批的 MCP / 自定义工具在成员手里不会执行。要用这类工具,交给团队外的 agent 节点 - tool:直接调一个工具。config: {tool: 工具名, args: {...}, assign_to} - code:沙箱里跑代码。config: {language: python|bash|node, code, timeout, network, assign_to} + evidence_role:source(取数:产出本身就是源数据)/ compute(计算,默认)。负责取数的代码写 source; + compute 的产出喂给口径卡会被提示「核对不了出处」——业务计算写进口径卡的表达式。 assign_to 拿到的是 stdout(尾部的换行已去掉)。**stdout 是 JSON 时会解析成对象**, 这时下游要用 {{ vars.x.字段 }} 取字段,拿它比字符串永远不成立。要判一个简单结论, 就 print 一个短字符串(比如 print('ok'))并让下游比它。 @@ -172,26 +183,33 @@ NODE_REFERENCE = """\ - subgraph:嵌套另一个工作流。config: {workflow_id, input} - metrics:口径卡。报告里要出现的数字、以及所有派生计算(比率、增幅、占比、差值)都登记在这里。 config: {caliber, caliber_version, metrics:[{id, name, unit, decimals, format, expression}], on_missing, assign_to} - expression 是受限表达式(不是模板):vars.q.rows[0][0]、round((vars.q.rows[0][0] - vars.q.rows[0][1]) / vars.q.rows[0][1] * 100, 1) + expression 是受限表达式(不是模板):vars.kpi.gmv、cell(nodes.fetch, 0, 'gmv')、 + round((vars.kpi.gmv - vars.kpi.gmv_prev) / vars.kpi.gmv_prev * 100, 1) format:thousands(默认,千分位)/ plain(不分组,年份编号这类)/ percent_of_ratio(值是 0.0235 这样的比率,显示成 2.35%); decimals 写 -15 到 15 的整数;percent_of_ratio 的 decimals 按比率算:要显示两位百分比(2.35%)写 4, 写 2 只剩「2%」,0.004 这样的小比率还会显示不出来、报告引用不了。on_missing 默认 fail(缺输入整张卡失败);写 null 则缺输入的指标记为空值,交给出具契约判档 - db_query__* 返回的是 JSON 文本 {columns, rows},口径卡取不到里面的字段:先接一个 - transform(mode=json,template="{{ nodes.取数节点id }}",assign_to="q"),口径卡再写 vars.q.rows[0][0] + 取 tool 节点查库的结果用 cell(nodes.取数节点id, 行, '列名')(行号从 0 数,列按列名):cell() 直接认查询工具 + 交回的结果,出处精确到快照里的那一格,不用再接 transform 解析。取 agent 的字段写 vars..字段 - report:报告撰写(带引用)。config: {instructions, system, metrics_from:[口径卡id], on_violation, max_repairs, assign_to} - **凡是给人看的、带数字的报告、分析、结论,都用 report,不要用 llm**。它自动收集上游口径卡的指标和运行输入, - 模型只能写 [[m:指标id]]、[[i:输入字段]] 这样的引用标记,数值由系统换成口径卡里的真实值; - 模型自己写的数字会被打回重写。它只能引用口径卡指标和运行输入,所以报告里要出现的每个数都得先在口径卡里登记。 + **凡是给人看的、带数字的报告、分析、结论,都用 report,不要用 llm**。它自动收集上游的口径卡指标、查询结果 + (tool 节点和 agent 查过的库,按查询先后编成 Q1、Q2…)和运行输入,模型只能写引用标记,数值由系统从证据里 + 取出来渲染;模型自己写的数字会被打回重写: + - [[m:指标id]]:口径卡指标。比率、增幅、占比、差值这类派生计算只能先在口径卡里登记再引用 + - [[v:Q1.r0.列名]]:第 1 次查询结果里第 0 行那一列的原值(行号从 0 数) + - [[table:Q1 cols=列a,列b rows=0-4]]:把查询结果的几行几列原样生成表格,每一格都点得开出处 + - [[i:输入字段]]:运行输入 metrics_from 不写就取所有上游口径卡;on_violation 不写时探索运行是 flag(标出来、照常产出),正式运行是 fail - output 的出具契约:config.contract = {report_from: 报告节点id, metrics_from:[口径卡id], required:[必需指标id], expected:[期望指标id], strict: true}。成果字段写 {{ nodes.报告节点id.text }}, - 前后不要拼别的字——拼了就没法逐段对应证据,出具会降档 + 前后不要拼别的字——拼了就没法逐段对应证据,出具会降档。受管模板的报告要直接引用查询单元格 + ([[v:]] / [[table:]])的,契约里写 cells: true,否则这些引用按解析不了算 搭图规则(有数字结论时必须遵守): -1. 取数(tool 节点查库,接 transform 解析)→ 口径卡 → report → output(配 report_from 契约)。 - agent 的回答是自由文本,进不了口径卡:要出可追溯的报告,取数用 tool 节点 +1. 取数(tool 节点查库;或者 agent 查库并配 output_schema + cite_fields)→ 口径卡 → report → output + (要三档出具时配 report_from 契约)。没有比率、增幅这类派生计算时可以不要口径卡,report 直接引用查询单元格 2. 能用 SUM / COUNT 做的聚合放在 SQL 里,口径卡只做标量运算 -3. 不要用 code 节点做业务计算;code 只做格式转换 +3. 不要用 code 节点做业务计算;code 只做格式转换(负责取数的 code 标 evidence_role: source) +4. 不要用 transform 解析 agent / llm 的文字:口径卡读 cell(nodes.查库节点id, 行, '列名') 或 vars..字段 模型字段(llm / agent / supervisor / report 的 config.model): - **不要填**。留空表示跟随当前供应商的默认模型,这几乎总是对的。 @@ -279,10 +297,16 @@ def _user_message(payload: GenerateIn, *, patch: bool) -> str: "- 涉及数据的问题必须用 db_query__* / db_schema__* 去真查," "不要只对问题本身做文字加工(改写、摘要、分类都不是回答)\n" "- 出口节点给出的应当是这个问题的答案本身\n" - # 报告撰写节点只能引用口径卡里登记过的数。agent 查到的结论还进不了口径卡, - # 普通问数也套上 report 的话,答案里的数会全部被判成没有出处 - "- 用户要的是报告、周报,或者要能核对每个数的出处时,按搭图规则走 tool 取数 → 口径卡 → " - "report → output(配 report_from 契约);普通的问数照旧用 agent,不必加 report\n" + # 二期起报告能直接引用 agent 查过的单元格:答案里的每个数都点得开出处,不必为问数硬塞一张口径卡。 + # 只问表结构、清单的没有数要核对,多一次写作调用只是多等十几秒 + "- 答案里有数字时,搭 input → agent → report → output:agent 绑 db_query__ 去查,report 的 instructions 写" + "「回答:{{ input.question }}」(可以附上 {{ nodes.agent的id.text }} 当分析思路),数用 [[v:Q1.r0.列名]] " + "直接引用 agent 查过的单元格,不必经过口径卡;要比率、增幅再在 agent 和 report 之间加口径卡,agent 配 " + "output_schema + cite_fields,口径卡读 vars..字段。出口字段写 {{ nodes.report的id.text }}\n" + "- 只问表结构、清单(有哪些表、有哪些字段、列出名单)的,不加 report,agent → output 就行\n" + "- 用户要的是周报、要三档出具时,按搭图规则配口径卡和 report_from 契约\n" + "- 不要用 transform 解析 agent / llm 的文字:要把 agent 查到的数交给下游,就给它配 output_schema + " + "cite_fields\n" # 只把历史放进 prompt 是不够的:模型拿到上一轮的图,默认仍然会 # 重新设计一张"更完整"的。而用户说"再按月份拆一下"时要的是上一张 # 图改个 SQL——重建出来的那张经常接到另一张表上,答案对不上前一轮。 @@ -943,12 +967,20 @@ def _sse(obj: dict[str, Any]) -> str: def _blocking_issues( nodes: dict[str, dict[str, Any]], edges: list[dict[str, Any]], scope: set[str] | None = None, + *, sources: list[Any] | None = None, baseline: list[dict[str, Any]] | None = None, + level: str | None = None, ) -> list[dict[str, Any]] | None: """按运行时同一套校验,挑出会挡住运行的问题。图本身不成形时返回 None。 限定了数据源时,用了范围外的库也算:用户点了「只查这个库」,拿别的库的数 回答他,比跑不起来更糟——答案看起来是对的。 + 另有两类运行时不挡、但助手搭图时要打回的写法(authored_issues):SQL 里的列在数据源 + 的结构里查不到、整形节点按 JSON 解析 agent / llm 写的文字。 + + level(published / governed)给了的话,那一档发布门禁的 error 也算进来:助手改完的图 + 能跑还不够,要能发布。不给就和以前一样只管能不能跑。 + 每条是 ValidationIssue 的字典形状(node_id、field 都在),和 final.issues 一样: 界面靠 node_id 定位卡片、靠 field 落到具体的输入框,不用从一行字里抠节点 id。 """ @@ -956,8 +988,178 @@ def _blocking_issues( spec = GraphSpec.model_validate({"nodes": list(nodes.values()), "edges": edges}) except Exception: # noqa: BLE001 - 结构不合法留给收尾那一步报 return None - return ([i.model_dump() for i in validate_graph(spec).issues if i.level == "error"] - + _issue_dicts(scope_issues(list(nodes.values()), scope), "datasource_out_of_scope")) + out = ([i.model_dump() for i in validate_graph(spec).issues if i.level == "error"] + + _issue_dicts(scope_issues(list(nodes.values()), scope), "datasource_out_of_scope") + + authored_issues(spec, sources or [], baseline)) + if level: + from app.engine.governance import lint_for_publish + + out += [i.model_dump() for i in lint_for_publish(spec, level=level).issues if i.level == "error"] + return out + + +def _changed(spec: GraphSpec, baseline: list[dict[str, Any]] | None) -> set[str]: + """这一轮新加或改过配置的节点。改图时只查这些:用户自己写的、这一轮没碰的 SQL 不替他改。""" + before = {n.get("id"): (n.get("data") or {}).get("config") for n in baseline or [] if isinstance(n, dict)} + return {n.id for n in spec.nodes if n.id not in before or before[n.id] != n.config} + + +def authored_issues(spec: GraphSpec, sources: list[Any], baseline: list[dict[str, Any]] | None = None, + ) -> list[dict[str, Any]]: + """助手搭图时要打回、运行时却不挡的写法,按 error 交回模型改: + + - sql_unknown_column:调用工具节点的 SQL 里有数据源结构(schema_cache)里查不到的列——凭空 + 猜的列名跑起来只会「no such column」,还白跑前面的步骤 + - parse_model_text:整形节点按 JSON 解析 agent / llm 写的文字(validate 里只是 warning:旧图照跑) + """ + changed = _changed(spec, baseline) + out: list[dict[str, Any]] = [] + for issue in text_parse_issues(spec): + if issue.node_id in changed: + # 模型的文字放在模板引号里的那种,改法是 | json,不是换结构化输出 + tail = "。不要用整形节点解析 agent / llm 的文字" if "cite_fields" in issue.message else "" + out.append({**issue.model_dump(), "level": "error", "code": "parse_model_text", + "message": issue.message + tail}) + by_name = {getattr(r, "name", None): r for r in sources} + for node in spec.nodes: + if node.id not in changed or node.type != NodeType.TOOL: + continue + source = by_name.get(_source_of(str(node.config.get("tool") or "")) or "") + args = node.config.get("args") + sql = args.get("sql") if isinstance(args, dict) else None + if source is None or not str(node.config.get("tool") or "").startswith("db_query__") \ + or not isinstance(sql, str): + continue + found = sql_unknown_columns(sql, source) + if not found: + continue + unknown, known = found + listed = "、".join(known[:20]) + (f" 等 {len(known)} 列" if len(known) > 20 else "") + out.append({"level": "error", "node_id": node.id, "edge_id": None, "field": "args.sql", + "code": "sql_unknown_column", + "message": f"SQL 里的 {'、'.join(unknown[:5])} 在数据源「{source.name}」的结构里查不到(这几张表的列:" + f"{listed})。照结构里的列名改;拿不准就先用 db_schema__{source.name} 查表结构," + "别凭空猜列名"}) + return out + + +# -------------------------------------------------------------------------- +# SQL 里的列名对照数据源结构 +# +# 只做有把握的判断,宁可漏判不可误判:这条会把图打回去让模型改。CTE、FROM 里的子查询、 +# 引号括起来的标识符、结构里找不到的表、结构没探查过或者探查被截断的,一律不判。 +# -------------------------------------------------------------------------- + +#: @ 开头的是变量(@region、@@session),: 开头的是参数,. 后面的是限定名 +_SQL_IDENT = re.compile(r"(? tuple[list[str], list[str]] | None: + """SQL 里用到、数据源结构里查不到的列:(查不到的列, 这几张表的全部列)。判断不了、或者都查得到时返回 None。""" + from app.data import introspect as _introspect + from app.data.guard import _blank_quoted, first_verb, strip_comments + + cache = getattr(source, "schema_cache", None) or {} + if not cache.get("tables") or cache.get("failed") or first_verb(sql) != "select": + return None + text = re.sub(r"\{\{.*?\}\}", " 0 ", strip_comments(sql), flags=re.S) + if '"' in text.replace("''", "") and _blank_quoted(text) != _blank_quoted(text, identifiers=False): + return None # 引号括起来的标识符:大小写、空格都可能,不猜 + text = _blank_quoted(text) + if re.search(r"\bwith\b", text, re.I) or re.search(r"\b(?:from|join)\s*\(", text, re.I) or "`" in text \ + or "[" in text: + return None # [方括号] 标识符、数组下标:同样不猜 + tables: dict[str, dict[str, Any]] = {} # 表名、别名(小写)→ 表结构 + spans: list[tuple[int, int]] = [] + for m in _FROM_ITEMS.finditer(text): + spans.append(m.span(1)) + for item in m.group(1).split(","): + parsed = _TABLE_ITEM.match(item) + if not parsed: + return None + name, alias = parsed.group(1), parsed.group(2) + meta = _introspect.find_table(source, name) + if meta is None: + return None # 结构里找不到的表(视图、同义词、截断了的结构)不判 + tables[name.lower()] = tables[name.rsplit(".", 1)[-1].lower()] = meta + if alias and alias.lower() not in _SQL_WORDS: + tables[alias.lower()] = meta + if not tables: + return None + known = list(dict.fromkeys(c["name"] for meta in {id(m): m for m in tables.values()}.values() + for c in meta.get("columns") or [])) + columns = {c.lower() for c in known} + # 「SELECT created_at FROM」里 created_at 前面是关键字,它是列,不是别名 + aliases = {a.lower() for a in _ALIAS_AS.findall(text)} | { + m.group(2).lower() for m in _ALIAS_BARE.finditer(text) if m.group(1).lower() not in _OPERAND_BEFORE} | { + a.lower() for a in _WINDOW_NAME.findall(text)} + names = {m.start(1) for pattern in (_NAME_AFTER, _DATEPART_ARG) for m in pattern.finditer(text)} + unknown: list[str] = [] + for m in _QUALIFIED.finditer(text): + if any(a <= m.start() < b for a, b in spans): + continue + meta = tables.get(m.group(1).lower()) + col = m.group(2) + if meta is not None and col != "*" and col.lower() not in {c["name"].lower() for c in meta.get("columns") or []}: + unknown.append(f"{m.group(1)}.{col}") + for m in _SQL_IDENT.finditer(text): + word = m.group(1) + low = word.lower() + if any(a <= m.start() < b for a, b in spans) or low in _SQL_WORDS or low in columns or low in aliases \ + or low in tables or m.start() in names or re.match(r"\s*[.(]", text[m.end():]) \ + or text[m.end():m.end() + 1] == "'": # N'…'、X'…'、_utf8mb4'…':字面量的前缀 + continue + unknown.append(word) + unknown = list(dict.fromkeys(unknown)) + return (unknown, known) if unknown else None def _source_of(tool: str) -> str | None: @@ -1322,7 +1524,7 @@ async def generate_stream(payload: GenerateIn, session: AsyncSession = Depends(g # 循环 / 分支条件里套了 {{ }}),前面的步骤白跑,报错还停在半路 repaired = 0 for round_no in range(1, _SELF_CHECK_ROUNDS + 1): - errors = _blocking_issues(nodes, edges, scope) + errors = _blocking_issues(nodes, edges, scope, sources=sources, baseline=baseline) if not errors: break yield _sse({"op": "check", "status": "repairing", "round": round_no, "issues": errors}) @@ -1340,7 +1542,7 @@ async def generate_stream(payload: GenerateIn, session: AsyncSession = Depends(g "message": f"自查修正没跑成:{explain_error(e)[0]}", "detail": raw_error(e)}) break repaired = round_no - remaining = _blocking_issues(nodes, edges, scope) + remaining = _blocking_issues(nodes, edges, scope, sources=sources, baseline=baseline) # 工具绑定变化不挡运行,但要在自查这一步就说出来:工具被改没了的图照样 # 能跑,只是跑出来的是模型「假设」查过库的答案 changes = tool_changes(baseline, list(nodes.values())) @@ -1363,6 +1565,9 @@ async def generate_stream(payload: GenerateIn, session: AsyncSession = Depends(g issues = [i.model_dump() for i in validate_graph(spec).issues] issues += _issue_dicts(scope_issues(list(nodes.values()), scope), "datasource_out_of_scope") + # 自查没改好的猜列名留在画布上:运行时不挡(结构缓存可能旧了),但要看得见 + issues += [{**i, "level": "warning"} for i in authored_issues(spec, sources, baseline) + if i["code"] == "sql_unknown_column"] # 跳过的节点要说出来,不能安静地少一步。code 给前端认:这一类要单独 # 提示「少了一步」,不能和普通校验警告混在一起 issues += [ @@ -1393,6 +1598,148 @@ async def generate_stream(payload: GenerateIn, session: AsyncSession = Depends(g ) +# -------------------------------------------------------------------------- +# 发布前自动修复的兜底:确定性修复修不了的 error 交给 Copilot,产出同样只是预览 +# -------------------------------------------------------------------------- + +_LEVEL_WORD = {"published": "已发布", "governed": "受管"} + +_ASSIST_RULES = """\ +硬性约定(违反任何一条,这次修改整个作废): +- 不许降低要求:不删节点、不删连线,add_node 不许用图里已有的节点 id(那等于把旧节点删了重建),也不换节点类型; + 不删掉或清空出具契约,不删 required 里的指标,不把 strict 改成 false,不往 allow_numbers 里加数,不替人打开 cells; + 审批策略只能往严里改(每次调用都审批 > 仅危险工具需要审批 > 全部自动放行),不放宽、不删掉,更不改成 never; + 不取消子工作流钉住的版本;发布级别不是你能改的。结构要动(比如换掉协作团队),输出 question 说明怎么拆,不要自己动手 +- 要人拿主意的,不替人选、不编:required 该包括哪些指标、几张口径卡该用哪张、钉哪个版本、协作团队拆成哪几个 + 固定步骤……每一处输出一行 {"op":"question","node_id":"节点 id","text":"要问的话,把候选列出来"},那一处不改 +- 只修下面列出的问题,别的不要动 +- 可用操作只有 update_node(config 只写要改的字段,没写的原样保留)、add_node、add_edge 和 question; + 不要输出 remove_node、remove_edge、reply +- 第一行 plan,最后一行 done,done 的 explanation 用一两句话说清改了什么、为什么""" + + +def _publish_fix_request(graph: dict[str, Any], errors: list[dict[str, Any]], level: str) -> str: + return ( + "这是当前的工作流:\n" + f"{json.dumps(_slim(graph), ensure_ascii=False, indent=2)}\n\n" + f"它要发布成「{_LEVEL_WORD.get(level, level)}」级别,发布前检查(和真正发布同一套规则)还拦着下面这些问题:\n" + + "\n".join(f"- {_issue_line(i)}" for i in errors) + + f"\n\n{_ASSIST_RULES}" + ) + + +async def assist_publish_fix( + session: AsyncSession, graph: dict[str, Any], *, level: str, + provider: str | None = None, model: str | None = None, +) -> dict[str, Any]: + """把挡住发布的 error 交给 Copilot 修。模型、工具目录、操作流协议都和画布上改图是同一套。 + + 拿回的操作在副本上应用,再按同一套规则复核(_blocking_issues 带上发布级别:validate、门禁, + 以及这一轮改过的节点里有没有凭空猜的列名):error 变少、没有新 error、没有降低要求的改动 + (autofix.forbidden_changes)才采纳,否则整个作废并写明原因。模型认为要人拿主意的,放进 + questions 原样交给人,不硬改。 + + 返回 {accepted, reason, summary, questions(一句一条的文字), graph, ops}。不落库。 + """ + from app.engine.autofix import forbidden_changes, judge + + sources = await _sources(session, None) + base = copy.deepcopy(graph) + before_nodes = {str(n["id"]): n for n in copy.deepcopy(base.get("nodes") or []) if isinstance(n, dict)} + before_edges = [e for e in copy.deepcopy(base.get("edges") or []) if isinstance(e, dict)] + baseline = list(copy.deepcopy(before_nodes).values()) + errors = _blocking_issues(before_nodes, before_edges, sources=sources, baseline=baseline, level=level) or [] + out: dict[str, Any] = {"accepted": False, "reason": None, "summary": "", "questions": [], "graph": graph, + "ops": []} + if not errors: + out["summary"] = "没有挡住发布的错误,不用交给 Copilot" + return out + try: + spec_ = await copilot_model_spec( + session, GenerateIn(instruction="发布前自动修复", provider=provider, model=model)) + chat, _ = await get_chat_model(session, spec_) + except ProviderNotConfigured as e: + out["summary"] = _unconfigured(e) + return out + + system = ( + "你是一个 agent 工作流编排专家。现在要做的是:让一张工作流过发布前检查,以操作流的方式输出修改。\n\n" + f"{NODE_REFERENCE}\n\n可用工具:\n{_tool_catalog(sources)}{_datasource_section(sources)}\n\n" + f"{_STREAM_PROTOCOL}\n\n另外可以输出 question 操作:{{\"op\":\"question\",\"node_id\":\"…\",\"text\":\"…\"}}" + ) + nodes = copy.deepcopy(before_nodes) + edges = copy.deepcopy(before_edges) + tried: list[dict[str, Any]] = [] + effective: list[dict[str, Any]] = [] + plan = "" + try: + async for op in _iter_ops(chat, [("system", system), ("human", _publish_fix_request(base, errors, level))]): + kind = op.get("op") + if kind in ("thinking", "heartbeat"): + continue + if kind == "plan": + plan = str(op.get("summary") or "") + elif kind == "done": + out["summary"] = str(op.get("explanation") or "") + elif kind in ("question", "reply"): + text = str(op.get("text") or "").strip() + # 原样交给人看,前面标上是哪个节点的事(界面上认节点标题,不认 id) + where = before_nodes.get(str(op.get("node_id") or "")) + title = str((where.get("data") or {}).get("label") or where.get("id")) if where else "" + if text: + out["questions"].append(f"「{title}」:{text}" if title and title not in text else text) + else: + tried.append(op) + if _apply_op(nodes, edges, op): + effective.append(op) + except Exception as e: # noqa: BLE001 - 修不成就照实说,确定性修复的结果照样交给人 + out["summary"] = f"Copilot 这一轮没跑完:{explain_error(e)[0]}" + return out + out["summary"] = out["summary"] or plan + if not effective: + # 没动图:要么全是要人拿主意的问题,要么它没给出能用的修改。都不算「拒绝」 + out["summary"] = out["summary"] or ("Copilot 认为这些问题要你来拿主意" if out["questions"] + else "Copilot 这一轮没有给出修改") + return out + + proposed = _proposed_graph(base, nodes, edges) + reasons = forbidden_changes(base, proposed, tried) + after = _blocking_issues(_nodes_by_id(proposed), proposed.get("edges") or [], sources=sources, + baseline=baseline, level=level) + if reasons: + out["reason"] = "Copilot 的修改降低了要求,已作废:" + ";".join(reasons) + elif after is None: + out["reason"] = "Copilot 改完的图结构读不懂,已作废" + elif why := judge(errors, after): + out["reason"] = f"Copilot 的修改没有让图变得更好,已作废:{why}" + else: + out.update(accepted=True, graph=proposed, ops=effective) + return out + + +def _nodes_by_id(graph: dict[str, Any]) -> dict[str, dict[str, Any]]: + return {str(n["id"]): n for n in graph.get("nodes") or [] if isinstance(n, dict) and n.get("id")} + + +def _proposed_graph(base: dict[str, Any], nodes: dict[str, dict[str, Any]], + edges: list[dict[str, Any]]) -> dict[str, Any]: + """Copilot 改完的整张图:原图里前端的东西(坐标、连线 id、视口)原样留着,新节点找空处摆。""" + out = copy.deepcopy(base) + out["nodes"] = list(nodes.values()) + out["edges"] = edges + pinned = _pinned_positions(base) + fresh = [nid for nid in nodes if nid not in pinned] + if fresh: + try: + laid, _ = _layout_keeping(GraphSpec.model_validate({"nodes": out["nodes"], "edges": edges}), pinned) + where = {n.id: n.position.model_dump() for n in laid.nodes} + for nid in fresh: + nodes[nid]["position"] = where.get(nid, nodes[nid].get("position")) + except Exception: # noqa: BLE001 - 摆不好就留在原点,复核那一步会报结构问题 + pass + return out + + class FromRunIn(BaseModel): run_id: str name: str = "" diff --git a/backend/app/api/datasources.py b/backend/app/api/datasources.py index ff5c8c60..be613f0f 100644 --- a/backend/app/api/datasources.py +++ b/backend/app/api/datasources.py @@ -19,8 +19,8 @@ from app.core.config import settings from app.core.crypto import encrypt, mask from app.data import introspect as introspect_mod from app.data.engine import ( - MAX_QUERY_TIMEOUT_S, QUERY_TIMEOUT_OPTION, SUPPORTED_KINDS, build_url, engine_args, engines, - query_timeout_problem, + MASK_COLUMNS_OPTION, MAX_QUERY_TIMEOUT_S, QUERY_TIMEOUT_OPTION, SUPPORTED_KINDS, build_url, engine_args, + engines, mask_columns_problem, query_timeout_problem, ) from app.db.base import get_session from app.db.models import DataSource @@ -155,8 +155,16 @@ _TIMEOUT_FIELD = { } +#: 证据面板展示原始行时遮掉的列。每种库都有:遮罩只看查询结果的列名,和方言无关 +_MASK_FIELD = { + "key": MASK_COLUMNS_OPTION, "label": "遮罩的列", "placeholder": "phone, email", + "help": "证据面板展示查询结果的原始行时,这些列的值一律显示成「已遮罩」。在有身份体系之前," + "遮罩只减少暴露,不是安全边界:完整快照仍能按工件取到,SQL 里给列起别名也能绕开", +} + + def _refuse_bad_options(options: dict[str, Any] | None) -> None: - problem = query_timeout_problem(options) + problem = query_timeout_problem(options) or mask_columns_problem(options) if problem: raise HTTPException(422, problem) @@ -175,11 +183,11 @@ async def list_kinds() -> dict[str, Any]: "needs": ["host", "database", "username", "password"], "hint": "字符集默认 utf8mb4,要换就在「高级连接参数」里加 charset", "advanced": [{"key": "charset", "label": "字符集", "placeholder": "utf8mb4"}, - _TIMEOUT_FIELD]}, + _TIMEOUT_FIELD, _MASK_FIELD]}, {"value": "postgres", "label": "PostgreSQL", "default_port": 5432, "needs": ["host", "database", "username", "password"], "hint": "「schema」留空就用 public", - "advanced": [_TIMEOUT_FIELD]}, + "advanced": [_TIMEOUT_FIELD, _MASK_FIELD]}, {"value": "oracle", "label": "Oracle", "default_port": 1521, "needs": ["host", "username", "password"], "hint": "service_name 和 SID 二选一:一般填 service_name,老库只给了 SID 就切到 SID。" @@ -191,10 +199,11 @@ async def list_kinds() -> dict[str, Any]: "help": "和 SID 二选一"}, {"key": "sid", "label": "SID", "placeholder": "ORCL", "help": "和 service_name 二选一"}, _TIMEOUT_FIELD, + _MASK_FIELD, ]}, {"value": "sqlite", "label": "SQLite(文件)", "default_port": None, "needs": ["database"], "hint": "「数据库文件路径」填 .db 文件的绝对路径", - "advanced": [_TIMEOUT_FIELD]}, + "advanced": [_TIMEOUT_FIELD, _MASK_FIELD]}, ], "supported": list(SUPPORTED_KINDS), } diff --git a/backend/app/api/evidence.py b/backend/app/api/evidence.py index 99a65a18..1eb82cf9 100644 --- a/backend/app/api/evidence.py +++ b/backend/app/api/evidence.py @@ -5,12 +5,18 @@ - 报告文档:report.checked 事件里的 doc_artifact - 口径卡的指标集:口径卡节点 node.finished 事件里的 evidence 台账 +- 查询快照:tool.end 事件里的 query_artifact,或者 node.finished.evidence 里的 query 条目 - 成果(哪些字段是报告原文):出口节点 node.finished 事件里的 node_output 工件 - 运行输入:入口节点 node.finished 事件里的 node_output 工件 artifacts 表可以事后插行,run.output 是可以改写的一列,封存之后追加的事件不在 -核对范围里——这三样都不当来源。本期只解析数字层(口径卡指标、运行输入)的链; -旧运行按旧契约的位置信息标出 matched(legacy_contract),不按数值猜来源。 +核对范围里——这三样都不当来源。解析数字层的链:口径卡指标 → 它的输入 → 查询快照里 +的那一格,或者单元格引用直接到查询快照;旧运行按旧契约的位置信息标出 matched +(legacy_contract),不按数值猜来源。 + +查询步骤只给被引用的行和前后各 2 行,完整快照仍走 /api/artifacts/{id}。数据源 +options.mask_columns 里的列换成「已遮罩」——在有身份体系之前,这只减少暴露,不是 +安全边界:知道工件 id 的人照样能取到整份快照。 """ from __future__ import annotations @@ -22,8 +28,9 @@ from sqlalchemy import select from app.api.coded import CodedHTTPException from app.core.artifact_store import load +from app.data.engine import masked_columns from app.db.base import SessionLocal -from app.db.models import Run, RunEvent +from app.db.models import DataSource, Run, RunEvent, Workflow from app.engine.evidence import ( RenderError, find_segment, @@ -47,6 +54,18 @@ LEGACY_NOTE = "旧版出具:按数值匹配,不是显式引用。同一个 NONE_NOTE = "这次运行没有报告文档,也没有出具契约,没有可以展示的证据" NO_DOC_NOTE = "这次运行的出具契约用的是引用模式,但报告没有产出可以核对的文档" +MASKED = "已遮罩" +MASK_NOTE = "这些列在数据源里设了遮罩,面板上不显示原值。在有身份体系之前,遮罩只减少暴露,不是安全边界" +#: 查询条目的数据源现在找不到了(改名或删掉了):只能按快照里记下的、查询当时的遮罩来遮 +SOURCE_GONE = "数据源「{source}」现在找不到了(改名或删掉了),按查询当时记下的遮罩处理" +#: 查询步骤给被引用的行前后各带几行 +WINDOW = 2 +#: 一个查询步骤最多给多少行:数组字段引用了一大段行时,窗口不能把整份快照搬过来 +MAX_WINDOW_ROWS = 50 +UNSEALED_QUERY = "这份查询快照不在封存范围内的任何事件里(没有哪次查询在封存前交回过它),不能当证据展示" +TAMPERED_QUERY = "查询快照和它的哈希对不上,疑似被改过,不展示其中的行" +MISSING_QUERY = "查询快照在工件库里取不回来" + # -------------------------------------------------------------------------- # 封存范围 @@ -63,6 +82,10 @@ class _Sealed: ledger: set[tuple[str, str]] = field(default_factory=set) #: 节点 id → 它最后一次 node.finished 的产出工件 id outputs: dict[str, str] = field(default_factory=dict) + #: 封存范围内交回过的查询快照:工件 id → {node_id, tool, source, via, call_id, exec} + queries: dict[str, dict[str, Any]] = field(default_factory=dict) + #: 口径卡节点 id → 封存范围内它的 caliber.upgrade 事件(升版处置) + upgrades: dict[str, dict[str, Any]] = field(default_factory=dict) @property def sealed(self) -> bool: @@ -110,13 +133,24 @@ async def _sealed(run_id: str) -> _Sealed: "sealed": bool(verdict.get("sealed")), "ok": verdict.get("ok"), "manifest_seq": bound, "legacy": bool(verdict.get("legacy", False))}) for _, etype, node_id, data in events: - if etype != "node.finished" or not node_id or not isinstance(data, dict): + if not node_id or not isinstance(data, dict): + continue + if etype == "tool.end" and data.get("query_artifact"): + out.queries.setdefault(str(data["query_artifact"]), { + "node_id": node_id, "tool": data.get("tool"), "via": data.get("artifact")}) + elif etype == "caliber.upgrade": + out.upgrades[node_id] = data + if etype != "node.finished": continue if data.get("artifact"): out.outputs[node_id] = data["artifact"] for entry in data.get("evidence") or []: if isinstance(entry, dict) and entry.get("artifact"): out.ledger.add((node_id, entry["artifact"])) + if entry.get("kind") == "query": + out.queries[str(entry["artifact"])] = { + "node_id": node_id, **{k: entry[k] for k in ("tool", "source", "via", "call_id", "exec") + if entry.get(k) is not None}} return out @@ -243,25 +277,63 @@ def _graph_of(report: _Report, sealed: _Sealed, inputs: dict[str, Any]) -> tuple evidence = [] cards: dict[str, dict[str, Any] | None] = {} - for alias, entry in (doc.get("catalog") or {}).items(): - evidence.append({ + catalog = doc.get("catalog") or {} + cells: list[str] = [] + for _, unit in iter_units(doc): + for seg in unit.get("segments") or []: + cite = seg.get("cite") or {} + if cite.get("kind") == "cell" and cite.get("status") == "resolved": + cells.append(_cell_name(cite.get("alias"), cite.get("locator") or {})) + for alias, entry in catalog.items(): + item = { "alias": alias, "eid": entry.get("eid"), "kind": entry.get("kind"), "label": entry.get("label"), "node_id": entry.get("node_id"), "artifact": entry.get("artifact"), "sealed": _entry_sealed(sealed, entry, inputs), "cited_by": cited_by.get(alias, []), "units": units_of.get(alias, []), "report": report.node_id, - }) + } + if entry.get("kind") in ("query", "retrieval"): + # 查询、检索条目带上是什么、有多大:图上不点开也看得出这是哪次取数 + item.update({k: entry[k] for k in ("tool", "source", "columns", "rows", "truncated") if k in entry}) + evidence.append(item) if entry.get("kind") != "metric" or not entry.get("artifact"): continue artifact = entry["artifact"] if artifact not in cards: cards[artifact] = _load_card(artifact)[0] metric = _metric_in(cards[artifact], (entry.get("locator") or {}).get("metric")) - for item in (metric or {}).get("inputs") or []: - edges.append({"from": alias, "to": item.get("path"), "rel": "input", "node_id": item.get("node_id"), - "report": report.node_id}) + for source in (metric or {}).get("inputs") or []: + edge = {"from": alias, "to": source.get("path"), "rel": "input", "node_id": source.get("node_id"), + "report": report.node_id} + if cell := _input_cell(source, catalog): + edge["cell"] = cell + cells.append(cell) + edges.append(edge) + for cell in dict.fromkeys(c for c in cells if c): + edges.append({"from": cell, "to": cell.split(".", 1)[0], "rel": "cell_of", "report": report.node_id}) return evidence, edges +def _cell_name(alias: Any, locator: dict[str, Any]) -> str: + """Q1.r0.gmv:报告目录里的全局编号加行列,和 [[v:]] 的写法一致。""" + if not alias or not isinstance(locator.get("row"), int) or not locator.get("column"): + return "" + return f"{alias}.r{locator['row']}.{locator['column']}" + + +def _query_alias(catalog: dict[str, Any], artifact: Any) -> str | None: + """这份快照在报告目录里的全局编号。agent 字段的 ref 是那个节点内部的编号,靠工件对上。""" + if not artifact: + return None + return next((alias for alias, e in catalog.items() if e.get("kind") == "query" and e.get("artifact") == artifact), + None) + + +def _input_cell(item: dict[str, Any], catalog: dict[str, Any]) -> str: + """口径卡的一个输入落在哪一格(cell() 取的、或者 agent 字段核对到的),按全局编号写。""" + locator = item.get("locator") or {} + return _cell_name(_query_alias(catalog, item.get("artifact")), locator) if item.get("artifact") else "" + + def _load_card(artifact: str) -> tuple[dict[str, Any] | None, bool]: try: card = load(artifact) @@ -312,7 +384,7 @@ def _legacy(sealed: _Sealed, issuance: dict[str, Any]) -> dict[str, Any]: @router.get("/{run_id}/evidence/segments/{segment_id}") async def evidence_segment(run_id: str, segment_id: str, report: str | None = None) -> dict[str, Any]: - """点开报告里的一个片段:片段、所在的句子、出处链(本期是指标步骤加它的输入)、封存状态。 + """点开报告里的一个片段:片段、所在的句子、出处链(指标 → 输入 → 查询快照,或者单元格直接到查询快照)、封存状态。 一次运行里有好几份报告时用 ?report=<节点 id> 指定;不指定取成果里标注的那一份。 """ @@ -333,8 +405,9 @@ async def evidence_segment(run_id: str, segment_id: str, report: str | None = No doc = chosen.doc markdown = str(doc.get("markdown") or "") start, end = unit.get("span") or [0, 0] - chain = _chain(seg, doc, sealed) + chain = await _chain(seg, doc, sealed) covered = sealed.trusted and all(step.get("sealed") for step in chain if "sealed" in step) + masked = list(dict.fromkeys(c for step in chain if step.get("step") == "query" for c in step.get("masked") or [])) return { "report": {"node_id": chosen.node_id, "doc_artifact": chosen.doc_artifact}, "segment": {"id": seg["id"], "text": seg.get("text"), "kind": seg.get("kind"), "state": seg.get("state"), @@ -348,8 +421,7 @@ async def evidence_segment(run_id: str, segment_id: str, report: str | None = No "violations": [v for v in doc.get("violations") or [] if v.get("segment") == seg["id"] or (v.get("unit") == unit.get("id") and not v.get("segment"))], "seal": {"sealed": sealed.sealed, "ok": sealed.seal["ok"], "covered": covered}, - # 本期没有查询快照的行,也就没有要遮的列;字段先占位,前端不用等二期再改 - "redacted": {"columns": []}, + "redacted": {"columns": masked, "note": MASK_NOTE} if masked else {"columns": []}, } @@ -363,11 +435,21 @@ def _choose(reports: list[_Report], wanted: str | None, sealed: _Sealed) -> _Rep return next((r for r in reports if r.node_id == primary.get("report_node")), reports[0]) -def _chain(seg: dict[str, Any], doc: dict[str, Any], sealed: _Sealed) -> list[dict[str, Any]]: +async def _chain(seg: dict[str, Any], doc: dict[str, Any], sealed: _Sealed) -> list[dict[str, Any]]: cite = seg.get("cite") or {} + entry = (doc.get("catalog") or {}).get(cite.get("alias")) or {} + masks = _Masks() + if _missing_input(cite, entry): + # 指标在卡里、只是这次没有值(显示「—」):照样给出指标、输入和查询步骤,面板才指得出是哪个 + # 输入空了、那一格在哪次查询里。片段上没记定位和 eid,用目录条目的(它们指的是同一个指标) + return await _metric_chain(seg, {**cite, "locator": entry.get("locator") or {}, "eid": entry.get("eid")}, + entry, doc, sealed, masks) if seg.get("state") != "deterministic" or cite.get("status") != "resolved": return [] - entry = (doc.get("catalog") or {}).get(cite.get("alias")) or {} + if cite.get("kind") == "cell": + locator = cite.get("locator") or {} + return [await _query_step(str(entry.get("artifact") or ""), [(locator.get("row"), locator.get("column"))], + doc, sealed, masks)] if cite.get("kind") == "input": name = (cite.get("locator") or {}).get("field") inputs = _input_payloads(sealed) @@ -378,11 +460,17 @@ def _chain(seg: dict[str, Any], doc: dict[str, Any], sealed: _Sealed) -> list[di "sealed": _entry_sealed(sealed, entry, inputs)}] if cite.get("kind") != "metric": return [] - return _metric_chain(seg, cite, entry, sealed) + return await _metric_chain(seg, cite, entry, doc, sealed, masks) -def _metric_chain(seg: dict[str, Any], cite: dict[str, Any], entry: dict[str, Any], - sealed: _Sealed) -> list[dict[str, Any]]: +def _missing_input(cite: dict[str, Any], entry: dict[str, Any]) -> bool: + """引用的指标存在,只是这次缺输入没有值。引用写错(目录里没有、同名指标有歧义)的不算。""" + return (cite.get("kind") == "metric" and cite.get("status") == "unresolved" and entry.get("kind") == "metric" + and entry.get("value") is None and entry.get("status") == "missing_input") + + +async def _metric_chain(seg: dict[str, Any], cite: dict[str, Any], entry: dict[str, Any], doc: dict[str, Any], + sealed: _Sealed, masks: "_Masks") -> list[dict[str, Any]]: """指标步骤:从封存台账里那件 metric_set 工件取回整张卡,按 id 找到指标,再验三件事—— eid 能由工件和定位重算出来、卡里的值按同样的格式渲染出来就是报告上的字、这件工件 确实记在口径卡节点封存过的台账里。""" @@ -409,10 +497,127 @@ def _metric_chain(seg: dict[str, Any], cite: dict[str, Any], entry: dict[str, An "hash_ok": hash_ok and metric is not None, "render_ok": rendered_now is not None and rendered_now == seg.get("text"), "sealed": sealed.trusted and (str(entry.get("node_id") or ""), artifact) in sealed.ledger, - # 口径卡的版本还没纳入升版处置(那一套目前只管钉了版本的子工作流) - "caliber_upgrade": None, + "source": await _caliber_source((card or {}).get("source")), + "caliber_upgrade": _caliber_upgrade(sealed.upgrades.get(str(entry.get("node_id") or ""))), } - return [step, *({"step": "input", **item} for item in (metric or {}).get("inputs") or [])] + catalog = doc.get("catalog") or {} + inputs: list[dict[str, Any]] = [] + #: 输入指到的查询快照 → 被引用的格;同一份快照只给一个查询步骤,格合在一起高亮 + wanted: dict[str, list[tuple[Any, Any]]] = {} + for item in (metric or {}).get("inputs") or []: + one = {"step": "input", **item} + artifact_of = item.get("artifact") + if artifact_of and item.get("via") in ("tool_cell", "agent_field"): + one["query"] = _query_alias(catalog, artifact_of) + if cell := _input_cell(item, catalog): + one["cell"] = cell + wanted.setdefault(str(artifact_of), []).extend(_located(item.get("locator") or {})) + inputs.append(one) + queries = [await _query_step(a, cells, doc, sealed, masks) for a, cells in wanted.items()] + return [step, *inputs, *queries] + + +def _located(locator: dict[str, Any]) -> list[tuple[Any, Any]]: + """输入的定位 → 被引用的格。单个格 {row, column};数组字段是一段行 {rows:[a,b], column | columns}。""" + if isinstance(locator.get("row"), int): + return [(locator["row"], locator.get("column"))] + rows = locator.get("rows") + if isinstance(rows, list) and len(rows) == 2 and all(isinstance(r, int) for r in rows): + cols = [locator["column"]] if locator.get("column") else list((locator.get("columns") or {}).values()) + return [(r, c) for r in range(rows[0], rows[1] + 1) for c in cols] + return [] + + +async def _caliber_source(source: Any) -> dict[str, Any] | None: + """钉住别处口径卡(caliber_from)时,定义取自哪个工作流的哪一版;带上工作流现在的名字方便认。""" + if not isinstance(source, dict) or not source.get("workflow_id"): + return None + async with SessionLocal() as session: + workflow = await session.get(Workflow, str(source["workflow_id"])) + return {**source, "workflow_name": workflow.name if workflow else None} + + +def _caliber_upgrade(event: dict[str, Any] | None) -> dict[str, Any] | None: + """这张口径卡在正式运行发起时的升版处置:钉在哪一版、上游最新是哪一版、怎么处置的。""" + if not isinstance(event, dict): + return None + return {k: event.get(k) for k in ("workflow_id", "caliber_node", "pinned", "latest", "policy", "policy_label")} + + +class _Masks: + """一次请求里查过的数据源遮罩:数据源名 → (要遮的列(小写), 数据源现在还在不在)。""" + + def __init__(self) -> None: + self._memo: dict[str, tuple[set[str], bool]] = {} + + async def of(self, source: Any) -> tuple[set[str], bool]: + name = str(source or "") + if not name: + return set(), True + if name not in self._memo: + async with SessionLocal() as session: + row = (await session.execute(select(DataSource).where(DataSource.name == name))).scalar_one_or_none() + self._memo[name] = ({c.lower() for c in masked_columns(row.options if row else None)}, row is not None) + return self._memo[name] + + +async def _query_step(artifact: str, cells: list[tuple[Any, Any]], doc: dict[str, Any], sealed: _Sealed, + masks: _Masks) -> dict[str, Any]: + """查询步骤:被引用的格所在的行加前后各 WINDOW 行,高亮那几格,遮掉数据源设了遮罩的列。 + + 快照只认封存范围内的事件交回过的那些(tool.end.query_artifact、node.finished.evidence 的 + query 条目);目录里写着、事件里追不到的,照实说不认,一行都不给。 + """ + catalog = doc.get("catalog") or {} + alias = _query_alias(catalog, artifact) + entry = catalog.get(alias, {}) if alias else {} + info = sealed.queries.get(artifact) if artifact else None + rows_hit = sorted({r for r, _ in cells if isinstance(r, int)}) + cols_hit = list(dict.fromkeys(str(c) for _, c in cells if c)) + step: dict[str, Any] = { + "step": "query", "alias": alias, "artifact": artifact or None, + "node_id": (info or {}).get("node_id") or entry.get("node_id"), + "tool": (info or {}).get("tool") or entry.get("tool"), + "source": (info or {}).get("source") or entry.get("source"), + "sql": None, "columns": [], "rows": [], "row_offset": 0, "row_index": [], "total_rows": None, + "truncated": None, + "highlight": {"rows": rows_hit, "cols": cols_hit, + "cells": [[r, str(c)] for r, c in dict.fromkeys(cells) if isinstance(r, int) and c]}, + "masked": [], "hash_ok": None, "sealed": False, + } + if info is None: + return {**step, "note": UNSEALED_QUERY} + step["sealed"] = sealed.trusted + try: + snap = load(artifact) + step["hash_ok"] = True if snap is not None else None + except ValueError: + snap, step["hash_ok"] = None, False + if not isinstance(snap, dict) or not isinstance(snap.get("rows"), list): + return {**step, "note": TAMPERED_QUERY if step["hash_ok"] is False else MISSING_QUERY} + columns = [str(c) for c in snap.get("columns") or []] + rows = snap["rows"] + step["source"] = step["source"] or snap.get("source") + # 遮的是「查询当时记下的」和「数据源现在设的」两者之和:事后加的遮罩照样生效,数据源改名、 + # 删掉了也不会把当时遮着的列亮出来 + hidden, found = await masks.of(step["source"]) + recorded = snap.get("mask_columns") + hidden = hidden | ({str(c).lower() for c in recorded if isinstance(c, str)} if isinstance(recorded, list) else set()) + if not found: + step["mask_note"] = SOURCE_GONE.format(source=step["source"]) + masked = {i for i, c in enumerate(columns) if c.lower() in hidden} + index = sorted({i for r in rows_hit for i in range(r - WINDOW, r + WINDOW + 1) if 0 <= i < len(rows)}) + step.update(sql=snap.get("sql"), columns=columns, total_rows=len(rows), truncated=bool(snap.get("truncated")), + masked=[columns[i] for i in sorted(masked)], + # 查询时从驱动的原始值记下的列类型(老快照没有):文本列里的 "2026" 按文本显示,不当数 + column_types=snap.get("column_types") if isinstance(snap.get("column_types"), dict) else {}) + if len(index) > MAX_WINDOW_ROWS: + index, step["window_truncated"] = index[:MAX_WINDOW_ROWS], True + step["row_index"] = index + step["row_offset"] = index[0] if index else 0 + step["rows"] = [[MASKED if i in masked else v for i, v in enumerate(rows[r])] if isinstance(rows[r], list) + else rows[r] for r in index] + return step def _note(seg: dict[str, Any], unit: dict[str, Any]) -> str: @@ -422,6 +627,8 @@ def _note(seg: dict[str, Any], unit: dict[str, Any]) -> str: return "这个数字没有出处:写作者直接写了数字,没有用引用标记,系统没法核对它" if issue == "unresolved_ref": return f"引用解析不了:{(seg.get('cite') or {}).get('reason') or '证据目录里没有它'}" + if seg.get("state") == "deterministic" and (seg.get("cite") or {}).get("kind") == "cell": + return "这个值由系统从查询快照里取出、按固定规则渲染,没有经过模型转写" if seg.get("state") == "deterministic": return "数字由系统从证据里取出、按口径卡的格式渲染,没有经过模型转写" kind = seg.get("kind") diff --git a/backend/app/api/runs.py b/backend/app/api/runs.py index e2212e11..e6489b3f 100644 --- a/backend/app/api/runs.py +++ b/backend/app/api/runs.py @@ -2,6 +2,7 @@ from __future__ import annotations import asyncio import contextlib +import logging from datetime import datetime from typing import Any from urllib.parse import unquote @@ -13,15 +14,20 @@ from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy import select as _select +from app.api.coded import CodedHTTPException from app.core.bus import bus from app.core import artifact_store from app.db.base import SessionLocal, get_session from app.db.models import Approval, Artifact, Run, RunEvent, Workflow, WorkflowVersion -from app.engine.governance import unresolved_caliber_upgrades +from app.engine.governance import governance_note, unresolved_caliber_upgrades from app.engine.runner import run_manager -from app.engine.schema import GraphSpec +from app.engine.schema import GraphSpec, NodeType, type_label router = APIRouter(prefix="/api/runs", tags=["runs"]) +logger = logging.getLogger(__name__) + +#: 发起时发现绑定的工具在本机不存在(见 missing_tools) +TOOL_MISSING = "run_tool_missing" def actor_of(header: str | None) -> str | None: @@ -87,6 +93,7 @@ async def start_run( name = "未保存的工作流" version: int | None = None version_hash: str | None = None + governed = False if payload.run_class == "formal": # 正式运行的全部语义就这一条规则:必须引用一个不可变的已发布版本。 @@ -126,6 +133,8 @@ async def start_run( graph = snapshot.graph version_hash = snapshot.graph_hash or artifact_store.graph_hash(graph) name = workflow.name + # 按这一版发布时记下的级别;记级别之前发布的老版本没有,照旧看工作流现在的 status + governed = (snapshot.level == "governed") if snapshot.level else workflow.status == "governed" # 口径升版是被迫处置的事件:钉住的方法卡有新版本而未声明策略,拒绝启动 spec = GraphSpec.model_validate(graph) @@ -143,6 +152,7 @@ async def start_run( name = workflow.name if not graph: raise HTTPException(400, "没有要运行的内容:请求里既没有工作流 id,也没有工作流内容") + await _refuse_missing_tools(session, graph) from app.api.settings import resolve_run_scope @@ -165,6 +175,11 @@ async def start_run( except ValueError as e: raise HTTPException(400, str(e)) from e + if payload.run_class == "formal": + # 按不按受管出具在发起这一刻定下来、记进封存范围:status 反映的是当前画布,中途改图、 + # 审批停下再恢复时再去读它,报告节点和出口复核可能各读到不同的值 + await run_manager.note(run.id, "log", **governance_note( + governed=governed, workflow_name=name, version=version)) # 已声明策略的升版记入事件流,出具物上能看到"这期换口径了、怎么处置的" for ev in upgrade_events: payload_ev = dict(ev) @@ -173,6 +188,94 @@ async def start_run( return run +# -------------------------------------------------------------------------- +# 发起前检查绑定的工具 +# -------------------------------------------------------------------------- + + +def _bindings(spec: GraphSpec) -> list[tuple[str, str]]: + """图里每一处绑定的工具:(谁绑的, 工具名)。调用工具节点的 tool、Agent 的 tools、协作成员的 tools。""" + out: list[tuple[str, str]] = [] + for node in spec.nodes: + who = f"「{node.title}」({type_label(node.type)})" + cfg = node.config + if node.type == NodeType.TOOL: + names = [cfg.get("tool")] + out.extend((who, n) for n in names if isinstance(n, str) and n.strip()) + elif node.type == NodeType.AGENT: + out.extend((who, n) for n in cfg.get("tools") or [] if isinstance(n, str) and n.strip()) + elif node.type == NodeType.SUPERVISOR: + for member in cfg.get("agents") or []: + if not isinstance(member, dict): + continue + label = f"「{node.title}」的成员「{member.get('name') or '?'}」" + out.extend((label, n) for n in member.get("tools") or [] if isinstance(n, str) and n.strip()) + # 模板拼出来的工具名({{ vars.tool }})发起时还不知道是谁,交给运行时 + return [(who, name.strip()) for who, name in out if "{{" not in name] + + +async def missing_tools(session: AsyncSession, spec: GraphSpec) -> list[tuple[str, str, str]]: + """本机不存在的绑定:(谁绑的, 工具名, 去哪接入:data / tools)。 + + 解析和运行时 build_tools 一致:内置(静态注册)、mcp:<服务>/<工具>、db_query__ / db_schema__ + <数据源>、其余按自定义工具。数据源和自定义工具只认启用着的——停用的运行时同样拿不到。 + MCP 只看服务登记着、启用着没有:连不连得上、工具还在不在,要连一次才知道,发起时不去等它, + 上次没连上或者工具清单里没有的只在日志里提一句。 + """ + from app.db.models import CustomTool, DataSource, McpServer + from app.tools.datasource import QUERY_PREFIX, SCHEMA_PREFIX + from app.tools.registry import all_specs + + bindings = _bindings(spec) + if not bindings: + return [] + builtin = all_specs() + sources = {n for (n,) in await session.execute(select(DataSource.name).where(DataSource.enabled.is_(True)))} + custom = {n for (n,) in await session.execute(select(CustomTool.name).where(CustomTool.enabled.is_(True)))} + servers = {r.name: r for r in (await session.execute(select(McpServer))).scalars()} + missing: list[tuple[str, str, str]] = [] + for who, name in bindings: + if name.startswith("mcp:"): + server, _, tool = name[4:].partition("/") + row = servers.get(server) + if row is None or not row.enabled: + missing.append((who, name, "tools")) + elif row.status == "error": + logger.warning("%s 绑的 %s:MCP 服务 %s 上次没连上,运行时可能拿不到这个工具", who, name, server) + elif tool not in ("", "*") and row.tools_cache and tool not in row.tools_cache: + logger.warning("%s 绑的 %s:MCP 服务 %s 上次交回的工具清单里没有 %s", who, name, server, tool) + elif name in builtin: + continue + elif name.startswith((QUERY_PREFIX, SCHEMA_PREFIX)): + source = name[len(QUERY_PREFIX):] if name.startswith(QUERY_PREFIX) else name[len(SCHEMA_PREFIX):] + if source not in sources: + missing.append((who, name, "data")) + elif name not in custom: + missing.append((who, name, "tools")) + return missing + + +async def _refuse_missing_tools(session: AsyncSession, graph: dict[str, Any]) -> None: + """绑定的工具在本机不存在就不发起:以前要跑到那一步才失败,前面的步骤白跑、钱白花。""" + try: + spec = GraphSpec.model_validate(graph) + except Exception: # noqa: BLE001 - 图本身不成形交给 run_manager.start 报 + return + missing = await missing_tools(session, spec) + if not missing: + return + lines = ";".join(f"{who}绑的 {name}" for who, name, _ in missing[:8]) + more = f"等 {len(missing)} 处" if len(missing) > 8 else "" + places = {where for _, _, where in missing} + if places == {"data"}: + fix = "去数据页接入,或在节点里重新选" + elif places == {"tools"}: + fix = "去工具页接入,或在节点里重新选" + else: + fix = "数据源工具去数据页接入、其他工具去工具页接入,或在节点里重新选" + raise CodedHTTPException(422, f"绑定的工具在本机不存在:{lines}{more}。{fix}", TOOL_MISSING) + + #: workflow_id 取这个值时只要没挂在任何工作流上的运行:画布上没保存就跑的临时图, #: 以及工作流被删掉之后留下来的运行(外键是 SET NULL) UNSAVED = "__none__" diff --git a/backend/app/api/workflows.py b/backend/app/api/workflows.py index 6b3e78d1..123fc69c 100644 --- a/backend/app/api/workflows.py +++ b/backend/app/api/workflows.py @@ -12,12 +12,14 @@ from app.api.runs import actor_of from app.core.artifact_store import graph_hash from app.db.base import get_session from app.db.models import Run, Workflow, WorkflowVersion -from app.engine.governance import lint_for_publish -from app.engine.schema import GraphSpec, validate_graph +from app.engine.governance import publish_issues +from app.engine.schema import GraphSpec, NodeType, validate_graph router = APIRouter(prefix="/api/workflows", tags=["workflows"]) _GONE = "这个工作流不存在,可能已经被删了" +_LEVELS = ("published", "governed") +_BAD_LEVEL = "发布档位只能选「已发布」或「受管」" class WorkflowIn(BaseModel): @@ -181,6 +183,8 @@ class VersionOut(BaseModel): version: int note: str created_at: Any = None + #: 这一版按哪一档发布的(published / governed)。没发布过、或者是记级别之前发布的为 null + level: str | None = None model_config = {"from_attributes": True} @@ -238,7 +242,7 @@ async def get_version( id=snapshot.id, version=snapshot.version, note=snapshot.note, created_at=snapshot.created_at, workflow_id=workflow_id, graph=graph, graph_hash=snapshot.graph_hash or graph_hash(graph), input_fields=fields, - published=workflow.published_version == snapshot.version, + published=workflow.published_version == snapshot.version, level=snapshot.level, ) @@ -297,8 +301,8 @@ async def publish_workflow( 错误会挡住发布——这就是"必经检查点"落地的地方。 返回 200 + ok/issues 而不是抛错,让前端能把问题列表渲染出来。 """ - if payload.level not in ("published", "governed"): - raise HTTPException(400, "发布档位只能选「已发布」或「受管」") + if payload.level not in _LEVELS: + raise HTTPException(400, _BAD_LEVEL) workflow = await session.get(Workflow, workflow_id) if not workflow: raise HTTPException(404, _GONE) @@ -320,13 +324,14 @@ async def publish_workflow( except Exception as e: # noqa: BLE001 return {"ok": False, "issues": [{"level": "error", "message": f"工作流的结构读不懂:{graph_error(e)}"}]} - issues = [i.model_dump() for i in validate_graph(spec).issues] - issues += [i.model_dump() for i in lint_for_publish(spec, level=payload.level).issues] + issues = [i.model_dump() for i in publish_issues(spec, level=payload.level)] if any(i["level"] == "error" for i in issues): return {"ok": False, "level": payload.level, "version": version, "issues": issues} if not snapshot.graph_hash: snapshot.graph_hash = graph_hash(snapshot.graph) + # 级别记在这一版上:之后改画布(status 退回 draft),从这一版发起的正式运行照样按发布时的级别出具 + snapshot.level = payload.level workflow.status = payload.level workflow.published_version = version workflow.published_by = actor_of(x_actor) @@ -341,6 +346,130 @@ async def publish_workflow( } +# -------------------------------------------------------------------------- +# 发布前检查与自动修复:两个接口都只读,不改库、不发布 +# -------------------------------------------------------------------------- + +class PublishCheckIn(BaseModel): + level: str = "published" + #: 画布上还没保存的图。不带就用库里当前的草稿 + graph: dict[str, Any] | None = None + + +class AutofixIn(PublishCheckIn): + #: 要应用的修复 id(publish-check 给的 fixes[].id) + apply: list[str] = Field(default_factory=list) + #: choice 类修复的选择:{fix_id: 值},可以多选的给列表 + choices: dict[str, Any] = Field(default_factory=dict) + #: 剩下的 error 交给 Copilot 试着修(结果同样只是预览) + assist: bool = False + provider: str | None = None + model: str | None = None + + +async def _draft(session: AsyncSession, workflow_id: str, payload: PublishCheckIn) -> dict[str, Any]: + if payload.level not in _LEVELS: + raise HTTPException(400, _BAD_LEVEL) + workflow = await session.get(Workflow, workflow_id) + if not workflow: + raise HTTPException(404, _GONE) + return payload.graph if payload.graph is not None else (workflow.graph or {}) + + +async def _gate_context(session: AsyncSession, spec: GraphSpec) -> tuple[dict[str, Any], dict[str, Any]]: + """修复要用、但门禁本身不查库的东西:子工作流当前的发布版本,钉在别处的口径卡有哪些指标。""" + versions: dict[str, Any] = {} + cards: dict[str, Any] = {} + for node in spec.nodes: + cfg = node.config + if node.type == NodeType.SUBGRAPH and cfg.get("workflow_id") and str(cfg["workflow_id"]) not in versions: + upstream = await session.get(Workflow, str(cfg["workflow_id"])) + versions[str(cfg["workflow_id"])] = upstream.published_version if upstream else None + elif node.type == NodeType.METRICS and isinstance(cfg.get("caliber_from"), dict): + ref = cfg["caliber_from"] + version = ref.get("workflow_version") + if not ref.get("workflow_id") or not str(version or "").isdigit(): + continue + snapshot = (await session.execute(select(WorkflowVersion).where( + WorkflowVersion.workflow_id == str(ref["workflow_id"]), + WorkflowVersion.version == int(version)))).scalar_one_or_none() + card = next((n for n in ((snapshot.graph if snapshot else None) or {}).get("nodes") or [] + if isinstance(n, dict) and n.get("id") == ref.get("node_id")), None) + if card is not None: + cards[node.id] = ((card.get("data") or {}).get("config") or {}).get("metrics") or [] + return versions, cards + + +def _unreadable(level: str, e: Exception) -> dict[str, Any]: + return {"level": level, "ok": False, "fixes": [], "issues": [ + {"level": "error", "node_id": None, "edge_id": None, "field": None, "code": None, "fix": None, + "message": f"工作流的结构读不懂:{graph_error(e)}"}]} + + +@router.post("/{workflow_id}/publish-check") +async def publish_check( + workflow_id: str, payload: PublishCheckIn, session: AsyncSession = Depends(get_session) +) -> dict[str, Any]: + """发布前检查:和真正发布同一套口径(validate + 门禁)列出问题,以及能怎么修。 + + 只读:不改库、不发布。发布弹窗一打开就调它,不用等点了发布才知道被拦。 + """ + from app.engine.autofix import check + + graph = await _draft(session, workflow_id, payload) + try: + spec = GraphSpec.model_validate(graph) + except Exception as e: # noqa: BLE001 + return _unreadable(payload.level, e) + versions, cards = await _gate_context(session, spec) + return check(spec, level=payload.level, versions=versions, cards=cards) + + +@router.post("/{workflow_id}/autofix") +async def autofix( + workflow_id: str, payload: AutofixIn, session: AsyncSession = Depends(get_session) +) -> dict[str, Any]: + """应用选中的修复,返回修复后的整张图——只是预览。 + + 不自动保存、不自动发布:人看过逐项变更再确认,前端走现有的保存接口存草稿,再检查、再发布。 + 每条修复都复核过(降低要求的、错误没变少或者冒出新错误的丢弃并写明原因)。assist 为真时, + 剩下的 error 交给 Copilot,同一道复核通过才采纳。 + """ + from app.engine.autofix import apply_fixes, diff_changes + + graph = await _draft(session, workflow_id, payload) + try: + spec = GraphSpec.model_validate(graph) + except Exception as e: # noqa: BLE001 + bad = _unreadable(payload.level, e) + return {"graph": graph, "changes": [], "ops": [], "applied": [], "rejected": [ + {"fix_id": fid, "reason": "工作流的结构读不懂,没法应用修复"} for fid in payload.apply], + "remaining": bad["issues"], "fixes": [], "assist": None, "ok": False} + versions, cards = await _gate_context(session, spec) + out = apply_fixes(graph, payload.apply, payload.choices, level=payload.level, versions=versions, cards=cards) + out["assist"] = None + if payload.assist: + if out["ok"]: + out["assist"] = {"ok": True, "summary": "修复之后已经没有挡住发布的错误,没有再交给 Copilot", + "questions": []} + return out + from app.api.copilot import assist_publish_fix + + helped = await assist_publish_fix(session, out["graph"], level=payload.level, + provider=payload.provider, model=payload.model) + out["assist"] = {"ok": helped["accepted"], "summary": helped["summary"], "questions": helped["questions"]} + if helped["accepted"]: + fixed = helped["graph"] + out["changes"] += diff_changes(out["graph"], fixed, fix_id="assist", label="Copilot 的修改") + out["ops"] += helped["ops"] + out["applied"].append("assist") + after = apply_fixes(fixed, [], level=payload.level, versions=versions, cards=cards) + out.update(graph=fixed, remaining=after["remaining"], fixes=after["fixes"], ok=after["ok"]) + elif helped["reason"]: + out["rejected"].append({"fix_id": "assist", "reason": helped["reason"]}) + return out + + class ValidateIn(BaseModel): graph: dict[str, Any] diff --git a/backend/app/data/engine.py b/backend/app/data/engine.py index d668ead0..60376885 100644 --- a/backend/app/data/engine.py +++ b/backend/app/data/engine.py @@ -8,6 +8,7 @@ from __future__ import annotations import asyncio import json import logging +import re import time from contextlib import asynccontextmanager from dataclasses import dataclass @@ -48,10 +49,18 @@ QUERY_TIMEOUT_OPTION = "query_timeout_s" MIN_QUERY_TIMEOUT_S = 1 MAX_QUERY_TIMEOUT_S = 600 +#: 数据源 options 里要遮罩的列(列名列表,或者逗号分隔的一段文字)。证据面板展示原始行时, +#: 这些列的值一律换成「已遮罩」。在有身份体系之前,遮罩只减少暴露,不是安全边界: +#: 完整快照仍能按工件 id 取到,SQL 里给列起个别名也能绕开 +MASK_COLUMNS_OPTION = "mask_columns" + # options 里这些 key 是 AgentLab 自己的配置,不是驱动参数,拼 URL 时要摘掉。 # schema:探查哪个 schema(企业库里只读账号名下常常什么都没有,数据在别处) # query_timeout_s:查询时限,由数据层按语句下发给数据库,见 _server_deadline -_NON_DRIVER_OPTIONS = frozenset({"schema", QUERY_TIMEOUT_OPTION}) +# mask_columns:证据面板遮罩的列,驱动不认识它,拼进连接串会被当成未知参数拒掉 +_NON_DRIVER_OPTIONS = frozenset({"schema", QUERY_TIMEOUT_OPTION, MASK_COLUMNS_OPTION}) + +_MASK_SPLIT = re.compile(r"[,,、;;\n]+") @dataclass @@ -62,9 +71,11 @@ class QueryResult: truncated: bool elapsed_ms: int sql: str + #: 列名 → number / text / date / datetime / time / boolean。拿不准的列(全是空值、类型混着)不在里面 + column_types: dict[str, str] | None = None def to_payload(self) -> dict[str, Any]: - return { + payload: dict[str, Any] = { "columns": self.columns, "rows": self.rows, "row_count": self.row_count, @@ -72,6 +83,9 @@ class QueryResult: "elapsed_ms": self.elapsed_ms, "sql": self.sql, } + if self.column_types: + payload["column_types"] = self.column_types + return payload def build_url(source: Any, *, reveal: bool = False) -> str: @@ -174,6 +188,23 @@ def query_timeout_problem(options: dict[str, Any] | None) -> str | None: return None +def masked_columns(options: dict[str, Any] | None) -> list[str]: + """options.mask_columns → 列名列表(去空白、去重,保持顺序)。列表和「a, b」这样的文字都认。""" + raw = (options or {}).get(MASK_COLUMNS_OPTION) + items = _MASK_SPLIT.split(raw) if isinstance(raw, str) else raw if isinstance(raw, list) else [] + return list(dict.fromkeys(str(c).strip() for c in items if isinstance(c, (str, int)) and str(c).strip())) + + +def mask_columns_problem(options: dict[str, Any] | None) -> str | None: + """保存数据源时查一下遮罩列填得对不对;没填、或者填对了返回 None。""" + raw = (options or {}).get(MASK_COLUMNS_OPTION) + if raw is None or raw == "" or raw == []: + return None + if isinstance(raw, str) or (isinstance(raw, list) and all(isinstance(c, str) for c in raw)): + return None + return f"遮罩的列要填列名,用逗号分开,比如「phone, email」;现在填的是「{raw}」" + + def query_timeout(source: Any) -> float: """这个数据源上一条查询最多跑多久(秒)。没配、或者库里存着的值不对,用缺省。 @@ -477,23 +508,25 @@ async def run_query( if not source.readonly and is_write(statement): return await _run_write(engine, kind, statement, limits, started) - async def _read(conn: AsyncConnection) -> tuple[list[str], list[list[Any]], bool]: + async def _read(conn: AsyncConnection) -> tuple[list[str], list[list[Any]], bool, dict[str, str]]: cursor = await conn.stream(text(statement)) columns = list(cursor.keys()) rows: list[list[Any]] = [] + seen = _TypeTally(len(columns)) truncated = False size = 0 async for row in cursor: # 逐行累加,边收边判上限:一次性 fetchall 一个亿级表就晚了 + seen.add(row) values = [_jsonable(v) for v in row] rows.append(values) size += len(json.dumps(values, ensure_ascii=False, default=str)) if len(rows) >= limits.max_rows or size >= limits.max_bytes: truncated = True break - return columns, rows, truncated + return columns, rows, truncated, seen.types(columns) - columns, rows, truncated = await _bounded( + columns, rows, truncated, types = await _bounded( engine.connect, kind, limits, f"查询超过 {_seconds(limits.timeout_seconds)}s 被中断。加上 WHERE 条件或 LIMIT 缩小范围。", _read, @@ -505,9 +538,55 @@ async def run_query( truncated=truncated, elapsed_ms=int((time.perf_counter() - started) * 1000), sql=statement, + column_types=types, ) +class _TypeTally: + """边读边记每列见过的值类型。只看驱动交回的原始值:_jsonable 之后 Decimal 就成了字符串, + 小数位为 0 的 DECIMAL("45678")和文本列的 "2026" 再也分不开。""" + + def __init__(self, width: int) -> None: + self._kinds: list[set[str]] = [set() for _ in range(width)] + + def add(self, row: Any) -> None: + for kinds, value in zip(self._kinds, row): + if value is not None: + kinds.add(_value_kind(value)) + + def types(self, columns: list[str]) -> dict[str, str]: + return {str(name): kind for name, kinds in zip(columns, self._kinds) + if len(kinds) == 1 and (kind := next(iter(kinds))) != "other"} + + +def _value_kind(value: Any) -> str: + from datetime import date, datetime, time as dtime + from decimal import Decimal + + if isinstance(value, bool): + return "boolean" + if isinstance(value, (int, float, Decimal)): + return "number" + if isinstance(value, datetime): + return "datetime" + if isinstance(value, date): + return "date" + if isinstance(value, dtime): + return "time" + return "text" if isinstance(value, str) else "other" + + +def column_types(columns: list[str], rows: list[Any]) -> dict[str, str]: + """一批原始行里每列的类型:number / text / date / datetime / time / boolean。 + + 全是空值、类型混着(同一列里有数也有文本)的列不记——拿不准就不说,下游照旧按值猜。 + """ + tally = _TypeTally(len(columns)) + for row in rows: + tally.add(row) + return tally.types(columns) + + async def _run_write( engine: AsyncEngine, kind: str, statement: str, limits: QueryLimits, started: float ) -> QueryResult: diff --git a/backend/app/db/base.py b/backend/app/db/base.py index 5bd52f7a..75254db7 100644 --- a/backend/app/db/base.py +++ b/backend/app/db/base.py @@ -150,6 +150,8 @@ _COLUMN_MIGRATIONS: list[tuple[str, str, str]] = [ ("workflows", "published_version", "INTEGER"), ("workflows", "published_by", "VARCHAR(100)"), ("workflow_versions", "graph_hash", "VARCHAR(64)"), + # 存量版本回填成 NULL:不知道当时按哪一档发布的,正式运行照旧看工作流的 status + ("workflow_versions", "level", "VARCHAR(20)"), ("approvals", "resolved_by", "VARCHAR(100)"), # 存量向量没有这两列,回填成空串 / 0 —— 正好表示"不知道是谁建的", # 检索侧会把它们当成对不上当前 embedder 处理 diff --git a/backend/app/db/models.py b/backend/app/db/models.py index 960ef647..20bdddb9 100644 --- a/backend/app/db/models.py +++ b/backend/app/db/models.py @@ -144,6 +144,9 @@ class WorkflowVersion(Base, TimestampMixin): note: Mapped[str] = mapped_column(Text, default="") # 图内容的 sha256 指纹(剔除 viewport)。formal run 钉住的就是它。 graph_hash: Mapped[str | None] = mapped_column(String(64), default=None) + # 这一版按哪一档发布的:published | governed。没发布过、或者是加这一列之前发布的为空。 + # 正式运行按不按受管出具看它,而不是工作流的 status——status 说的是当前画布,改一笔就退回 draft + level: Mapped[str | None] = mapped_column(String(20), default=None) workflow: Mapped[Workflow] = relationship(back_populates="versions") diff --git a/backend/app/engine/autofix.py b/backend/app/engine/autofix.py new file mode 100644 index 00000000..f6453c84 --- /dev/null +++ b/backend/app/engine/autofix.py @@ -0,0 +1,762 @@ +"""发布前自动修复。 + +发布门禁拦下的问题,以前只能自己动手、或者打开 Copilot 描述一遍。这里把「答案唯一、不降低 +要求」的那些做成确定性的修复,一律先给预览,人确认后前端走现有的保存接口: + +- auto:答案唯一。出口上游恰好一张口径卡、审批从「全部自动放行」改成「仅危险工具需要审批」… +- choice:要人拿主意。required 选哪几个指标、多张口径卡里选哪几张——列候选,不替人选, + 也不给「默认全选」 +- assist:结构性的(协作团队节点、多个出口…),交给 Copilot,产出同样只是预览 + +任何修复都不能降低要求(forbidden_changes);应用后重跑 validate 和门禁,错误必须变少、 +不能冒出新的错误(judge),做不到的修复丢弃并写明原因。 + +本模块是纯函数:库里的东西(子工作流当前的发布版本、钉在别处的口径卡有哪些指标)由调用方 +查好传进来。改图直接改原始的图字典,只动修复涉及的那几个字段:坐标、连线 id、视口这些 +前端的东西原样留着,存回去不会丢。 +""" +from __future__ import annotations + +import copy +from typing import Any, Callable + +from pydantic import BaseModel + +from app.engine.governance import publish_issues +from app.engine.schema import GraphNode, GraphSpec, NodeType, _ancestors + +#: 被选中的修复按这个顺序应用:先补契约骨架,再补契约里的各项,最后是节点上的开关。 +#: 前面的修复可能改变后面的候选(metrics_from 决定 required 从哪几张卡里挑) +_ORDER = ( + "governed.no_contract", "contract.metrics_from_missing", "contract.metrics_from_invalid", + "contract.report_from_missing", "contract.report_from_invalid", "report.metrics_from_invalid", + "contract.required_missing", "contract.strict_off", "governed.agent_approval_never", + "governed.default_approval_never", "governed.subgraph_unpinned", "governed.supervisor", "contract.not_object", +) + +#: 修复落在全图上、不落在任何节点上的问题:问题各挂在跟随全图默认的那几个节点上(点一条能定位), +#: 修复只有一条(id 是 code:graph),改的是图级 defaults,一改几个节点一起好 +_GRAPH_FIXES = frozenset({"governed.default_approval_never"}) + +#: 除了 error,这几条警告也给修复(受管档位下)。已发布档位的门禁只给警告,不借「一键修复」 +#: 替人把审批、strict 这些往受管的要求上拧 +_WARNING_FIXES = {"governed": frozenset({"contract.strict_off"})} + +ASSIST_ONLY = "这一处是结构性的问题,要交给 Copilot:请求里带上 assist: true" +STALE = "这处问题现在已经没有了(修复 id 过期),重新检查一次" +SOLVED = "前面的修复已经一并解决了这一处" +NEED_CHOICE = "这一处要你来选:{label}" + +_TEXT_NODES = (NodeType.LLM, NodeType.AGENT) +#: 修复和 Copilot 兜底只许改节点配置、补节点和连线。其余的图操作(删节点、删连线)和 +#: 任何不认识的操作(发布、改级别…)一律当降低要求处理 +_GRAPH_OPS = frozenset({"add_node", "update_node", "add_edge"}) + + +# -------------------------------------------------------------------------- +# 给出修复 +# -------------------------------------------------------------------------- + + +class _Ctx: + def __init__(self, spec: GraphSpec, versions: dict[str, Any] | None, cards: dict[str, Any] | None) -> None: + self.spec = spec + self.nodes = spec.node_map() + self.versions = versions or {} + self.cards = cards or {} + + def upstream(self, node_id: str, *types: NodeType) -> list[GraphNode]: + """这个节点上游的某几类节点,按画布上的节点顺序。""" + above = _ancestors(self.spec, node_id) + return [n for n in self.spec.nodes if n.id in above and n.type in types] + + def metrics_of(self, card: GraphNode) -> list[dict[str, Any]]: + """一张口径卡定义的指标。钉在别处(caliber_from)的卡本地没写,用调用方从那一版里查出来的。""" + local = [d for d in card.config.get("metrics") or [] if isinstance(d, dict) and d.get("id")] + if local and not isinstance(card.config.get("caliber_from"), dict): + return local + return [d for d in self.cards.get(card.id) or [] if isinstance(d, dict) and d.get("id")] + + +def _title(node: GraphNode | None) -> str: + return node.title if node is not None else "" + + +def _auto(label: str, field: str, before: Any, after: Any) -> dict[str, Any]: + return {"kind": "auto", "label": label, "field": field, + "preview": {"field": field, "before": copy.deepcopy(before), "after": copy.deepcopy(after)}, + "_set": {field: after}} + + +def _choice(label: str, field: str, options: list[dict[str, Any]], *, multiple: bool = False, + default: Any = None, **extra: Any) -> dict[str, Any]: + out = {"kind": "choice", "label": label, "field": field, "options": options, "multiple": multiple, **extra} + if default is not None: + out["default"] = default + return out + + +def _assist(label: str, field: str | None = None) -> dict[str, Any]: + # 界面上这类修复的按钮就叫「交给 Copilot」,label 只写要它做什么、或者为什么确定性修复做不了 + return {"kind": "assist", "label": label, "field": field} + + +def _node_options(nodes: list[GraphNode]) -> list[dict[str, Any]]: + from app.engine.schema import type_label + + return [{"value": n.id, "label": f"「{n.title}」", "hint": type_label(n.type)} for n in nodes] + + +def _contract(node: GraphNode) -> dict[str, Any]: + contract = node.config.get("contract") + return contract if isinstance(contract, dict) else {} + + +def _as_list(value: Any) -> list[Any]: + if value in (None, ""): + return [] + return [value] if isinstance(value, str) else list(value) if isinstance(value, list) else [value] + + +def _cards_choice(ctx: _Ctx, node: GraphNode, before: Any, field: str) -> dict[str, Any]: + cards = ctx.upstream(node.id, NodeType.METRICS) + if len(cards) == 1: + return _auto(f"出具契约的指标来自上游唯一的口径卡「{cards[0].title}」(metrics_from)", field, before, + [cards[0].id]) + if cards: + return _choice("选出具契约的指标来自哪几张口径卡(metrics_from)", field, _node_options(cards), + multiple=True) + return _assist("出口上游没有口径卡,出具契约的 metrics_from 无处可指", field) + + +def _plan_metrics_from_missing(ctx: _Ctx, issue: dict[str, Any], node: GraphNode | None) -> dict[str, Any] | None: + if node is None: + return None + return _cards_choice(ctx, node, _contract(node).get("metrics_from"), "contract.metrics_from") + + +def _plan_metrics_from_invalid(ctx: _Ctx, issue: dict[str, Any], node: GraphNode | None) -> dict[str, Any] | None: + if node is None: + return None + before = _contract(node).get("metrics_from") + keep = [s for s in _as_list(before) if isinstance(s, str) and getattr(ctx.nodes.get(s), "type", None) + == NodeType.METRICS] + if keep: + bad = [str(s) for s in _as_list(before) if s not in keep and str(s).strip()] + return _auto(f"出具契约的 metrics_from 去掉不存在的 {'、'.join(bad) or '空项'}", "contract.metrics_from", + before, keep) + return _cards_choice(ctx, node, before, "contract.metrics_from") + + +def _reports_choice(ctx: _Ctx, node: GraphNode, before: Any, *, narrative: bool) -> dict[str, Any]: + field = "contract.report_from" + reports = ctx.upstream(node.id, NodeType.REPORT) + if len(reports) == 1: + return _auto(f"出具契约核对上游唯一的「报告撰写」节点「{reports[0].title}」的文档(report_from)", field, + before, reports[0].id) + if reports: + return _choice("选出具契约核对哪个「报告撰写」节点的文档(report_from)", field, _node_options(reports)) + writers = ctx.upstream(node.id, *_TEXT_NODES) if narrative else [] + if writers: + # 没有报告撰写节点时退回旧的叙述模式:数字按叙述回指口径卡。只有一个候选也要人确认—— + # 它是不是那段该核对的叙述,只有作者知道 + options = [{"value": f"{{{{ nodes.{n.id}.text }}}}", "label": f"「{n.title}」写的文字", + "hint": f"narrative = {{{{ nodes.{n.id}.text }}}}"} for n in writers] + return _choice("选出具契约要核对的叙述来自哪个节点(narrative)", "contract.narrative", options, + default=options[0]["value"] if len(options) == 1 else None) + return _assist("出口上游没有「报告撰写」节点,也没有写叙述的模型节点", field) + + +def _plan_report_from_missing(ctx: _Ctx, issue: dict[str, Any], node: GraphNode | None) -> dict[str, Any] | None: + if node is None: + return None + return _reports_choice(ctx, node, _contract(node).get("report_from"), narrative=True) + + +def _plan_report_from_invalid(ctx: _Ctx, issue: dict[str, Any], node: GraphNode | None) -> dict[str, Any] | None: + if node is None: + return None + return _reports_choice(ctx, node, _contract(node).get("report_from"), narrative=False) + + +def _plan_report_metrics_from(ctx: _Ctx, issue: dict[str, Any], node: GraphNode | None) -> dict[str, Any] | None: + if node is None: + return None + before = node.config.get("metrics_from") + above = _ancestors(ctx.spec, node.id) + keep = [s for s in _as_list(before) if isinstance(s, str) and s in above + and getattr(ctx.nodes.get(s), "type", None) == NodeType.METRICS] + cards = ctx.upstream(node.id, NodeType.METRICS) + added = cards[0] if len(cards) == 1 and cards[0].id not in keep else None + after = [*keep, *([added.id] if added else [])] + bad = [str(s) for s in _as_list(before) if s not in keep and str(s).strip()] + label = f"「{node.title}」的 metrics_from " + (f"去掉无效的 {'、'.join(bad)}" if bad else "改成口径卡节点的 id 列表") + return _auto(label + (f",补上上游唯一的口径卡「{added.title}」" if added else ""), "metrics_from", before, after) + + +def _metric_options(ctx: _Ctx, cards: list[GraphNode]) -> list[dict[str, Any]]: + options: dict[str, dict[str, Any]] = {} + for card in cards: + for d in ctx.metrics_of(card): + mid = str(d["id"]) + name = str(d.get("name") or mid) + unit = str(d.get("unit") or "") + options.setdefault(mid, {"value": mid, "label": name if name == mid else f"{name}({mid})", + "hint": f"口径卡「{card.title}」" + (f" · {unit}" if unit else "")}) + return list(options.values()) + + +def _plan_required(ctx: _Ctx, issue: dict[str, Any], node: GraphNode | None) -> dict[str, Any] | None: + if node is None: + return None + named = [ctx.nodes[s] for s in _as_list(_contract(node).get("metrics_from")) + if isinstance(s, str) and getattr(ctx.nodes.get(s), "type", None) == NodeType.METRICS] + options = _metric_options(ctx, named or ctx.upstream(node.id, NodeType.METRICS)) + if not options: + return _assist("列不出口径卡里的指标,没法给出 required 的候选", "contract.required") + # 不给默认:哪些指标缺了就不予出具,是作者的决定 + return _choice("选受管出具必需的指标(required):缺了其中任何一个就不予出具", "contract.required", options, + multiple=True) + + +def _plan_strict(ctx: _Ctx, issue: dict[str, Any], node: GraphNode | None) -> dict[str, Any] | None: + if node is None: + return None + return _auto("把出具契约设为 strict:没有出处的数字直接拦下,而不是只降档", "contract.strict", + _contract(node).get("strict"), True) + + +def _plan_approval(ctx: _Ctx, issue: dict[str, Any], node: GraphNode | None) -> dict[str, Any] | None: + if node is None: + return None + return _auto(f"把「{node.title}」的审批策略改成「仅危险工具需要审批」", "approval", node.config.get("approval"), + "dangerous") + + +def _plan_default_approval(ctx: _Ctx, issue: dict[str, Any], node: GraphNode | None) -> dict[str, Any] | None: + followers = [n.title for n in ctx.spec.nodes if n.type == NodeType.AGENT and n.config.get("tools") + and n.config.get("approval") in (None, "")] + who = "、".join(f"「{t}」" for t in followers[:3]) + (f" 等 {len(followers)} 个节点" if len(followers) > 3 else "") + return _auto(f"把全图默认的审批策略改成「仅危险工具需要审批」({who or '跟随它的节点'}一并生效)", + "defaults.approval", ctx.spec.defaults.get("approval"), "dangerous") + + +def _plan_subgraph(ctx: _Ctx, issue: dict[str, Any], node: GraphNode | None) -> dict[str, Any] | None: + if node is None: + return None + wf = node.config.get("workflow_id") + version = ctx.versions.get(wf) if wf else None + if isinstance(version, int) and not isinstance(version, bool) and version > 0: + return _auto(f"把「{node.title}」钉到上游当前的发布版本 v{version}", "workflow_version", + node.config.get("workflow_version"), version) + # 上游没发布过:不替人挑一个草稿版本钉上去 + return _assist(f"「{node.title}」嵌套的工作流还没有发布版本,没法替你钉:先发布它,或者请 Copilot 看看", + "workflow_version") + + +def _plan_no_contract(ctx: _Ctx, issue: dict[str, Any], node: GraphNode | None) -> dict[str, Any] | None: + if node is None: + return _assist("出口不止一个(或者没有出口),该给哪个出口写契约说不准") + cards = ctx.upstream(node.id, NodeType.METRICS) + reports = ctx.upstream(node.id, NodeType.REPORT) + if len(cards) != 1 or len(reports) != 1: + return _assist(f"「{node.title}」上游的口径卡、报告撰写节点不是恰好各一个,契约该指向哪个说不准", "contract") + options = _metric_options(ctx, cards) + if not options: + return _assist(f"列不出口径卡「{cards[0].title}」里的指标,没法给出 required 的候选", "contract") + return _choice(f"给「{node.title}」生成出具契约:核对「{reports[0].title}」的文档,指标来自「{cards[0].title}」," + "再选出必需的指标(required)", "contract", options, multiple=True, + _skeleton={"report_from": reports[0].id, "metrics_from": [cards[0].id], "strict": True}) + + +def _plan_supervisor(ctx: _Ctx, issue: dict[str, Any], node: GraphNode | None) -> dict[str, Any] | None: + # 修复不许删节点、不许覆盖或换类型,Copilot 替换不了它,只能拟一个拆分方案交给人 + return _assist(f"「{_title(node)}」要换成固定编排的 Agent 或「模型调用」节点:修复不能删节点," + "Copilot 只会给出拆分建议,替换要你来做") + + +def _plan_not_object(ctx: _Ctx, issue: dict[str, Any], node: GraphNode | None) -> dict[str, Any] | None: + return _assist(f"「{_title(node)}」的出具契约不是一个 JSON 对象", "contract") + + +_PLANNERS: dict[str, Callable[[_Ctx, dict[str, Any], GraphNode | None], dict[str, Any] | None]] = { + "governed.agent_approval_never": _plan_approval, + "governed.default_approval_never": _plan_default_approval, + "governed.subgraph_unpinned": _plan_subgraph, + "governed.supervisor": _plan_supervisor, + "governed.no_contract": _plan_no_contract, + "contract.not_object": _plan_not_object, + "contract.metrics_from_missing": _plan_metrics_from_missing, + "contract.metrics_from_invalid": _plan_metrics_from_invalid, + "contract.report_from_missing": _plan_report_from_missing, + "contract.report_from_invalid": _plan_report_from_invalid, + "contract.required_missing": _plan_required, + "contract.strict_off": _plan_strict, + "report.metrics_from_invalid": _plan_report_metrics_from, +} + + +def _as_dict(issue: Any) -> dict[str, Any]: + return issue.model_dump() if isinstance(issue, BaseModel) else dict(issue) + + +def _target(ctx: _Ctx, issue: dict[str, Any]) -> str | None: + """修复落在哪个节点上。图级的「没有带契约的出口」落在唯一的那个出口上;改全图默认的不落在节点上。""" + if issue.get("code") in _GRAPH_FIXES: + return None + if issue.get("node_id"): + return str(issue["node_id"]) + if issue.get("code") == "governed.no_contract": + outputs = [n for n in ctx.spec.nodes if n.type == NodeType.OUTPUT] + return outputs[0].id if len(outputs) == 1 else None + return None + + +def plan_fixes(spec: GraphSpec, issues: list[Any], *, level: str, versions: dict[str, Any] | None = None, + cards: dict[str, list[dict[str, Any]]] | None = None) -> list[dict[str, Any]]: + """这批问题能怎么修:Fix 列表,按问题出现的顺序,同一处只出一条。 + + versions:子工作流 id → 它当前的发布版本号(没发布过为 None)。 + cards:钉在别处(caliber_from)的口径卡节点 id → 那一版里的指标定义,用来列 required 的候选。 + 返回的 Fix 里以 _ 开头的键是应用时要用的内部信息,交给前端前用 public() 去掉。 + """ + ctx = _Ctx(spec, versions, cards) + warned = _WARNING_FIXES.get(level, frozenset()) + out: dict[str, dict[str, Any]] = {} + for issue in map(_as_dict, issues): + code = issue.get("code") + planner = _PLANNERS.get(code or "") + if planner is None or (issue.get("level") != "error" and code not in warned): + continue + target = _target(ctx, issue) + fid = f"{code}:{target or 'graph'}" + if fid in out: + continue + fix = planner(ctx, issue, ctx.nodes.get(target) if target else None) + if fix: + out[fid] = {"id": fid, "code": code, "node_id": target, **fix} + return list(out.values()) + + +def public(fix: dict[str, Any]) -> dict[str, Any]: + return {k: v for k, v in fix.items() if not k.startswith("_")} + + +def annotate(issues: list[Any], fixes: list[dict[str, Any]]) -> list[dict[str, Any]]: + """问题的字典形状,带上对应的修复 id(fix)。validate 和门禁报的同一处问题指向同一个修复。 + + 先按(编号、节点)认;认不出时,图级问题(没有节点)认同一编号的第一条修复,图级修复(不落在 + 节点上,比如改全图默认)认同一编号的每一条问题。前端 canvas/issues.fixFor 是同一个退路。 + """ + by_node = {(f["code"], f["node_id"]): f["id"] for f in fixes} + by_code: dict[str, str] = {} + by_graph: dict[str, str] = {} + for f in fixes: + by_code.setdefault(f["code"], f["id"]) + if f["node_id"] is None: + by_graph.setdefault(f["code"], f["id"]) + out = [] + for issue in map(_as_dict, issues): + code = issue.get("code") + fid = by_node.get((code, issue.get("node_id"))) if code else None + if fid is None and code: + fid = by_code.get(code) if issue.get("node_id") is None else by_graph.get(code) + out.append({**issue, "fix": fid}) + return out + + +def check(spec: GraphSpec, *, level: str, versions: dict[str, Any] | None = None, + cards: dict[str, list[dict[str, Any]]] | None = None) -> dict[str, Any]: + """发布前检查:和真正发布同一套口径的问题,以及能怎么修。只读。""" + issues = publish_issues(spec, level=level) + fixes = plan_fixes(spec, issues, level=level, versions=versions, cards=cards) + return {"level": level, "ok": not any(i.level == "error" for i in issues), + "issues": annotate(issues, fixes), "fixes": [public(f) for f in fixes]} + + +# -------------------------------------------------------------------------- +# 应用修复 +# -------------------------------------------------------------------------- + + +def _node(graph: dict[str, Any], node_id: Any) -> dict[str, Any] | None: + return next((n for n in graph.get("nodes") or [] if isinstance(n, dict) and n.get("id") == node_id), None) + + +def _config(node: dict[str, Any]) -> dict[str, Any]: + data = node.setdefault("data", {}) + if not isinstance(data.get("config"), dict): + data["config"] = {} + return data["config"] + + +def _write(config: dict[str, Any], path: str, value: Any) -> Any: + """按 a.b 写进配置,返回原值。中间缺的层补成对象。""" + *heads, last = path.split(".") + for key in heads: + if not isinstance(config.get(key), dict): + config[key] = {} + config = config[key] + before = copy.deepcopy(config.get(last)) + config[last] = copy.deepcopy(value) + return before + + +def _edit(graph: dict[str, Any], fix: dict[str, Any], value: Any) -> list[tuple[str, str, Any, Any]]: + """把一个修复写进图(原地改)。返回 [(节点 id, 字段, 原值, 新值)]。""" + node = _node(graph, fix["node_id"]) + if node is None: + return [] + if fix["kind"] == "auto": + writes = dict(fix.get("_set") or {}) + elif "_skeleton" in fix: + writes = {"contract": {**fix["_skeleton"], "required": value}} + else: + writes = {fix["field"]: value} + config = _config(node) + return [(str(node["id"]), path, _write(config, path, after), copy.deepcopy(after)) + for path, after in writes.items()] + + +def _edit_defaults(graph: dict[str, Any], fix: dict[str, Any], value: Any) -> list[tuple[str | None, str, Any, Any]]: + """改全图默认(graph.defaults):不落在任何节点上,节点 id 记 None,字段写全路径 defaults.x。""" + if not isinstance(graph.get("defaults"), dict): + graph["defaults"] = {} + return [(None, path, _write(graph["defaults"], path.split(".", 1)[1], after), copy.deepcopy(after)) + for path, after in (fix.get("_set") or {}).items() if path.startswith("defaults.")] + + +#: 每类问题怎么改图。大多是同一种「按节点字段写值」,改全图默认的另走一格;留着这张表是为了哪天 +#: 某类修复要特殊处理时只换它自己的那一格(测试里也借它模拟一条出错的修复规则) +_EDITORS: dict[str, Callable[[dict[str, Any], dict[str, Any], Any], list[tuple[str | None, str, Any, Any]]]] = { + code: (_edit_defaults if code in _GRAPH_FIXES else _edit) for code in _PLANNERS +} + + +def _chosen(fix: dict[str, Any], choices: dict[str, Any]) -> tuple[Any, str]: + """choice 类修复里人选的值,按候选校验。返回 (值, 不合格的原因)。""" + if fix["kind"] != "choice": + return None, "" + if fix["id"] not in choices: + return None, NEED_CHOICE.format(label=fix["label"]) + value = choices[fix["id"]] + allowed = [o["value"] for o in fix.get("options") or []] + if fix.get("multiple"): + if not isinstance(value, list) or not value: + return None, f"至少选一个:{fix['label']}" + if any(v not in allowed for v in value): + return None, f"选的 {'、'.join(str(v) for v in value if v not in allowed)} 不在候选里" + return [v for v in allowed if v in value], "" + if value not in allowed: + return None, f"选的 {value!r} 不在候选里" + return value, "" + + +def _op(graph: dict[str, Any], node_id: str | None, edits: list[tuple[str | None, str, Any, Any]]) -> dict[str, Any]: + """一次修复落成的操作。落在节点上的是 update_node,形状同 Copilot 的操作流:config 只写改过的 + 顶层字段(整份新值);改全图默认的是 update_defaults,defaults 写改过的那几项。""" + if node_id is None: + defaults = graph.get("defaults") if isinstance(graph.get("defaults"), dict) else {} + keys = dict.fromkeys(path.split(".", 1)[1] for _, path, _, _ in edits if path.startswith("defaults.")) + return {"op": "update_defaults", "defaults": {k: copy.deepcopy(defaults.get(k)) for k in keys}} + config = _config(_node(graph, node_id) or {}) + tops = dict.fromkeys(path.split(".")[0] for _, path, _, _ in edits) + return {"op": "update_node", "id": node_id, "config": {k: copy.deepcopy(config.get(k)) for k in tops}} + + +def apply_fixes(graph: dict[str, Any] | GraphSpec, fix_ids: list[str], choices: dict[str, Any] | None = None, *, + level: str, versions: dict[str, Any] | None = None, + cards: dict[str, list[dict[str, Any]]] | None = None) -> dict[str, Any]: + """在副本上逐个应用修复,每一个都复核:降低要求、错误没变少或者冒出新错误的,丢弃并写明原因。 + + 返回 {graph, changes, ops, applied, rejected, remaining, fixes, ok}。graph 只是预览,不落库。 + """ + raw = graph.model_dump(mode="json") if isinstance(graph, GraphSpec) else graph + current = copy.deepcopy(raw) + spec = GraphSpec.model_validate(current) + issues = publish_issues(spec, level=level) + plan = plan_fixes(spec, issues, level=level, versions=versions, cards=cards) + known = {f["id"] for f in plan} + rank = {code: i for i, code in enumerate(_ORDER)} + wanted = sorted(dict.fromkeys(fix_ids), key=lambda fid: rank.get(fid.split(":")[0], len(rank))) + applied: list[str] = [] + rejected: list[dict[str, str]] = [] + changes: list[dict[str, Any]] = [] + ops: list[dict[str, Any]] = [] + for fid in wanted: + fix = next((f for f in plan if f["id"] == fid), None) + if fix is None: + rejected.append({"fix_id": fid, "reason": SOLVED if fid in known else STALE}) + continue + if fix["kind"] == "assist": + rejected.append({"fix_id": fid, "reason": ASSIST_ONLY}) + continue + value, why = _chosen(fix, choices or {}) + if why: + rejected.append({"fix_id": fid, "reason": why}) + continue + trial = copy.deepcopy(current) + edits = _EDITORS.get(fix["code"], _edit)(trial, fix, value) + try: + trial_spec = GraphSpec.model_validate(trial) + except Exception: # noqa: BLE001 - 修复规则把图改坏了:丢弃,不交给人一张存不回去的图 + rejected.append({"fix_id": fid, "reason": "改完的图结构读不懂,这条修复已丢弃"}) + continue + trial_issues = publish_issues(trial_spec, level=level) + why = ";".join(forbidden_changes(current, trial)) or judge( + issues, trial_issues, target=(fix["code"], fix["node_id"])) + if why: + rejected.append({"fix_id": fid, "reason": why}) + continue + current, spec, issues = trial, trial_spec, trial_issues + applied.append(fid) + titles = spec.node_map() + changes += [{"fix_id": fid, "node_id": nid, "node_title": _title(titles.get(nid)), "field": path, + "before": before, "after": after, "label": fix["label"]} for nid, path, before, after in edits] + if edits: + ops.append(_op(current, fix["node_id"], edits)) + plan = plan_fixes(spec, issues, level=level, versions=versions, cards=cards) + return {"graph": current, "changes": changes, "ops": ops, "applied": applied, "rejected": rejected, + "remaining": annotate(issues, plan), "fixes": [public(f) for f in plan], + "ok": not any(i.level == "error" for i in issues)} + + +# -------------------------------------------------------------------------- +# 复核:修完必须更好,不许降低要求 +# -------------------------------------------------------------------------- + + +def _sig(issue: dict[str, Any]) -> tuple[Any, ...]: + """同一处问题的认法。有编号的按(级别、编号、节点、连线、字段)认:文案里带节点标题,改个名 + 文案就变了,那还是同一处问题,不是冒出来的新问题。没编号的只能连文案一起比。""" + head = (issue.get("level"), issue.get("code"), issue.get("node_id"), issue.get("edge_id"), issue.get("field")) + return head if issue.get("code") else (*head, issue.get("message")) + + +def judge(before: list[Any], after: list[Any], *, target: tuple[str, str | None] | None = None) -> str | None: + """改完是不是更好了:不能冒出新的 error,error 不能变多;要修的那一处必须没了。 + + target 是 (问题编号, 节点 id):修的是一条警告(strict)时,error 不增加、那条警告消失就算更好; + 修的是 error,或者不指定 target(Copilot 兜底),error 必须变少。返回 None 表示更好,否则是原因。 + """ + b = [_as_dict(i) for i in before] + a = [_as_dict(i) for i in after] + b_err = [i for i in b if i.get("level") == "error"] + a_err = [i for i in a if i.get("level") == "error"] + seen = {_sig(i) for i in b_err} + new = [i for i in a_err if _sig(i) not in seen] + if new: + return f"改完会冒出新的问题:{new[0].get('message')}" + if len(a_err) > len(b_err): + return f"改完错误反而变多了({len(b_err)} → {len(a_err)})" + if target is not None: + code, node_id = target + + def hit(i: dict[str, Any]) -> bool: + # 图级修复(node_id 为 None,改全图默认)管的是这个编号下挂在各个节点上的每一条 + return i.get("code") == code and (node_id is None or i.get("node_id") in (node_id, None)) + + still = [i for i in a if hit(i)] + if still: + return f"改完这处问题还在:{still[0].get('message')}" + if not any(i.get("level") == "error" for i in b if hit(i)): + return None + if len(a_err) >= len(b_err): + return "改完错误没有变少" + return None + + +def _nodes_of(graph: dict[str, Any]) -> dict[str, dict[str, Any]]: + return {str(n.get("id")): n for n in graph.get("nodes") or [] if isinstance(n, dict) and n.get("id")} + + +def _edge_key(edge: dict[str, Any]) -> tuple[str, str, str]: + handle = edge.get("sourceHandle") + return (str(edge.get("source")), str(edge.get("target")), + "" if handle in (None, "out", "source") else str(handle)) + + +def _cfg(node: dict[str, Any]) -> dict[str, Any]: + config = (node.get("data") or {}).get("config") + return config if isinstance(config, dict) else {} + + +def _name(node: dict[str, Any]) -> str: + return str((node.get("data") or {}).get("label") or node.get("id")) + + +#: 审批策略从严到宽:每次调用都审批 > 仅危险工具需要审批 > 全部自动放行 +_APPROVAL_RANK = {"never": 0, "dangerous": 1, "always": 2} +_APPROVAL_LABEL = {"never": "全部自动放行", "dangerous": "仅危险工具需要审批", "always": "每次调用都审批"} + + +def _approval(value: Any) -> str | None: + """审批策略的取值:没写、写 null、写空串都算没写(运行时一样往下回退)。""" + return None if value in (None, "") else str(value) + + +def _approval_word(value: str) -> str: + return f"「{_APPROVAL_LABEL.get(value, value)}」" + + +def _approval_lowered(who: str, prev: str | None, now: str | None, *, inherited: str | None, + fallback: str | None) -> str | None: + """审批策略有没有往宽里改。返回一句原因,没有就是 None。 + + prev / now:改之前、之后写明的值(None 是没写)。没写时运行时往下回退:节点退到全图默认, + 全图默认退到全局设置——全局设置至多是「仅危险工具需要审批」,也可能是「全部自动放行」, + 所以退到全局一律按可能全部放行算。inherited:改之前没写时实际跟随的值(节点跟全图默认; + 全图默认没有可跟的,为 None)。fallback:改之后没写时退到的值(同理)。 + """ + if prev == now: + return None + if now is None: + if prev is None or prev == "never": + return None + if fallback in _APPROVAL_RANK and _APPROVAL_RANK[fallback] >= _APPROVAL_RANK.get(prev, 0): + return None + return (f"去掉了{who}的审批策略(原来是{_approval_word(prev)}),会退回" + + (f"全图默认的{_approval_word(fallback)}" if fallback else "全局设置,可能是「全部自动放行」")) + if now not in _APPROVAL_RANK: + return f"把{who}的审批策略改成了不认识的值 {now!r}" + base = prev if prev is not None else inherited + if base in _APPROVAL_RANK and _APPROVAL_RANK[now] < _APPROVAL_RANK[base]: + was = _approval_word(base) + ("" if prev is not None else "(跟随全图默认)") + return f"把{who}的审批策略从{was}放宽成了{_approval_word(now)}" + return None + + +def _numbers(value: Any) -> list[str]: + return [str(v).strip() for v in _as_list(value) if str(v).strip()] + + +def _contract_loosened(name: str, prior: Any, contract: Any) -> list[str]: + """出具契约里替作者放宽口子的改动:allow_numbers 多出来的数(白名单里的数不用引用也能过), + cells 从没开变成开(受管级别要作者显式声明,不由模型替人打开)。原来没有契约也一样查。""" + if not isinstance(contract, dict): + return [] + prior = prior if isinstance(prior, dict) else {} + out = [] + had = set(_numbers(prior.get("allow_numbers"))) + extra = [n for n in dict.fromkeys(_numbers(contract.get("allow_numbers"))) if n not in had] + if extra: + out.append(f"往「{name}」出具契约的 allow_numbers 里加了 {'、'.join(extra)}(不带出处也能放行的数)") + if contract.get("cells") is True and prior.get("cells") is not True: + out.append(f"替你打开了「{name}」出具契约的 cells(允许报告直接引用查询单元格),这要你自己决定") + return out + + +def forbidden_changes(before: dict[str, Any], after: dict[str, Any], + ops: list[dict[str, Any]] | None = None) -> list[str]: + """降低要求的改动,一条一句。空列表表示没有。确定性修复和 Copilot 兜底过的是同一道检查。 + + 禁止: + - 删节点、删连线;用 add_node 覆盖已有的节点、把节点换成别的类型(等于删了重建) + - 删掉或清空出具契约、删掉 required 里的指标、把 strict 改成 false; + 往 allow_numbers 里加数、替人打开 cells + - 审批策略往宽里改(每次调用都审批 > 仅危险工具需要审批 > 全部自动放行)、删掉写明的审批策略, + 把审批写成(或新加一个)「全部自动放行」;全图默认同理 + - 取消子工作流钉住的版本 + - 改发布级别——级别不在图里,修复接口也从不写工作流的 status,所以只可能以一个不认识的 + 操作出现,按不认识的操作拒掉 + """ + old, new = _nodes_of(before), _nodes_of(after) + reasons: list[str] = [] + for op in ops or []: + kind = op.get("op") + if kind == "remove_node": + reasons.append(f"想删节点「{op.get('id')}」") + elif kind == "remove_edge": + reasons.append(f"想删连线 {op.get('source')} → {op.get('target')}") + elif kind == "add_node": + # Copilot 的改图流程遇到已有的 id 会拿新节点整个盖掉旧的:配置、位置全丢,类型也可能换了 + added = op.get("node") + target = added.get("id") if isinstance(added, dict) else None + if target is not None and str(target) in old: + reasons.append(f"想用新节点覆盖已有的「{_name(old[str(target)])}」(等于删了重建)") + elif kind not in _GRAPH_OPS: + reasons.append(f"用了修复不许用的操作 {kind}(只能改节点配置、补节点和连线)") + reasons += [f"删了节点「{_name(n)}」" for nid, n in old.items() if nid not in new] + kept = {_edge_key(e) for e in after.get("edges") or [] if isinstance(e, dict)} + reasons += [f"删了连线 {s} → {t}" for s, t, _ in + dict.fromkeys(_edge_key(e) for e in before.get("edges") or [] if isinstance(e, dict)) + if (s, t, _) not in kept] + old_default = _approval((before.get("defaults") or {}).get("approval")) + new_default = _approval((after.get("defaults") or {}).get("approval")) + for nid, node in new.items(): + was = old.get(nid) + cfg, prev = _cfg(node), _cfg(was) if was else {} + name = _name(node) + if was is not None and was.get("type") != node.get("type"): + from app.engine.schema import type_label + + reasons.append(f"把「{_name(was)}」从{_type_word(was.get('type'), type_label)}换成了" + f"{_type_word(node.get('type'), type_label)}(等于删了重建)") + approval, prior_approval = _approval(cfg.get("approval")), _approval(prev.get("approval")) + if approval == "never" and prior_approval != "never": + reasons.append(f"把「{name}」的审批策略改成了「全部自动放行」" if was + else f"新加的「{name}」审批策略是「全部自动放行」") + elif why := _approval_lowered(f"「{name}」", prior_approval, approval, inherited=old_default, + fallback=new_default): + reasons.append(why) + contract, prior = cfg.get("contract"), prev.get("contract") + reasons += _contract_loosened(name, prior, contract) + if was is None: + continue + if prev.get("workflow_version") and not cfg.get("workflow_version"): + reasons.append(f"取消了「{name}」钉住的版本") + if not isinstance(prior, dict) or not prior: + continue + if not isinstance(contract, dict) or not contract: + reasons.append(f"删掉或清空了「{name}」的出具契约") + continue + if prior.get("strict") and not contract.get("strict"): + reasons.append(f"把「{name}」出具契约的 strict 改成了 false") + dropped = [m for m in _as_list(prior.get("required")) if m not in _as_list(contract.get("required"))] + if dropped: + reasons.append(f"删掉了「{name}」出具契约 required 里的 {'、'.join(map(str, dropped))}") + if new_default == "never" and old_default != "never": + reasons.append("把全图默认的审批策略改成了「全部自动放行」") + elif why := _approval_lowered("全图默认", old_default, new_default, inherited=None, fallback=None): + reasons.append(why) + return reasons + + +def _type_word(node_type: Any, type_label: Callable[[Any], str]) -> str: + try: + return f"「{type_label(NodeType(node_type))}」" + except ValueError: + return f"「{node_type}」" + + +def diff_changes(before: dict[str, Any], after: dict[str, Any], *, fix_id: str, label: str) -> list[dict[str, Any]]: + """两张图之间给人看的逐项变更(Copilot 兜底用):节点配置按顶层字段比,新加的节点、连线各一条。""" + old, new = _nodes_of(before), _nodes_of(after) + out: list[dict[str, Any]] = [] + for nid, node in new.items(): + was = old.get(nid) + if was is None: + out.append({"fix_id": fix_id, "node_id": nid, "node_title": _name(node), "field": None, "before": None, + "after": {"type": node.get("type"), "config": copy.deepcopy(_cfg(node))}, + "label": f"{label}:新加节点"}) + continue + if was.get("type") != node.get("type"): + # forbidden_changes 现在就拒掉换类型;万一哪天放行,预览里也得看得出节点换了类型 + out.append({"fix_id": fix_id, "node_id": nid, "node_title": _name(node), "field": "type", + "before": was.get("type"), "after": node.get("type"), "label": label}) + if _name(was) != _name(node): + out.append({"fix_id": fix_id, "node_id": nid, "node_title": _name(node), "field": "label", + "before": _name(was), "after": _name(node), "label": label}) + cfg, prev = _cfg(node), _cfg(was) + for key in dict.fromkeys([*prev, *cfg]): + if prev.get(key) != cfg.get(key): + out.append({"fix_id": fix_id, "node_id": nid, "node_title": _name(node), "field": key, + "before": copy.deepcopy(prev.get(key)), "after": copy.deepcopy(cfg.get(key)), + "label": label}) + known = {_edge_key(e) for e in before.get("edges") or [] if isinstance(e, dict)} + for edge in after.get("edges") or []: + if isinstance(edge, dict) and _edge_key(edge) not in known: + out.append({"fix_id": fix_id, "node_id": None, "node_title": "", "field": "edge", "before": None, + "after": {"source": edge.get("source"), "target": edge.get("target"), + "sourceHandle": edge.get("sourceHandle")}, "label": f"{label}:新加连线"}) + return out diff --git a/backend/app/engine/evidence.py b/backend/app/engine/evidence.py index aa3f89a4..1f3daf3d 100644 --- a/backend/app/engine/evidence.py +++ b/backend/app/engine/evidence.py @@ -8,15 +8,20 @@ 整个模块是纯函数(不碰数据库、不调模型),报告节点自查、出口契约复核、证据接口 共用这一份,免得三处各写一套规则、各判各的。 -标记语法(本期支持的四种): +标记语法: - [[m:<指标 id>]] 口径卡指标,渲染成「值+单位」 - [[m:<指标 id>|万]] 换算显示:万 / 亿 / pct / int / .N(小数位),由渲染器完成 - [[i:<输入字段>]] 运行输入 - [[see:,…]] 依据:挂在句末,不渲染 + [[m:<指标 id>]] 口径卡指标,渲染成「值+单位」 + [[m:<指标 id>|万]] 换算显示:万 / 亿 / pct / int / .N(小数位),由渲染器完成 + [[v:Q.r<行>.<列>]] 查询快照里的一格(取回时复验哈希),也能加 |万 这类换算 + [[table:Q cols=a,b rows=0-4]] 整表:系统从快照生成 Markdown 表,每一格都是带出处的片段 + [[i:<输入字段>]] 运行输入 + [[see:,…]] 依据:挂在句末,不渲染 -`[[v:]]` `[[t:]]` `[[c:]]` `[[q:]]` `[[table:]]` 能解析,本期一律判为解析不了, -原因写「这种引用在后续版本支持」。 +`[[t:]]` `[[c:]]` `[[q:]]` 能解析,本期一律判为解析不了,原因写「这种引用在后续版本支持」。 +沙箱代码节点的产出不能直接引用(`[[v:N:calc.x]]`),原因写「沙箱算出来的数要进口径卡」。 + +单元格和整表要读快照:整个模块仍然不碰数据库、不调模型,快照经 loader 取(缺省是 +artifact_store.load,只读文件)。测试和接口可以换成自己的 loader。 偏移一律按 Unicode 码点算(Python 的 str 下标)。前端的 JS 字符串按 UTF-16 计, 碰到码点在 BMP 之外的字符(emoji)要自己换算。 @@ -25,12 +30,16 @@ from __future__ import annotations import bisect +import json import math import re +from collections.abc import Callable from decimal import ROUND_HALF_UP, Context, Decimal from typing import Any, Literal, TypedDict from app.core.artifact_store import canonical_json, content_hash +from app.core.artifact_store import load as load_artifact +from app.engine.expressions import CellError, cell_value, column_kind, locate_cell from app.engine.issuance import extract_numbers, number_allowance DOC_SCHEMA = "agentlab.report/1" @@ -57,6 +66,9 @@ class EvidenceEntry(TypedDict, total=False): caliber: str # metric_set 专有:口径名、版本、指标 id 清单 version: str metrics: list[str] + code_sha: str # node_output(代码节点)专有:实际执行的代码的 sha256、 + role: str # evidence_role(source / compute)、语言 + language: str class Citation(TypedDict, total=False): @@ -140,6 +152,56 @@ def input_eid(field: str, value: Any) -> str: return make_eid("input", content_hash(canonical_json(value)), {"field": field}) +def cell_eid(artifact: str | None, row: int, column: str) -> str: + """查询快照里一格的 eid。报告的 [[v:]]、agent 字段的出处、口径卡的 cell() 都按它算, + 同一格在三处是同一个标识。column 一律是列名(列号先换成列名)。""" + return make_eid("cell", artifact, {"row": row, "column": column}) + + +# -------------------------------------------------------------------------- +# 台账:节点执行器写条目时用的几件小事 +# -------------------------------------------------------------------------- + + +def ledger_enabled(run: Any) -> bool: + """这次运行记不记证据台账(以及二期的其他新行为)。 + + 判断「升级前发起的运行」沿用护栏那次加的快照:agent_limits 为 None 的运行可能正停在 + 某个审批上,节点恢复时整个重放——多一个 task、给模型的消息多一行,checkpoint 里缓存 + 的结果就对错号。这类运行一律走旧逻辑,一个字都不加。 + """ + return getattr(run, "agent_limits", None) is not None + + +def next_exec(state: Any, node_id: str) -> int: + """这个节点这是第几次成功执行(从 1 数,循环里会有多次),按 trail 算。""" + done = sum(1 for t in (state.get("trail") or []) if t.get("node_id") == node_id and "error" not in t) + return done + 1 + + +def query_entry_fields(payload: Any) -> dict[str, Any] | None: + """数据源查询工具交回的 JSON(文本或已解析的对象)→ 台账 query 条目要的字段。 + + 不是一次成功的查询(「查询失败:…」这类原话、别的工具的返回、没落成快照的)返回 None。 + rows 是快照里的行数:单元格按快照里的行号引用。 + """ + data = payload + if isinstance(data, str): + if not data.lstrip().startswith("{"): + return None + try: + data = json.loads(data) + except ValueError: + return None + if not isinstance(data, dict): + return None + artifact, columns, rows = data.get("artifact"), data.get("columns"), data.get("rows") + if not (isinstance(artifact, str) and artifact and isinstance(columns, list) and isinstance(rows, list)): + return None + return {"artifact": artifact, "source": data.get("source"), "columns": [str(c) for c in columns], + "rows": len(rows), "truncated": bool(data.get("truncated"))} + + # -------------------------------------------------------------------------- # 渲染:数值 → 报告里显示的那几个字 # @@ -307,6 +369,29 @@ def render_metric(metric: dict[str, Any], conv: str | None = None) -> str: ) +def render_cell(value: Any, conv: str | None = None, *, kind: str | None = None) -> str: + """查询快照里的一格渲染成字。 + + - 数按千分位,小数位照值本来的样子(最多 10 位、去尾零);DECIMAL 落盘后的数字文本 + ("45678.50",小数位为 0 的 "45678",规则见 expressions.cell_value)按数算,用 Decimal + 渲染,不经 float 丢精度 + - kind 是这一列查询时记下的类型:text 列按文本,VARCHAR 的 "2026" 不渲染成「2,026」; + 老快照没记类型就按值猜 + - NULL 显示「—」:这是快照里的事实(那一格就是空的),不是缺值,更不是 0 + - 文本压成一行,竖线换成 ¦:它常常落在表格里,原样的 | 会多切出一格 + """ + if isinstance(value, str) and cell_value(value, kind) is not value: + try: + return _render_numeric(Decimal(value.strip()), unit="", decimals=None, fmt=DEFAULT_FORMAT, conv=conv) + except RenderError: + raise + except (ArithmeticError, ValueError) as e: + raise RenderError(f"{value!r} 按规则渲染不出来({type(e).__name__})") from e + if isinstance(value, (dict, list)): + value = json.dumps(value, ensure_ascii=False, default=str) + return render_number(value, fmt=DEFAULT_FORMAT, conv=conv).replace("|", "¦") + + # -------------------------------------------------------------------------- # 标记语法 # -------------------------------------------------------------------------- @@ -321,9 +406,18 @@ _MARKER_GUARD = rf"(?=[^\[\]\n]{{0,{MARKER_MAX - 4}}}\]\])" #: [[kind:body]]。body 里不许有方括号和换行——这样一个没闭合的 [[ 最多吞到行尾 MARKER_RE = re.compile( r"\[\[" + _MARKER_GUARD + r"[ \t]*(?P[A-Za-z]+)[ \t]*:(?P[^\[\]\n]*?)\]\]") -SUPPORTED_KINDS = frozenset({"m", "i", "see"}) -LATER_KINDS = frozenset({"v", "t", "c", "q", "table"}) +SUPPORTED_KINDS = frozenset({"m", "i", "see", "v", "table"}) +LATER_KINDS = frozenset({"t", "c", "q"}) LATER_REASON = "这种引用在后续版本支持" +#: 沙箱代码节点的产出不能直接引用:它能做任意计算,那种数要进口径卡、留下代入式 +CODE_REASON = "沙箱算出来的数要进口径卡" +#: 受管级别的正式出具,契约里没写 "cells": true 时复核按这个原因拒掉单元格引用 +CELLS_REASON = "受管出具要在契约里声明 cells" +NODE_FIELD_REASON = "节点字段引用在后续版本支持:数要从查询单元格 [[v:Q1.r0.列]] 或口径卡指标 [[m:…]] 来" +#: 整表:不写 rows 时取前几行、最多几行、不写 cols 时最多几列 +TABLE_DEFAULT_ROWS = 5 +TABLE_MAX_ROWS = 20 +TABLE_MAX_COLS = 12 _ROLE = {"t": "entity", "c": "entity", "q": "quote"} _SEG_KIND = {"t": "entity", "c": "entity", "q": "quote"} @@ -410,6 +504,9 @@ def build_catalog( 同一个指标 id 出现在两张卡里时不登记 m:,只登记 m:<卡的节点 id>.—— 按先来后到默认给一个,正是旧契约错配的那种错。 + + 沙箱代码节点的台账条目登记成 N:<节点 id>(kind node_output,code: True):不编号、不进 + 写作目录,只为了有人写 [[v:N:calc.x]] 时能说清为什么不行。 """ if not metrics_from: metrics_from = None @@ -453,22 +550,34 @@ def build_catalog( for entry in entries: if allow is not None and entry.get("node_id") not in allow: continue + rows = entry.get("rows") + if entry.get("kind") == "node_output" and entry.get("code_sha"): + # 沙箱代码节点:登记下来只为了引用它时能说清为什么不行,不编号、不进写作目录 + node_id = str(entry.get("node_id") or "") + catalog[f"N:{node_id}"] = { + "alias": f"N:{node_id}", "kind": "node_output", "eid": make_eid("node_output", entry.get("artifact"), {}), + "locator": {}, "code": True, + **{k: entry[k] for k in ("node_id", "exec", "artifact", "code_sha", "role", "language") if k in entry}, + "label": f"沙箱代码 {node_id}({entry.get('role') or 'compute'})", + } + continue if entry.get("kind") == "query": queries += 1 alias, kind = f"Q{queries}", "query" + label = f"{entry.get('tool') or entry.get('source') or kind}" + (f" · {rows} 行" if isinstance(rows, int) else "") elif entry.get("kind") == "retrieval": retrievals += 1 alias, kind = f"K{retrievals}", "retrieval" + label = (f"知识库「{entry['source']}」" if entry.get("source") else "知识库检索") \ + + (f" · {rows} 条" if isinstance(rows, int) else "") else: continue - rows = entry.get("rows") catalog[alias] = { "alias": alias, "kind": kind, "eid": make_eid(kind, entry.get("artifact"), {}), "locator": {}, **{k: entry[k] for k in ("node_id", "exec", "artifact", "via", "call_id", "tool", "source", "columns", "rows", "truncated") if k in entry}, - "label": f"{entry.get('tool') or entry.get('source') or kind}" - + (f" · {rows} 行" if isinstance(rows, int) else ""), + "label": label, } for key, value in (inputs or {}).items(): @@ -508,17 +617,246 @@ def _metric_alias(ref: str, catalog: dict[str, Any]) -> tuple[str | None, str]: return None, f"目录里没有指标 {ref}(能用的指标见证据目录)" -def resolve_marker(marker: dict[str, Any], catalog: dict[str, Any]) -> dict[str, Any]: - """行内标记(m / i / v / t / c / q / table)→ Citation,带 rendered(正文里显示的字)。""" +# -------------------------------------------------------------------------- +# 单元格与整表:值一律从查询快照取 +# -------------------------------------------------------------------------- + +#: 取快照的函数:工件 id → 内容。取不到返回 None,哈希对不上抛 ValueError(artifact_store.load 的约定) +Loader = Callable[[str], Any] + + +class _Snapshots: + """这一次解析里取过的查询快照。 + + 一张整表每一格都要读同一份快照,每格都读盘、复验一次哈希不值。缓存只活在这一次 + compose / verify / 流式渲染里:出口复核是另一次调用,照样重新取、重新验。 + """ + + def __init__(self, loader: Loader | None = None) -> None: + self.loader = loader or load_artifact + self.memo: dict[str, tuple[Any, str | None]] = {} + + def get(self, alias: str, artifact: Any) -> tuple[Any, str | None]: + """(快照内容, 取不到的原因)。原因以 alias 开头,能直接当 unresolved 的 reason。""" + key = str(artifact or "") + if key not in self.memo: + self.memo[key] = self._fetch(key) + content, why = self.memo[key] + return content, (f"{alias} {why}" if why else None) + + def _fetch(self, artifact: str) -> tuple[Any, str | None]: + if not artifact: + return None, "没有查询快照" + try: + content = self.loader(artifact) + except ValueError: + return None, "的查询快照和哈希对不上,疑似被改过" + except Exception as e: # noqa: BLE001 - 取不回来就是解析不了,不能让整篇报告崩掉 + return None, f"的查询快照取不回来({type(e).__name__})" + if content is None: + return None, f"的查询快照取不回来(工件 {artifact[:12]}… 不存在)" + if not isinstance(content, dict) or not isinstance(content.get("rows"), list): + return None, "的快照里没有表格数据" + return content, None + + +def _snapshots(loader: Loader | _Snapshots | None) -> _Snapshots: + return loader if isinstance(loader, _Snapshots) else _Snapshots(loader) + + +_CELL_REF = re.compile(r"^(?P[QK]\d+)\.r(?P\d+)\.(?P.+)$") +_NODE_REF = re.compile(r"^N:(?P[^.\s]+)(?:\.(?P.+))?$") + + +def parse_cell_ref(ref: str) -> dict[str, Any] | None: + """Q4.r0.amount → {alias: "Q4", row: 0, column: "amount"};写法不对返回 None。""" + m = _CELL_REF.match((ref or "").strip()) + return {"alias": m.group("alias"), "row": int(m.group("row")), "column": m.group("column")} if m else None + + +def _query_entry(alias: str, catalog: dict[str, Any]) -> tuple[dict[str, Any] | None, str]: + entry = catalog.get(alias) + if entry is None: + return None, f"目录里没有 {alias}(能引用的查询见证据目录)" + if entry.get("kind") != "query": + return None, f"{alias} 不是查询结果,不能按单元格引用" + if entry.get("code"): + return None, CODE_REASON + return entry, "" + + +def _resolve_cell(marker: dict[str, Any], catalog: dict[str, Any], snaps: _Snapshots, + cells_allowed: bool) -> dict[str, Any]: + ref = marker.get("ref") or "" + conv = marker.get("conv") + node = _NODE_REF.match(ref) + if node: + alias = f"N:{node.group('node')}" + entry = catalog.get(alias) or {} + return _unresolved(marker, alias, "node_field", CODE_REASON if entry.get("code") else NODE_FIELD_REASON) + parsed = parse_cell_ref(ref) + if parsed is None: + return _unresolved(marker, ref.split(".")[0], "cell", + "单元格要写成 Q<编号>.r<行>.<列>(行号从 0 数),比如 [[v:Q1.r0.amount]]") + alias = parsed["alias"] + if not cells_allowed: + return _unresolved(marker, alias, "cell", CELLS_REASON) + entry, why = _query_entry(alias, catalog) + if entry is None: + return _unresolved(marker, alias, "cell", why) + snapshot, why = snaps.get(alias, entry.get("artifact")) + if why: + return _unresolved(marker, alias, "cell", why) + try: + raw, column = locate_cell(snapshot, parsed["row"], parsed["column"]) + kind = column_kind(snapshot, column) + rendered = render_cell(raw, conv, kind=kind) + except CellError as e: + return _unresolved(marker, alias, "cell", f"{alias} {e}") + except RenderError as e: + return _unresolved(marker, alias, "cell", str(e)) + locator = {"row": parsed["row"], "column": column} + cite = {"ref": ref, "alias": alias, "locator": locator, "eid": cell_eid(entry.get("artifact"), **locator), + "kind": "cell", "role": "value", "status": "resolved", "value": cell_value(raw, kind), + "rendered": rendered} + if conv: + cite["conv"] = conv + return cite + + +_TABLE_OPTIONS = ("cols", "rows") +_TABLE_ROWS = re.compile(r"^(\d+)(?:-(\d+))?$") +#: 列名里有这些字就写不进 [[v:]] 标记,或者会把表格行切错 +_UNSAFE_COLUMN = re.compile(r"[\[\]\n|]") + + +def _table_plan(marker: dict[str, Any], catalog: dict[str, Any], snaps: _Snapshots, + cells_allowed: bool) -> tuple[str | None, dict[str, Any] | None]: + """整表标记 → (Markdown 表,每格是一个 [[v:]] 标记, None),或者 (None, 解析不了的 Citation)。 + + 表由系统生成,但每一格照样是单元格引用:切块、切片段、复核都走单元格那条路, + 不用为整表另写一套核对规则。 + """ + alias = marker.get("ref") or "" + + def fail(reason: str) -> tuple[None, dict[str, Any]]: + return None, _unresolved(marker, alias, "query", reason) + + if not cells_allowed: + return fail(CELLS_REASON) + _, *rest = (marker.get("body") or "").split() or [""] + odd = [p for p in rest if p.partition("=")[0] not in _TABLE_OPTIONS or "=" not in p] + if odd: + return fail(f"不认识的选项 {odd[0]}:整表只支持 cols=列a,列b 和 rows=0-4") + options = marker.get("options") or {} + entry, why = _query_entry(alias, catalog) + if entry is None: + return fail(why) + snapshot, why = snaps.get(alias, entry.get("artifact")) + if why: + return fail(why) + columns = [str(c) for c in snapshot.get("columns") or []] + rows = snapshot["rows"] + if "cols" in options: + wanted = [c.strip() for c in options["cols"].split(",") if c.strip()] + if not wanted: + return fail("cols= 后面要写列名,用逗号分开") + missing = [c for c in wanted if c not in columns] + if missing: + return fail(f"{alias} 没有列「{missing[0]}」,有:{'、'.join(columns[:12])}") + if len(wanted) > TABLE_MAX_COLS: + return fail(f"整表最多 {TABLE_MAX_COLS} 列,cols= 写了 {len(wanted)} 列") + else: + wanted = columns + if len(wanted) > TABLE_MAX_COLS: + return fail(f"{alias} 有 {len(columns)} 列,整表最多 {TABLE_MAX_COLS} 列:用 cols=列a,列b 挑出要的列") + if not wanted: + return fail(f"{alias} 没有列,列不出表") + if not rows: + return fail(f"{alias} 是空的(0 行),没有可以列的行") + if "rows" in options: + span = _TABLE_ROWS.match(options["rows"].strip()) + first, last = (int(span.group(1)), int(span.group(2) or span.group(1))) if span else (1, 0) + if last < first: + return fail(f"rows 要写成 0-4 这样从小到大的行号范围(从 0 数),写的是 {options['rows']}") + if last - first + 1 > TABLE_MAX_ROWS: + return fail(f"整表最多 {TABLE_MAX_ROWS} 行,rows={options['rows']} 是 {last - first + 1} 行:" + "挑出要的行,或者分成几张表") + if last >= len(rows): + return fail(f"{alias} 只有 {len(rows)} 行,没有第 {last} 行(从 0 数)") + else: + first, last = 0, min(TABLE_DEFAULT_ROWS, len(rows)) - 1 + for c in wanted: + if not c.strip() or c != c.strip() or _UNSAFE_COLUMN.search(c) \ + or len(f"[[v:{alias}.r{last}.{c}]]") > MARKER_MAX: + return fail(f"列名「{_clean(c)[:30]}」写不进表格(空的、首尾有空格、带方括号竖线换行,或者太长):" + "在 SQL 里给它起个别名再查") + lines = ["| " + " | ".join(_clean(c) for c in wanted) + " |", + "| " + " | ".join("---" for _ in wanted) + " |"] + lines += ["| " + " | ".join(f"[[v:{alias}.r{r}.{c}]]" for c in wanted) + " |" for r in range(first, last + 1)] + return "\n".join(lines), None + + +def _expand_tables(text: str, catalog: dict[str, Any], snaps: _Snapshots, cells_allowed: bool, + prev: str = "") -> str: + """把能展开的整表标记换成 Markdown 表(每格一个 [[v:]])。展开不了的原样留着,后面按解析不了处理。 + + 表格要独占几行:标记前面的字不是换行就补一个,后面的字不是换行也补一个。这只取决于 + 紧挨着标记的那两个字——prev 是这段文本之前的那个字(流式渲染时在上一块里)。 + """ + out: list[str] = [] + last = 0 + for m in MARKER_RE.finditer(text): + if m.group("kind") != "table": + continue + body = m.group("body").strip() + table, _ = _table_plan({"kind": "table", "body": body, **_parse_body("table", body)}, catalog, snaps, + cells_allowed) + if table is None: + continue + before = text[m.start() - 1] if m.start() > 0 else prev + after = text[m.end()] if m.end() < len(text) else "" + out.append(text[last:m.start()]) + out.append(("" if before in ("", "\n") else "\n") + table + ("" if after in ("", "\n") else "\n")) + last = m.end() + if not out: + return text + out.append(text[last:]) + return "".join(out) + + +# -------------------------------------------------------------------------- +# 解析一个行内标记 +# -------------------------------------------------------------------------- + + +def resolve_marker(marker: dict[str, Any], catalog: dict[str, Any], *, cells_allowed: bool = True, + loader: Loader | _Snapshots | None = None) -> dict[str, Any]: + """行内标记(m / i / v / t / c / q / table)→ Citation,带 rendered(正文里显示的字)。 + + cells_allowed=False 时单元格和整表一律解析不了,原因是 CELLS_REASON(受管出具没在契约里声明 cells)。 + loader 是取查询快照的函数,缺省 artifact_store.load。 + """ kind = marker["kind"] ref = marker.get("ref") or "" + if kind == "v": + return _resolve_cell(marker, catalog, _snapshots(loader), cells_allowed) + if kind == "table": + snaps = _snapshots(loader) + table, bad = _table_plan(marker, catalog, snaps, cells_allowed) + if bad is not None: + return bad + # 整篇渲染时整表已经按上下文展开过了,走到这里的是复核一个片段:给出不带上下文的样子 + return {"ref": ref, "alias": ref, "locator": {}, "eid": catalog[ref]["eid"], "kind": "query", + "role": "value", "status": "resolved", "rendered": _render(table or "", catalog, snaps, cells_allowed)} if kind in LATER_KINDS: alias = {"t": f"t:{ref}", "c": f"c:{ref}"}.get(kind, ref.split(".")[0]) - ev_kind = {"v": "cell", "t": "table", "c": "column", "q": "quote", "table": "query"}[kind] + ev_kind = {"t": "table", "c": "column", "q": "quote"}[kind] return _unresolved(marker, alias, ev_kind, LATER_REASON) if kind not in ("m", "i"): return _unresolved(marker, f"{kind}:{ref}", kind, - f"不认识的引用类型 {kind}:本期只支持 m(口径卡指标)、i(运行输入)、see(依据)") + f"不认识的引用类型 {kind}:只支持 m(口径卡指标)、v(查询单元格)、table(整表)、" + "i(运行输入)、see(依据)") conv = marker.get("conv") if kind == "m": @@ -591,24 +929,31 @@ def resolve_support(ref: str, catalog: dict[str, Any]) -> dict[str, Any]: "locator": {**(entry.get("locator") or {}), **locator}} -def resolve_ref(ref: str, catalog: dict[str, Any]) -> dict[str, Any]: - """片段上记的 ref(m:gmv|万、i:week)→ Citation。证据接口点开片段时用它重新解析。""" - return resolve_marker(_parse_ref(ref), catalog) +def resolve_ref(ref: str, catalog: dict[str, Any], *, cells_allowed: bool = True, + loader: Loader | _Snapshots | None = None) -> dict[str, Any]: + """片段上记的 ref(m:gmv|万、i:week、v:Q1.r0.amount)→ Citation。证据接口点开片段时用它重新解析。""" + return resolve_marker(_parse_ref(ref), catalog, cells_allowed=cells_allowed, loader=loader) -def render_markers(text: str, catalog: dict[str, Any]) -> str: - """把一段文本里的标记全部换成字:m / i 渲染成值,see 去掉,解析不了的换成占位。""" +def _render(text: str, catalog: dict[str, Any], snaps: _Snapshots, cells_allowed: bool, prev: str = "") -> str: + text = _expand_tables(text, catalog, snaps, cells_allowed, prev) out: list[str] = [] last = 0 for marker in parse_markers(text): out.append(text[last:marker["start"]]) if marker["kind"] != "see": - out.append(resolve_marker(marker, catalog)["rendered"]) + out.append(resolve_marker(marker, catalog, cells_allowed=cells_allowed, loader=snaps)["rendered"]) last = marker["end"] out.append(text[last:]) return "".join(out) +def render_markers(text: str, catalog: dict[str, Any], *, cells_allowed: bool = True, + loader: Loader | None = None) -> str: + """把一段文本里的标记全部换成字:m / i / v 渲染成值,整表展开成表,see 去掉,解析不了的换成占位。""" + return _render(text, catalog, _snapshots(loader), cells_allowed) + + # -------------------------------------------------------------------------- # 流式渲染:让 llm.token 里出现的就是最终的数字 # -------------------------------------------------------------------------- @@ -619,15 +964,23 @@ def _safe_cut(text: str) -> int: 一个标记最长 MARKER_MAX 个字,所以没闭合的时候最多攒 MARKER_MAX - 1 = _HOLD_LIMIT 个字; 超过了、或者碰到换行,它就不可能再是标记。末尾单独一个 [ 也要留着——下一块的 开头可能是另一个 [。 + + 整表标记闭合了也先不放:它展开时要看紧跟在后面的那个字(是不是换行),那个字还没到。 """ + cut = len(text) start = text.rfind("[[") if start != -1 and text.find("]]", start) == -1: tail = text[start:] if len(tail) <= _HOLD_LIMIT and "\n" not in tail: - return start - if text.endswith("["): - return len(text) - 1 - return len(text) + cut = start + if cut == len(text) and text.endswith("["): + cut = len(text) - 1 + while (start := text.rfind("[[", 0, cut)) != -1: + m = MARKER_RE.match(text, start) + if not (m and m.end() == cut and m.group("kind") == "table"): + break + cut = start + return cut class StreamRenderer: @@ -641,22 +994,33 @@ class StreamRenderer: ctx.emit(EventType.LLM_TOKEN, delta=tail) 所有 feed / flush 的输出拼起来,等于 render_markers(全文),不论模型怎么分块: - 整篇解析也只认 MARKER_MAX 以内的标记,超长的两边都原样当文字。 + 整篇解析也只认 MARKER_MAX 以内的标记,超长的两边都原样当文字;整表展开要看的 + 前一个字记在 prev 里,后一个字靠 _safe_cut 等它到了再放。 """ - def __init__(self, catalog: dict[str, Any]) -> None: + def __init__(self, catalog: dict[str, Any], *, cells_allowed: bool = True, loader: Loader | None = None) -> None: self.catalog = catalog + self.cells_allowed = cells_allowed + self.snaps = _Snapshots(loader) self.pending = "" + self.prev = "" + + def _release(self, ready: str) -> str: + if not ready: + return "" + out = _render(ready, self.catalog, self.snaps, self.cells_allowed, self.prev) + self.prev = ready[-1] + return out def feed(self, delta: str) -> str: self.pending += delta or "" cut = _safe_cut(self.pending) ready, self.pending = self.pending[:cut], self.pending[cut:] - return render_markers(ready, self.catalog) if ready else "" + return self._release(ready) def flush(self) -> str: ready, self.pending = self.pending, "" - return render_markers(ready, self.catalog) if ready else "" + return self._release(ready) # -------------------------------------------------------------------------- @@ -1051,13 +1415,18 @@ def compose_doc( node_id: str = "", run_id: str | None = None, allow_numbers: list[Any] | None = None, + cells_allowed: bool = True, + loader: Loader | None = None, ) -> dict[str, Any]: """模型写的原文(带标记)→ 报告文档:渲染后的 markdown、块、句、片段、统计、违规。 stats / violations 由 verify_doc 算出,和出口契约复核用的是同一个函数。 + 整表标记先展开成每格一个 [[v:]] 的 Markdown 表,之后和模型自己写的表格走同一条路; + doc["source"] 仍是模型的原文。 """ source = (raw or "").replace("\r\n", "\n").replace("\r", "\n").strip() - text, strong = _normalize_strong(source) + snaps = _Snapshots(loader) + text, strong = _normalize_strong(_expand_tables(source, catalog, snaps, cells_allowed)) markers = parse_markers(text) for marker in markers: marker["strong"] = any(a <= marker["start"] and marker["end"] <= b for a, b in strong) @@ -1118,7 +1487,7 @@ def compose_doc( if marker["kind"] == "see": see.extend(resolve_support(r, catalog) for r in marker["refs"]) continue - cite = resolve_marker(marker, catalog) + cite = resolve_marker(marker, catalog, cells_allowed=cells_allowed, loader=snaps) ok = cite["status"] == "resolved" seg = { "kind": _SEG_KIND.get(marker["kind"]) @@ -1146,7 +1515,7 @@ def compose_doc( markdown = "".join(parts) # 裸数字单独切成片段:前端要在那几个字底下画「无出处」 - refs = [tuple(s["span"]) for s in all_segments if s.get("ref")] + refs = [tuple(s["span"]) for s in all_segments if s.get("ref")] + _header_masks(out_blocks, catalog) syntax = [tuple(s["span"]) for s in all_segments if s["kind"] == "structural"] bare = _bare_numbers(markdown, refs, allow_numbers, syntax) for block in out_blocks: @@ -1179,11 +1548,33 @@ def compose_doc( "schema": DOC_SCHEMA, "run_id": run_id, "node_id": node_id, "markdown": markdown, "source": source, "catalog": catalog, "blocks": out_blocks, } - checked = verify_doc(doc, catalog, allow_numbers=allow_numbers) + checked = verify_doc(doc, catalog, allow_numbers=allow_numbers, cells_allowed=cells_allowed, loader=snaps) doc["stats"], doc["violations"] = checked["stats"], checked["violations"] return doc +def _header_masks(blocks: list[dict[str, Any]], catalog: dict[str, Any]) -> list[tuple[int, int]]: + """表头里恰好是某次查询的列名的格:整表的表头是系统照快照列名写的,「销售额2025」这种列名 + 里的数字不是结论数字,写作者也改不了它。只放过表头、只放过和列名一字不差的格。""" + names = {_clean(str(c)) for e in catalog.values() if e.get("kind") == "query" for c in e.get("columns") or []} + out: list[tuple[int, int]] = [] + for block in blocks if names else []: + if not isinstance(block, dict) or block.get("type") != "table": + continue + for unit in block.get("units") or []: + if (unit.get("loc") or {}).get("row") != -1: + continue + body = [s for s in unit.get("segments") or [] if s.get("kind") != "structural"] + spans = [s.get("span") for s in body] + if not body or not all(isinstance(p, list) and len(p) == 2 and all(isinstance(x, int) for x in p) + for p in spans): + continue # 片段的偏移坏了:复核会报 segment_mismatch,这里不替它兜 + if all(s.get("kind") == "text" for s in body) \ + and "".join(str(s.get("text") or "") for s in body).strip() in names: + out.append((spans[0][0], spans[-1][1])) + return out + + def _split_bare(segments: list[dict[str, Any]], bare: list[Any]) -> list[dict[str, Any]]: out: list[dict[str, Any]] = [] for seg in segments: @@ -1260,9 +1651,14 @@ def verify_doc( catalog: dict[str, Any], *, allow_numbers: list[Any] | None = None, + cells_allowed: bool = True, + loader: Loader | _Snapshots | None = None, ) -> dict[str, Any]: """逐项复核一份报告文档:{ok, violations, stats}。 + cells_allowed=False:单元格引用(含整表里的格)一律判解析不了,原因是 CELLS_REASON—— + 受管级别的正式出具,契约没声明 cells 时出口复核这样调。快照经 loader 重新取、复验哈希。 + 报告节点自查用它(违规就让写作者重写),出口契约复核也用它(判档)——后者传的 是自己从状态里重建的目录,不用文档里存的那份。复核什么: @@ -1274,6 +1670,7 @@ def verify_doc( 「100. 」「```45678」也在内) - 片段标的状态和核对结果一致(界面按状态画线,不能标着「有出处」其实没有) """ + snaps = _snapshots(loader) violations: list[dict[str, Any]] = [] stats = {"units": 0, "segments": 0, "claims": 0, "connective": 0, "headings": 0, "numbers": 0, "numbers_cited": 0, "values": 0, "uncited_numbers": 0, "unresolved": 0, "see": 0, @@ -1323,7 +1720,7 @@ def verify_doc( f"结构片段里夹带了内容「{text[:30]}」", **where)) elif seg.get("ref"): ref = str(seg["ref"]) - cite = resolve_ref(ref, catalog) + cite = resolve_ref(ref, catalog, cells_allowed=cells_allowed, loader=snaps) ok = cite["status"] == "resolved" matches = cite["rendered"] == text if intact: @@ -1371,6 +1768,7 @@ def verify_doc( if tiled and pos != len(markdown): violations.append(_violation("segment_mismatch", "正文末尾有一段不在任何片段里:文档可能被改过")) + masks += _header_masks(list(doc.get("blocks") or []), catalog) for token in _bare_numbers(markdown, masks, allow_numbers, syntax): owner = next(((sid, uid) for s, e, sid, uid in owners if s <= token.start < e), (None, None)) violations.append(_violation( @@ -1398,9 +1796,67 @@ MARKER_RULES = """写作规则(系统会逐字核对): 4. 日期、ISO 周(2026-W37)、「前 3 名」「第 2 季度」这类序号可以直接写。 5. 目录里没有的指标不要编造引用;标着「没有值」的指标不要写进报告。""" +#: 引用查询结果的写法。只跟着目录里的查询出现(catalog_prompt):写作规则本身保持一期那份,升级前 +#: 发起的运行跑到报告节点时提示一字不差;只有口径卡的目录也不该教写作者去引用不存在的 Q1 +CELL_RULES = ( + "查询结果(里面的数和指标一样只能用引用标记写,系统照快照换成真实数值、逐字核对):\n" + "写法:一格写 [[v:Q1.r0.列名]](行号从 0 数),换算同指标,比如 [[v:Q1.r0.列名|万]];" + "要列出多行多列,单独一行写 [[table:Q1 cols=列a,列b rows=0-4]],系统照快照生成表格" + f"(不写 rows 就是前 {TABLE_DEFAULT_ROWS} 行,最多 {TABLE_MAX_ROWS} 行);" + "结论的依据可以写查询编号,比如 [[see:Q1]]。下面没有的查询、行、列不要编造。" +) -def catalog_prompt(catalog: dict[str, Any], *, budget: int = 12000) -> str: - """证据目录的文字版,放进写作者的 prompt。超过预算就截断,并照实说截断了。""" +#: 写作目录里每次查询最多列出几行、几列。更多的行照样能按行号引用 +_PROMPT_ROWS = 20 +_PROMPT_COLS = 12 + + +def _query_prompt(entry: dict[str, Any], snaps: _Snapshots, cells_allowed: bool) -> list[str]: + """一次查询在写作目录里的样子:编号、来源、列,以及能引用的行(行号 + 渲染后的值)。 + + 值按 [[v:]] 的渲染规则写出来:写作者在这里看到的,就是引用之后报告里显示的字。 + """ + alias = entry["alias"] + cols = entry.get("columns") or [] + lines = [f"- {alias}:{entry.get('label')}" + (f",列 {', '.join(map(str, cols[:_PROMPT_COLS]))}" if cols else "")] + snapshot, why = snaps.get(alias, entry.get("artifact")) + if why: + return [*lines, f" ({why},不要引用)"] + if not cells_allowed: + return lines + columns = [str(c) for c in snapshot.get("columns") or []][:_PROMPT_COLS] + rows = snapshot["rows"] + example = None + for r, record in enumerate(rows[:_PROMPT_ROWS]): + cells = [] + for c in columns: + try: + raw, _ = locate_cell(snapshot, r, c) + kind = column_kind(snapshot, c) + shown, numeric = render_cell(raw, kind=kind), is_number(cell_value(raw, kind)) + except (CellError, RenderError): + shown, numeric = MISSING, False + cells.append(f"{c}={shown}") + if example is None and r == 0 and numeric: + example = c # 示例挑第一行里第一个数,写作者最常引用的就是它 + lines.append(f" r{r}:" + ",".join(cells)) + if not rows: + lines.append(" (0 行,没有可以引用的格)") + elif len(rows) > _PROMPT_ROWS: + lines.append(f" …共 {len(rows)} 行,这里只列出前 {_PROMPT_ROWS} 行;后面的行也能按行号引用") + if rows and columns: + lines.append(f" 例:[[v:{alias}.r0.{example or columns[0]}]]") + return lines + + +def catalog_prompt(catalog: dict[str, Any], *, budget: int = 12000, cells_allowed: bool = True, + loader: Loader | None = None) -> str: + """证据目录的文字版,放进写作者的 prompt。超过预算就截断,并照实说截断了。 + + 查询结果会列出能引用的行(要读快照);cells_allowed=False 时只列编号,说明只能当依据。 + 沙箱代码节点不列:它的产出不能引用。 + """ + snaps = _Snapshots(loader) lines = ["可引用的证据(写数字只能用下面的引用标记,系统会换成真实数值):"] groups: dict[tuple[str, str, str], list[dict[str, Any]]] = {} for entry in catalog.values(): @@ -1422,12 +1878,16 @@ def catalog_prompt(catalog: dict[str, Any], *, budget: int = 12000) -> str: if inputs: lines.append("\n运行输入:") lines.extend(f"- [[{e['alias']}]] = {e.get('rendered')}" for e in inputs) - others = [e for e in catalog.values() if e.get("kind") in ("query", "retrieval")] - if others: - lines.append("\n查询与检索(本期只能在 [[see:…]] 里当依据,不能直接引用其中的数):") - for e in others: - cols = f",列 {', '.join(map(str, e['columns'][:12]))}" if e.get("columns") else "" - lines.append(f"- {e['alias']}:{e.get('label')}{cols}") + queries = [e for e in catalog.values() if e.get("kind") == "query"] + if queries: + lines.append(f"\n{CELL_RULES}" if cells_allowed else + "\n查询结果(这次出具不允许直接引用单元格,只能写在 [[see:Q1]] 这样的依据里):") + for e in queries: + lines.extend(_query_prompt(e, snaps, cells_allowed)) + retrievals = [e for e in catalog.values() if e.get("kind") == "retrieval"] + if retrievals: + lines.append("\n知识库检索(只能写在 [[see:K1]] 这样的依据里):") + lines.extend(f"- {e['alias']}:{e.get('label')}" for e in retrievals) text = "\n".join(lines) if len(text) > budget: text = text[:budget].rsplit("\n", 1)[0] + "\n…(目录太长,后面的省略了)" diff --git a/backend/app/engine/expressions.py b/backend/app/engine/expressions.py index 0d9123d3..f23360d0 100644 --- a/backend/app/engine/expressions.py +++ b/backend/app/engine/expressions.py @@ -2,6 +2,7 @@ from __future__ import annotations import ast import json +import math import operator import re from typing import Any @@ -107,6 +108,10 @@ def render_deep(value: Any, ctx: dict[str, Any]) -> Any: # 安全表达式求值(分支条件用) # -------------------------------------------------------------------------- +class ExpressionError(ValueError): + pass + + # 幂运算的上界。`9**9**9**9` 只有 11 个 AST 节点,轻松过掉节点数护栏, # 却会让 CPython 无限期地算一个天文数字的大整数 —— 而求值是在事件循环里 # 同步执行的(分支条件、transform、口径卡、每个节点的 skip_if 都走这里), @@ -151,6 +156,135 @@ _CMP_OPS = { } _UNARY_OPS = {ast.Not: operator.not_, ast.USub: operator.neg, ast.UAdd: operator.pos} + +# -------------------------------------------------------------------------- +# 查询结果里的一格:cell(x, row, column) +# +# 口径卡直接从 tool 节点的查询结果取数时,以前只能写 vars.q.rows[0][2]:列的顺序一改, +# 取到的就是另一列,而且报告点开只追得到这条路径。cell() 按列名取,来历能精确到 +# 那次查询的那一格。报告里的 [[v:Q1.r0.amount]] 用的也是这里的规则,两边取到的永远是同一个值。 +# -------------------------------------------------------------------------- + +#: 数据源把 DECIMAL 一律落成 str(Decimal)(data/engine.py 的 _jsonable):"45678.50";小数位为 0 的 +#: ——MySQL 对整数列 SUM()(包括 SUM(CASE WHEN … THEN 1 ELSE 0 END) 这种计数)、Postgres 的 +#: SUM(bigint)、NUMERIC(10,0)——是没有小数点的 "45678";很小的写成 "1E-7"。这些都按数算。 +#: 带前导零的 "00123" 是编号,照文本。快照里没有列类型:文本列里的值恰好全是不带前导零的数字时, +#: 同样会被当成数,要文本就写 str(cell(...)) +_NUMERIC_TEXT = re.compile(r"^\s*-?(?:0|[1-9]\d*)(?:\.\d+)?(?:E[+-]?\d+)?\s*$") +#: 字段声明成数时,前导零、正号、小写 e 也按数读:写 schema 的人说了它是数 +_LOOSE_NUMERIC_TEXT = re.compile(r"^\s*[+-]?\d+(?:\.\d+)?(?:[eE][+-]?\d+)?\s*$") +_INTEGER_TEXT = re.compile(r"^\s*[+-]?\d+\s*$") + + +class CellError(ExpressionError): + """取不到那一格。消息是人话:第几行、有几行、有哪些列。口径卡、分支条件照接住 + 表达式错误的路子接住它。""" + + +def numeric_text(raw: Any, *, loose: bool = False) -> int | float | None: + """数字文本 → 数;不是数字文本(或者根本不是文本)返回 None。 + + 没有小数点、没有指数的给整数:不经 float,大整数不丢位,45678 也不会变成 45678.0。 + loose=True 连前导零、正号也认,只在字段声明成数时用。 + """ + if not isinstance(raw, str) or not (_LOOSE_NUMERIC_TEXT if loose else _NUMERIC_TEXT).match(raw): + return None + try: + return int(raw) if _INTEGER_TEXT.match(raw) else float(raw) + except ValueError: # 超过 Python 整数位数上限(4300 位)的:照文本,不让它在渲染、核对里炸开 + return None + + +def cell_value(raw: Any, kind: str | None = None) -> Any: + """快照里的一格 → 参与运算、比对的值:DECIMAL 落成的数字文本换成数,其余原样。 + + kind 是查询时从驱动的原始值记下的列类型(query_snapshot 的 column_types):text 列原样是文本, + VARCHAR 的 "2026" 不会变成 2026。不给(老快照没记、口径卡表达式里的 cell())就按值猜。 + """ + if kind == "text": + return raw + number = numeric_text(raw) + return raw if number is None else number + + +def column_kind(table: Any, column: Any) -> str | None: + """查询时记下的这一列的类型:number / text / date / datetime / time / boolean。老快照没记,返回 None。""" + types = table.get("column_types") if isinstance(table, dict) else None + kind = types.get(column) if isinstance(types, dict) and isinstance(column, str) else None + return kind if isinstance(kind, str) else None + + +def same_value(model: Any, truth: Any) -> bool: + """模型报的值和快照里的一格是不是一回事:数按数比(数字文本也算数,45678.0 和 "45678" 一致), + 文本压成一行比。cite_fields 的核对和口径卡的来历用同一把尺子。""" + if model is None or truth is None: + return model is None and truth is None + m, t = cell_value(model), cell_value(truth) + if isinstance(m, bool) or isinstance(t, bool): + return m == t + if isinstance(m, (int, float)) and isinstance(t, (int, float)): + return math.isclose(float(m), float(t), rel_tol=1e-9, abs_tol=1e-9) + return re.sub(r"\s+", " ", str(m)).strip() == re.sub(r"\s+", " ", str(t)).strip() + + +def _table_of(table: Any) -> dict[str, Any]: + data = table + if isinstance(data, str): + try: + data = json.loads(data) + except ValueError: + # 查库失败时工具交回的是一句话(「查询失败:…」):原话照搬,比「不是 JSON」有用 + raise CellError(f"要的是查询结果,拿到的是「{data.strip()[:80]}」") from None + if not isinstance(data, dict) or not isinstance(data.get("rows"), list): + raise CellError("第一个参数要是查询结果:查询工具交回的 JSON 文本,或者 {columns, rows}") + return data + + +def locate_cell(table: Any, row: Any, column: Any) -> tuple[Any, str]: + """(那一格的原值, 列名)。列号换成列名:按列名和按列号引用同一格,得到同一个 eid。""" + data = _table_of(table) + rows = data["rows"] + if isinstance(row, bool) or not isinstance(row, int): + raise CellError(f"行号要是从 0 数的整数,写的是 {row!r}") + if not rows: + raise CellError("这份查询结果是空的(0 行)") + if not 0 <= row < len(rows): + raise CellError(f"要第 {row} 行,但这份查询结果只有 {len(rows)} 行(从 0 数)") + record = rows[row] + columns = [str(c) for c in data.get("columns") or []] + if isinstance(record, dict): + columns = columns or [str(k) for k in record] + if isinstance(column, int) and not isinstance(column, bool): + if not 0 <= column < len(columns): + raise CellError(f"要第 {column} 列,但只有 {len(columns)} 列(从 0 数)") + column = columns[column] + elif not isinstance(column, str): + raise CellError(f"列要写列名,或者从 0 数的列号,写的是 {column!r}") + if isinstance(record, dict): + if column not in record: + raise CellError(f"没有列「{column}」,有:{'、'.join(columns[:12])}") + return record[column], column + if column not in columns: + raise CellError(f"没有列「{column}」,有:{'、'.join(columns[:12])}") + index = columns.index(column) + if not isinstance(record, (list, tuple)) or index >= len(record): + raise CellError(f"第 {row} 行没有「{column}」这一格") + return record[index], column + + +def table_cell(table: Any, row: Any, column: Any) -> Any: + """表达式里的 cell(x, row, column):x 是查询工具交回的 JSON 文本或 {columns, rows}。 + + 不看列类型、照旧按值猜:口径卡是在算数,数字写成文本的列(CAST 成文本、VARCHAR 存的金额)多半 + 就是要拿来算的。要文本写 str(cell(...))。 + """ + try: + raw, _ = locate_cell(table, row, column) + except CellError as e: + raise CellError(f"cell() {e}") from None + return cell_value(raw) + + _SAFE_FUNCS: dict[str, Any] = { "len": len, "str": str, @@ -172,6 +306,7 @@ _SAFE_FUNCS: dict[str, Any] = { "endswith": lambda s, p: str(s).endswith(p), "matches": lambda s, p: bool(re.search(p, str(s))), "get": lambda obj, key, default=None: (obj or {}).get(key, default), + "cell": table_cell, } _MAX_NODES = 200 @@ -197,8 +332,6 @@ _NODE_NAMES = { } -class ExpressionError(ValueError): - pass class _Unbrace(ast.NodeTransformer): @@ -292,7 +425,30 @@ def _chain_root(node: ast.AST) -> str | None: return None +def _cell_call(node: ast.AST) -> bool: + """cell(<取值链>, row, column):第一个参数是从根名字出发的取值链,整个调用算一个输入。""" + return (isinstance(node, ast.Call) and isinstance(node.func, ast.Name) and node.func.id == "cell" + and len(node.args) == 3 and not node.keywords and _chain_root(node.args[0]) is not None) + + +def cell_parts(path: str) -> tuple[str, str, str] | None: + """leaf_refs 给出的 cell(...) 路径拆回三段源码:(取值链, 行, 列)。不是 cell 调用返回 None。""" + try: + body = ast.parse(path, mode="eval").body + except SyntaxError: + return None + if not _cell_call(body): + return None + return tuple(ast.unparse(a) for a in body.args) # type: ignore[return-value] + + def _walk_refs(node: ast.AST, found: list[ast.AST]) -> None: + if _cell_call(node): + # 取哪一格由行、列决定,它们里面的引用是独立的输入;取值链本身只是这一格的来处 + found.append(node) + for arg in node.args[1:]: + _walk_refs(arg, found) + return if isinstance(node, (ast.Attribute, ast.Subscript, ast.Name)) and _chain_root(node): found.append(node) # 链本身不再往里拆(vars.kpi 是 vars.kpi.gmv 的一部分),但下标里的表达式是 @@ -313,6 +469,7 @@ def leaf_refs(tree: ast.AST) -> list[str]: parse_expression 只告诉你「有没有不认识的名字」;口径卡要记来源,得知道 round((vars.kpi.gmv - vars.kpi.gmv_prev) / …) 里用到的是 vars.kpi.gmv 和 vars.kpi.gmv_prev 这两个值,而不是 vars、kpi 这些零件。 + cell(nodes.fetch, 0, 'gmv') 整个调用算一个值:它的来历是那次查询的那一格。 路径用 ast.unparse 的写法,substitute 按同样的写法认。 """ found: list[ast.AST] = [] @@ -362,6 +519,13 @@ class _Substitute(ast.NodeTransformer): visit_Attribute = visit_Subscript = visit_Name = _replace + def visit_Call(self, node: ast.Call) -> ast.AST: + if _cell_call(node): + path = ast.unparse(node) + if path in self.values and _inlinable(self.values[path]): + return ast.copy_location(_literal(self.values[path]), node) + return self.generic_visit(node) + def substitute(tree: ast.AST, values: dict[str, Any]) -> str: """把引用换成它当时的值,得到代入式:round((45678.5 - 42010.0) / 42010.0 * 100, 1)。 diff --git a/backend/app/engine/governance.py b/backend/app/engine/governance.py index bb1b712e..ec44319c 100644 --- a/backend/app/engine/governance.py +++ b/backend/app/engine/governance.py @@ -5,7 +5,7 @@ from typing import Any from sqlalchemy import func, select from sqlalchemy.ext.asyncio import AsyncSession -from app.engine.schema import GraphSpec, NodeType, ValidationResult, type_label +from app.engine.schema import GraphSpec, NodeType, ValidationResult, type_label, validate_graph # 口径升版的三种合法处置。没有默认值是刻意的:升版最容易静默翻车的地方 # 就是"上周和这周的数其实不可比,但报表看起来一切正常"。 @@ -24,12 +24,17 @@ def lint_for_publish(spec: GraphSpec, *, level: str) -> ValidationResult: published:基础可运行性之外只给警告。 governed:受管模板,出具用的那一档 —— 把关节层的自由度收进边界里, 错误会挡住发布。 + + 每条问题带机器编号(code):发布前自动修复(engine/autofix.py)按它认问题、出修复, + 文案可以改,编号不能随便改。 """ result = ValidationResult() strict = level == "governed" - def flag(message: str, *, node_id: str | None = None, hard: bool = False) -> None: - result.add(message, level="error" if (hard and strict) else "warning", node_id=node_id) + def flag(message: str, *, code: str, node_id: str | None = None, hard: bool = False, + field: str | None = None) -> None: + result.add(message, level="error" if (hard and strict) else "warning", node_id=node_id, + field=field, code=code) has_contract_output = False for node in spec.nodes: @@ -41,29 +46,37 @@ def lint_for_publish(spec: GraphSpec, *, level: str) -> ValidationResult: flag( f"{who}:受管模板不允许全动态规划的节点——谁来做、做几轮都由模型临场" "决定,属于探索层。改用固定编排的 Agent 或「模型调用」节点", - node_id=node.id, hard=True, + code="governed.supervisor", node_id=node.id, hard=True, ) elif node.type == NodeType.SUBGRAPH: if not cfg.get("workflow_version"): flag( f"{who}没有钉住版本,口径会随上游最新版漂移。在节点里选定一个版本", - node_id=node.id, hard=True, + code="governed.subgraph_unpinned", node_id=node.id, hard=True, field="workflow_version", ) elif node.type == NodeType.AGENT: tools = cfg.get("tools") or [] if cfg.get("approval") == "never" and tools: flag( f"{who}的审批策略是「全部自动放行」,受管模板要求危险工具至少人工确认。" - "改成「仅危险工具需要确认」", - node_id=node.id, hard=True, + "改成「仅危险工具需要审批」", + code="governed.agent_approval_never", node_id=node.id, hard=True, field="approval", + ) + elif cfg.get("approval") in (None, "") and spec.defaults.get("approval") == "never" and tools: + # 节点自己没写,运行时跟随全图默认(context.approval_mode:节点 > 图级 defaults > 全局)。 + # 全图默认写着全部自动放行,这个 agent 就是全部自动放行,只是没写在它自己身上 + flag( + f"{who}没写自己的审批策略,跟随全图默认的「全部自动放行」,受管模板要求危险工具至少" + "人工确认。把全图默认改成「仅危险工具需要审批」,或者在这个节点上单独设置", + code="governed.default_approval_never", node_id=node.id, hard=True, field="approval", ) if not tools: flag(f"{who}没有配任何可用工具,通常意味着它该是个「模型调用」节点", - node_id=node.id) + code="governed.agent_no_tools", node_id=node.id, field="tools") elif len(tools) > 10: flag( f"{who}的可用工具有 {len(tools)} 个,边界过宽(白名单衰减的前兆)", - node_id=node.id, + code="governed.agent_tools_wide", node_id=node.id, field="tools", ) elif node.type == NodeType.OUTPUT: contract = cfg.get("contract") @@ -73,16 +86,24 @@ def lint_for_publish(spec: GraphSpec, *, level: str) -> ValidationResult: elif node.type == NodeType.CODE: if cfg.get("network"): flag(f"{who}打开了「允许联网」:受管模板建议把取数收敛到「调用工具」节点" - "(那里有取数快照)", node_id=node.id) + "(那里有取数快照)", code="governed.code_network", node_id=node.id, field="network") if strict and not has_contract_output: result.add( "受管模板至少要有一个「成果 / 出具」节点声明出具契约,否则三档出具无从谈起", - level="error", + level="error", code="governed.no_contract", ) return result +def publish_issues(spec: GraphSpec, *, level: str) -> list[Any]: + """发布时拦的全部问题:validate_graph 和 lint_for_publish 的并集,顺序也照发布接口。 + + 发布、发布前检查、自动修复复核都走这一个口径——检查说能发的,真发布时不会被拦。 + """ + return [*validate_graph(spec).issues, *lint_for_publish(spec, level=level).issues] + + def _lint_contract( contract: Any, *, node_id: str, spec: GraphSpec, flag: Any, strict: bool ) -> None: @@ -94,7 +115,8 @@ def _lint_contract( "这张图过过闸"的错觉。 """ if not isinstance(contract, dict): - flag("出具契约必须是一个 JSON 对象", node_id=node_id, hard=True) + flag("出具契约必须是一个 JSON 对象", code="contract.not_object", node_id=node_id, hard=True, + field="contract") return metric_nodes = {n.id for n in spec.nodes if n.type == NodeType.METRICS} @@ -104,34 +126,40 @@ def _lint_contract( if not sources: flag("出具契约没有声明 metrics_from(指标来自哪个「口径卡」节点),叙述里的数字无从回指", - node_id=node_id, hard=True) + code="contract.metrics_from_missing", node_id=node_id, hard=True, field="contract.metrics_from") for src in sources: if src not in metric_nodes: flag( f"出具契约的 metrics_from 指向 {src!r},但工作流里没有这个「口径卡」节点" "(写错了,或者那个节点还没建)", - node_id=node_id, hard=True, + code="contract.metrics_from_invalid", node_id=node_id, hard=True, field="contract.metrics_from", ) - if not str(contract.get("narrative") or "").strip(): - flag("出具契约没有声明 narrative(叙述),数字回指校验不会执行", node_id=node_id, hard=True) + # 引用模式(report_from)核对的是报告撰写节点产出的文档,数字回指靠文档里的引用标记, + # 用不上 narrative。report_from 指错了(不是报告撰写节点、不在出口上游)由 validate_graph + # 报 error(contract.report_from_invalid),发布时两者一起过,照样挡住 + if contract.get("report_from") in (None, "") and not str(contract.get("narrative") or "").strip(): + flag("出具契约既没有声明 report_from(核对哪个「报告撰写」节点的文档),也没有声明 narrative" + "(叙述),数字回指校验不会执行", code="contract.report_from_missing", node_id=node_id, hard=True, + field="contract.report_from") if strict and not (contract.get("required") or []): flag( "受管模板的出具契约必须声明 required(必需指标),否则「不予出具」这一档永远触发不了", - node_id=node_id, hard=True, + code="contract.required_missing", node_id=node_id, hard=True, field="contract.required", ) if strict and not contract.get("strict"): flag( "受管模板建议把出具契约设为 strict:非 strict 下未回指的数字只降档不拦截", - node_id=node_id, + code="contract.strict_off", node_id=node_id, field="contract.strict", ) async def unresolved_caliber_upgrades( session: AsyncSession, spec: GraphSpec ) -> tuple[list[str], list[dict[str, Any]]]: - """检查钉了版本的子图(方法卡)是否有未处置的新版本。 + """检查钉了版本的上游是否有未处置的新版本:子工作流(方法卡),以及用 caliber_from + 钉住别处口径卡的口径卡节点——两者是同一套规则。 返回 (阻断性错误, 需记录的升版事件)。有新版本但没声明 upgrade_policy 的直接阻断 formal 启动 —— 这是"被迫处置",不是提醒。 @@ -141,11 +169,19 @@ async def unresolved_caliber_upgrades( errors: list[str] = [] events: list[dict[str, Any]] = [] for node in spec.nodes: - if node.type != NodeType.SUBGRAPH: + extra: dict[str, Any] = {} + if node.type == NodeType.SUBGRAPH: + pinned = node.config.get("workflow_version") + wf_id = node.config.get("workflow_id") + what = f"「{node.title}」(子工作流)钉在 v{pinned}" + elif node.type == NodeType.METRICS and isinstance(node.config.get("caliber_from"), dict): + ref = node.config["caliber_from"] + pinned, wf_id = ref.get("workflow_version"), ref.get("workflow_id") + extra = {"caliber_node": ref.get("node_id")} + what = f"「{node.title}」(口径卡)钉住的口径卡在 v{pinned}" + else: continue - pinned = node.config.get("workflow_version") - wf_id = node.config.get("workflow_id") - if not pinned or not wf_id: + if not pinned or not wf_id or not str(pinned).isdigit(): continue latest = ( await session.execute( @@ -159,7 +195,7 @@ async def unresolved_caliber_upgrades( policy = node.config.get("upgrade_policy") if policy not in UPGRADE_POLICIES: errors.append( - f"「{node.title}」(子工作流)钉在 v{pinned},但上游已有 v{latest}。" + f"{what},但上游已有 v{latest}。" f"正式运行前必须在节点上声明升版策略(upgrade_policy):" + " / ".join(f"{k}({v})" for k, v in UPGRADE_POLICY_LABELS.items()) ) @@ -172,6 +208,58 @@ async def unresolved_caliber_upgrades( "latest": int(latest), "policy": policy, "policy_label": UPGRADE_POLICY_LABELS[policy], + **extra, } ) return errors, events + + +#: 发起正式运行时记下「这次按不按受管出具」的那条事件(log · info,不进主流程) +GOVERNANCE_CODE = "run_governance" + + +def governance_note(*, governed: bool, workflow_name: str, version: int | None = None) -> dict[str, Any]: + """发起正式运行时落进事件流的那条记录(runs.start_run 调用)。 + + governed 由调用方按这次钉住的版本发布时记下的级别定(WorkflowVersion.level),老版本没记过的 + 看工作流当时的 status。 + """ + which = f"「{workflow_name}」v{version}" if version else f"「{workflow_name}」" + message = (f"这次正式运行按受管级别出具:{which}是按受管级别发布的,之后改图、改级别都不影响这次" + if governed else + f"这次正式运行按已发布级别出具:{which}不是按受管级别发布的") + return {"level": "info", "code": GOVERNANCE_CODE, "governed": governed, "message": message} + + +async def governed_formal(run_id: str) -> bool: + """这次运行是不是受管级别的正式运行:从按受管级别发布的版本发起。 + + 受管的判断沿用发布门禁的口径(lint_for_publish 的 strict 档位)。探索运行、已发布级别的 + 正式运行都不是。 + + 报告节点和出口复核各问一次、前后隔着审批和重放,所以按发起时记下的那条事件算(start_run + 在封存范围内记的 run_governance,来源是钉住的那一版发布时记下的级别);没有这条记录的是 + 加这条之前发起的运行,照旧看工作流现在的 status。 + """ + from app.db.base import SessionLocal + from app.db.models import Run, RunEvent, Workflow + + async with SessionLocal() as session: + run = await session.get(Run, run_id) + if run is None or run.run_class != "formal" or not run.workflow_id: + return False + # 这条事件没有 node_id,发起时就记下了:只翻不属于任何节点的日志 + notes = (await session.execute( + select(RunEvent.data).where(RunEvent.run_id == run_id, RunEvent.type == "log", + RunEvent.node_id.is_(None)).order_by(RunEvent.seq))).scalars() + for data in notes: + if isinstance(data, dict) and data.get("code") == GOVERNANCE_CODE: + return data.get("governed") is True + workflow = await session.get(Workflow, run.workflow_id) + return workflow is not None and workflow.status == "governed" + + +def cells_allowed(contract: Any, *, governed: bool) -> bool: + """报告能不能直接引用查询单元格。受管级别的正式运行要在契约里显式写 "cells": true; + 探索运行和已发布级别一律允许(用户拍板)。""" + return not governed or (isinstance(contract, dict) and contract.get("cells") is True) diff --git a/backend/app/engine/nodes/io.py b/backend/app/engine/nodes/io.py index ecd5785e..3cb24daa 100644 --- a/backend/app/engine/nodes/io.py +++ b/backend/app/engine/nodes/io.py @@ -57,7 +57,12 @@ async def run_output(state: GraphState, ctx: NodeContext) -> dict[str, Any]: reports = _report_fields(result, mapping, state, ctx) contract = ctx.cfg("contract") if contract: - result["_issuance"] = _apply_contract(contract, state, ctx, reports=reports) + cells = True + if isinstance(contract, dict) and contract.get("report_from") not in (None, ""): + from app.engine.governance import cells_allowed, governed_formal + + cells = cells_allowed(contract, governed=await governed_formal(ctx.run.run_id)) + result["_issuance"] = _apply_contract(contract, state, ctx, reports=reports, cells=cells) evidence = _evidence_of(reports, contract) if evidence: result["_evidence"] = evidence @@ -137,7 +142,7 @@ def _evidence_of(reports: list[dict[str, Any]], contract: Any) -> dict[str, Any] def _apply_contract( contract: dict[str, Any], state: GraphState, ctx: NodeContext, - *, reports: list[dict[str, Any]] | None = None, + *, reports: list[dict[str, Any]] | None = None, cells: bool = True, ) -> dict[str, Any]: from datetime import datetime, timezone @@ -190,7 +195,7 @@ def _apply_contract( trace = None cited: dict[str, Any] = {} if citations: - cited = _check_citations(str(report_from), contract, state, ctx, reports or []) + cited = _check_citations(str(report_from), contract, state, ctx, reports or [], cells=cells) gaps.extend(cited["gaps"]) unmatched = cited["unmatched"] else: @@ -284,13 +289,15 @@ _INTEGRITY = {"bad_schema", "segment_mismatch", "structural_text", "render_misma def _check_citations( report_from: str, contract: dict[str, Any], state: GraphState, ctx: NodeContext, - reports: list[dict[str, Any]], + reports: list[dict[str, Any]], *, cells: bool = True, ) -> dict[str, Any]: """独立复核报告文档:{matched, unmatched, unresolved, gaps, doc_artifact, stats}。 不信报告节点自己的统计:文档按 id 从工件库取回(取回时复验哈希),目录按报告节点 同一套参数从状态里重建(不用文档里存的那份),每个带引用的片段重新解析、重新渲染、 逐字比对。一个配错的报告节点绕不过这里。 + + cells:受管级别的正式运行、契约没声明 cells 时为 False,单元格引用一律判解析不了。 """ from app.core.artifact_store import load from app.engine.evidence import iter_units, resolve_ref, verify_doc @@ -325,7 +332,7 @@ def _check_citations( return out catalog = report_catalog(state, spec, node) - checked = verify_doc(doc, catalog, allow_numbers=contract.get("allow_numbers")) + checked = verify_doc(doc, catalog, allow_numbers=contract.get("allow_numbers"), cells_allowed=cells) out["stats"] = checked["stats"] broken: list[str] = [] flagged: set[str] = set() @@ -350,7 +357,7 @@ def _check_citations( for seg in unit.get("segments") or []: if seg.get("kind") != "number" or not seg.get("ref") or seg.get("id") in flagged: continue - cite = resolve_ref(str(seg["ref"]), catalog) + cite = resolve_ref(str(seg["ref"]), catalog, cells_allowed=cells) if cite["status"] != "resolved" or cite["rendered"] != seg.get("text"): continue entry = catalog.get(cite["alias"]) or {} @@ -360,6 +367,9 @@ def _check_citations( label = " @ ".join(str(v) for v in (entry.get("caliber"), entry.get("version")) if v) if label: hit["caliber"] = label + elif cite["kind"] == "cell": + # 查询单元格:Q1.r0.gmv(全局编号,和 doc.catalog 一致) + hit["cell"] = f"{cite['alias']}.r{cite['locator']['row']}.{cite['locator']['column']}" else: hit["input"] = cite["locator"].get("field") out["matched"].append({**hit, "segment": seg["id"], "unit": unit["id"], "start": start, "end": end, @@ -437,15 +447,12 @@ async def run_transform(state: GraphState, ctx: NodeContext) -> dict[str, Any]: elif mode == "template": output = ctx.render_str(ctx.cfg("template", ""), state) elif mode == "json": - rendered = ctx.render_str(ctx.cfg("template", "{}"), state) + template = ctx.cfg("template", "{}") + rendered = ctx.render_str(template, state) try: output = json.loads(rendered) except json.JSONDecodeError as e: - raise NodeError( - ctx.node.id, - f"模板渲染出来的不是合法 JSON(第 {e.lineno} 行第 {e.colno} 列附近)。" - "检查模板里的引号、逗号,字符串值要用 | json 过滤器输出", - ) from e + raise _json_error(e, str(template or ""), tctx, ctx) from e else: raise NodeError(ctx.node.id, f"未知的整形模式:{mode}") @@ -454,3 +461,184 @@ async def run_transform(state: GraphState, ctx: NodeContext) -> dict[str, Any]: if var_name: updates["vars"] = {var_name: output} return updates + + +# -------------------------------------------------------------------------- +# 按 JSON 解析失败:错在模板,还是错在上游模型写的文字 +# -------------------------------------------------------------------------- + +#: 产出是模型写的文字的节点(llm 配了 output_schema 时 text 是系统序列化的,但那样也解析得了) +_MODEL_TEXT = (NodeType.AGENT, NodeType.LLM, NodeType.SUPERVISOR) +#: 技术细节里给出出错位置前后各多少个字 +_NEAR = 30 +_NODE_HEAD = re.compile(r"^nodes\s*(?:\.\s*([\w-]+)|\[\s*['\"]([^'\"]+)['\"]\s*\])") +_VAR_HEAD = re.compile(r"^vars\s*(?:\.\s*([\w-]+)|\[\s*['\"]([^'\"]+)['\"]\s*\])") + + +TEMPLATE_HINT = "检查模板里的引号、逗号,字符串值要用 | json 过滤器输出" +#: 解析器在这一段的头一个字上要的是这些:前一个值在模板里已经写完了,缺的是模板里的标点 +_DELIMITED = ("Expecting ',' delimiter", "Expecting ':' delimiter", "Extra data") + + +def _json_error(e: json.JSONDecodeError, template: str, tctx: dict[str, Any], ctx: NodeContext) -> NodeError: + """整形节点按 JSON 解析失败时的报错。 + + 真实踩过:整个模板就是 {{ nodes.X.text }},agent 在 JSON 字符串里夹了没转义的英文双引号, + 报错却让人去检查模板、加 | json,把人引到了错的方向。反过来也不行:出错的位置落在模型 + 写的那一段里,不等于错在模型——模板把一句话原样放进值的位置、放进引号里,或者紧挨着它 + 漏了逗号,都是模板的错,而且改法就是那句老提示。所以分三种说(见 _diagnose): + + - 上游确实在写 JSON 而没写对:点名上游、按实情说原因、指向结构化输出; + - 模板少了 | json:点名是模板里哪一段,给出改好的写法; + - 其余:原来那句提示。 + + 出错位置前后的原文只进技术细节(包在原异常里):首行是给人看的一句话,不贴模型的原文。 + """ + doc, pos = e.doc, e.pos + near = (doc[max(0, pos - _NEAR):pos] + "⟨此处⟩" + doc[pos:pos + _NEAR]).replace("\n", "\\n") + e.args = (f"{e.args[0]}\n出错位置前后的原文:{near}",) + verdict = _diagnose(template, tctx, ctx, e) + if verdict is None or isinstance(verdict, str): + return NodeError(ctx.node.id, f"模板渲染出来的不是合法 JSON(第 {e.lineno} 行第 {e.colno} 列附近)。" + f"{verdict or TEMPLATE_HINT}") + node, text, offset, reason = verdict + line = text.count("\n", 0, offset) + 1 + column = offset - (text.rfind("\n", 0, offset) + 1) + 1 + where = f"第 {line} 行第 {column} 列" if line > 1 else f"第 {column} 列" + fix = ("让模型交结构化数据请给它配 output_schema;数要进口径卡的,改用 agent 的 output_schema + cite_fields" + if node.type == NodeType.LLM else + "让模型交结构化数据请用 output_schema + cite_fields,别用整形节点解析它写的文字") + return NodeError(ctx.node.id, f"上游「{node.title}」输出的不是合法 JSON({where}附近),{reason};{fix}") + + +def _diagnose(template: str, tctx: dict[str, Any], ctx: NodeContext, + e: json.JSONDecodeError) -> tuple[GraphNode, str, int, str] | str | None: + """按 JSON 解析失败,错在谁。 + + 返回 (上游节点, 它写的那段文字, 出错位置在文字里的偏移, 原因) 表示错在上游;返回一句话 + 表示错在模板、而且说得出是哪一段;返回 None 表示照原来那句提示。 + + 只有上游确实在写 JSON(去掉开头空白后以 { 或 [ 开头,或者包在 ``` 代码块里)、而且这一段 + 不在模板的引号里,才算上游的错;整个模板就是这一段时,模板不可能写错,也算上游的。 + 解析器停在这一段末尾时(紧跟着的逗号漏了),只有它自己开了括号、引号没收尾才怪它。 + + 每个 {{ }} 单独渲染一次再拼起来,和 render_template 的整段替换结果逐字相同。 + """ + from app.engine.expressions import _TEMPLATE_RE, render_template, resolve_path + + doc, pos = e.doc, e.pos + at, last = 0, 0 + for m in _TEMPLATE_RE.finditer(template): + at += m.start() - last + piece = render_template(m.group(0), tctx) + start, end = at, at + len(piece) + at, last = end, m.end() + expr = m.group(1).strip() + head, *filters = [p.strip() for p in expr.split("|")] + if not head or pos < start: + continue + # 解析器越过空白才报错:停在这一段后面、中间只隔着空白的,也是停在它末尾 + at_end = pos >= end and not doc[end:pos].strip() + if not piece.strip(): + # 取出来是空的,解析器正好在这里要一个值 + if e.msg == "Expecting value" and not doc[start:pos].strip() and not _in_json_string(doc, start): + return f"模板里 {{{{ {expr} }}}} 取出来是空的,这里缺一个值:检查路径写对没有、前面的节点有没有产出" + continue + if pos > end and not at_end: + continue + quoted = _in_json_string(doc, start) + if "json" in filters: + # 过了 | json 就是一个合法的 JSON 值:错不在它的内容里,除非外面又套了一层引号 + return (f"模板里 {{{{ {expr} }}}} 出来的已经是合法的 JSON(文字自带引号),外面不要再加引号" + if quoted and not at_end else None) + value = resolve_path(tctx, head) + if not isinstance(value, str) or value != piece: + return None + node = _producer(head, ctx) + model = node is not None and node.type in _MODEL_TEXT + lead = start + len(piece) - len(piece.lstrip()) + if pos == lead and e.msg.startswith(_DELIMITED): + return None # 前一个值在模板里就写完了,缺的是它前面的标点 + if template.strip() == m.group(0): + return (node, piece, pos - start, _why(piece, at_end)) if model else None + if at_end and not _unclosed(piece): + return None # 这一段本身完整,缺的是它后面的标点 + if quoted: + return _needs_json(expr, model) + if _writes_json(piece): + return (node, piece, pos - start, _why(piece, at_end)) if model else None + return _needs_json(expr, model) + return None + + +def _needs_json(expr: str, model: bool) -> str: + what = "模型写的文字" if model else "一段文字" + return f"模板里 {{{{ {expr} }}}} 是{what},放进 JSON 要写成 {{{{ {expr} | json }}}}(外面不要再加引号)" + + +def _why(text: str, at_end: bool) -> str: + """上游写的 JSON 为什么解析不了,按看得出来的实情说。""" + body = text.lstrip() + if body.startswith("```"): + return "它把 JSON 包在了 ``` 代码块里" + if at_end and _unclosed(text): + return "它写的 JSON 没有收尾,可能写到一半被截断了" + if not body.startswith(("{", "[")): + return "它写的是一段文字,不是 JSON" + return "常见原因是字符串里有没转义的英文引号" + + +def _writes_json(text: str) -> bool: + """这段文字看得出是想写 JSON:以 { 或 [ 开头,或者是包在 ``` 代码块里的 JSON。""" + body = text.lstrip() + if body.startswith("```"): + body = body.split("\n", 1)[1].lstrip() if "\n" in body else "" + return body.startswith(("{", "[")) + + +def _in_json_string(doc: str, index: int) -> bool: + """doc[:index] 结束时是不是在一个 JSON 字符串里。解析器已经越过了这里,这一段前缀是 + 合法的 JSON 开头,数引号(跳过转义)就数得准。""" + inside, i = False, 0 + while i < index: + ch = doc[i] + if inside and ch == "\\": + i += 2 + continue + if ch == '"': + inside = not inside + i += 1 + return inside + + +def _unclosed(text: str) -> bool: + """这段文字自己开了引号、括号却没收尾(单独看它,从字符串外面数起)。""" + depth, inside, i = 0, False, 0 + while i < len(text): + ch = text[i] + if inside: + if ch == "\\": + i += 1 + elif ch == '"': + inside = False + elif ch == '"': + inside = True + elif ch in "{[": + depth += 1 + elif ch in "}]": + depth -= 1 + i += 1 + return inside or depth > 0 + + +def _producer(path: str, ctx: NodeContext) -> GraphNode | None: + """模板路径取的是哪个节点的产出:nodes.X 就是 X;vars.Y 是 assign_to 为 Y 的节点。""" + nodes = ctx.run.spec.node_map() + if m := _NODE_HEAD.match(path): + return nodes.get(m.group(1) or m.group(2)) + if m := _VAR_HEAD.match(path): + var = m.group(1) or m.group(2) + writers = [n for n in ctx.run.spec.nodes if str(n.config.get("assign_to") or "").strip() == var] + # 好几个节点写同一个变量时说不准是谁,宁可给原来的提示也不点错名 + return writers[0] if len(writers) == 1 else None + return None diff --git a/backend/app/engine/nodes/knowledge.py b/backend/app/engine/nodes/knowledge.py index 993d0c8d..8e2c8d8d 100644 --- a/backend/app/engine/nodes/knowledge.py +++ b/backend/app/engine/nodes/knowledge.py @@ -6,6 +6,7 @@ from typing import Any from app.core.events import EventType from app.db.base import SessionLocal from app.engine.context import NodeContext, NodeError +from app.engine.evidence import ledger_enabled, next_exec from app.engine.state import GraphState, template_context from app.memory import kb, store @@ -175,6 +176,10 @@ async def run_retrieve(state: GraphState, ctx: NodeContext) -> dict[str, Any]: } updates: dict[str, Any] = {"nodes": {ctx.node.id: result}} + if snapshot and ledger_enabled(ctx.run): + # 证据台账:报告目录按它编 K1…,[[see:K1]] 点开追得到这次检索命中了哪几段 + updates["evidence"] = [{"kind": "retrieval", "node_id": ctx.node.id, "exec": next_exec(state, ctx.node.id), + "artifact": snapshot, "source": collection, "rows": len(hits)}] var_name = ctx.cfg("assign_to", "") if var_name: updates["vars"] = {var_name: context_text} diff --git a/backend/app/engine/nodes/llm.py b/backend/app/engine/nodes/llm.py index 0b89c5a2..45569a8a 100644 --- a/backend/app/engine/nodes/llm.py +++ b/backend/app/engine/nodes/llm.py @@ -1,6 +1,7 @@ from __future__ import annotations import json +import re import time from typing import Any @@ -9,6 +10,7 @@ from langchain_core.tools import BaseTool from langgraph.func import task from sqlalchemy import select +from app.core import artifact_store from app.core.config import settings from app.core.errors import describe_exception, raw_detail from app.core.events import EventType @@ -16,6 +18,8 @@ from app.db.base import SessionLocal from app.db.models import Skill from app.engine.approval import read_decision from app.engine.context import NodeContext, NodeError +from app.engine.evidence import cell_eid, ledger_enabled, make_eid, next_exec, query_entry_fields +from app.engine.expressions import CellError, cell_value, column_kind, locate_cell, numeric_text, same_value from app.engine.guards import FALLBACK_CONTEXT, Guard, legacy_hint, node_limits from app.engine.replay import ask, once from app.engine.state import GraphState, message_text, template_context, thinking_text @@ -36,6 +40,7 @@ from app.providers.factory import ( bind_tools_safely, get_chat_model, ) +from app.tools.datasource import QUERY_PREFIX from app.tools.registry import ( ToolArgsError, ToolBuildError, @@ -407,6 +412,13 @@ async def run_agent(state: GraphState, ctx: NodeContext) -> dict[str, Any]: messages = await _build_messages(state, ctx) # 护栏(engine/guards.py)。None 是升级前发起的运行:默认 12 步、没有别的护栏,和以前一样 guard = _guard(ctx, model_id) + # 证据台账、工具结果前的编号、cite_fields:升级前发起的运行一样都不加(见 evidence.ledger_enabled) + evidence_on = ledger_enabled(ctx.run) + exec_no = next_exec(state, ctx.node.id) + #: 本节点查成功的库:编号 Q1… 只在本节点内有效,cite_fields 抽取靠它找快照 + queries: list[dict[str, Any]] = [] + #: 要记进证据台账的条目,按调用顺序 + entries: list[dict[str, Any]] = [] max_steps = (guard.limits.max_steps if guard else min(int(ctx.cfg("max_steps", 12) or 12), settings.max_agent_steps)) total_usage: dict[str, Any] = {"input_tokens": 0, "output_tokens": 0, "cost_usd": 0.0, "calls": 0} @@ -479,6 +491,10 @@ async def run_agent(state: GraphState, ctx: NodeContext) -> dict[str, Any]: ) except Exception: # noqa: BLE001 snapshot_id = None + # 查库成功时工具交回的 JSON 里带着查询快照的工件 id:记进返回值(进 checkpoint,重放拿到的 + # 是同一份)和 tool.end(在封存范围内,证据接口从这里出发找快照)。只认数据源工具:那个 id 是 + # 我们自己落的;MCP、自定义工具的文字来自外面,拼一个同样形状的 JSON 就能把任意快照塞进封存范围 + query = query_entry_fields(content) if evidence_on and ok and name.startswith(QUERY_PREFIX) else None ctx.emit( EventType.TOOL_END if ok else EventType.TOOL_ERROR, tool=name, @@ -486,9 +502,13 @@ async def run_agent(state: GraphState, ctx: NodeContext) -> dict[str, Any]: duration_ms=elapsed, preview=content[:2000], artifact=snapshot_id, + **({"query_artifact": query["artifact"]} if query else {}), **extra, ) - return {"content": content, "ok": ok, "duration_ms": elapsed} + outcome: dict[str, Any] = {"content": content, "ok": ok, "duration_ms": elapsed} + if evidence_on: + outcome.update(snapshot=snapshot_id, query_artifact=query["artifact"] if query else None) + return outcome @task async def gate_step(name: str, args: dict[str, Any], call_id: str) -> dict[str, Any]: @@ -499,6 +519,30 @@ async def run_agent(state: GraphState, ctx: NodeContext) -> dict[str, Any]: ctx.emit(EventType.TOOL_GATED, tool=name, call_id=call_id, **gate.event()) return {"allowed": gate.allowed, **gate.usage} + @task + async def extract_step(payload: list[BaseMessage], schema: dict[str, Any]) -> dict[str, Any]: + # cite_fields 的结构化抽取:进 checkpoint,节点重放时不再调用、不再计费。失败也在这里收住、 + # 作为结果返回——agent 的结论还在,字段记空值、发警告,而不是把整个节点赔进去。 + # thinking 关掉:结构化输出靠强制工具调用,和思考模式不兼容,抽取也用不着思考 + spec = model_spec.model_copy(update={ + "thinking": "off", "max_tokens": max(int(ctx.cfg("max_tokens") or 0), 4096)}) + started = time.perf_counter() + ctx.emit(EventType.LLM_START, model=model_id, structured=True, purpose="cite_fields", + message_count=len(payload)) + try: + async with SessionLocal() as session: + extractor, _ = await get_chat_model(session, spec) + got = await extractor.with_structured_output(schema, include_raw=True).ainvoke(payload) + except Exception as e: # noqa: BLE001 + ctx.emit(EventType.LLM_END, agent=ctx.node.title, model=model_id, purpose="cite_fields", + duration_ms=int((time.perf_counter() - started) * 1000), error=describe_exception(e)) + return {"error": f"结构化抽取没跑成:{explain_model_error(e, model_id)}"} + raw, parsed = _split_structured(got) + usage = _usage_of(raw if raw is not None else AIMessage(content=""), model_id) + ctx.emit(EventType.LLM_END, agent=ctx.node.title, model=model_id, purpose="cite_fields", + duration_ms=int((time.perf_counter() - started) * 1000), **usage) + return {"parsed": parsed, **usage} + final_text = "" #: 模型把工具调用写成了文字:纠正过一次了没有 nudged = False @@ -610,8 +654,13 @@ async def run_agent(state: GraphState, ctx: NodeContext) -> dict[str, Any]: outcome = await tool_step(step, name, args, call_id) content = outcome["content"] - transcript.append({"tool": name, "args": args, "ok": outcome["ok"], - "duration_ms": outcome["duration_ms"], "result": content[:4000]}) + record = {"tool": name, "args": args, "ok": outcome["ok"], + "duration_ms": outcome["duration_ms"], "result": content[:4000]} + if evidence_on: + # 编号只取决于 tool_step 的返回值(重放时取自 checkpoint),重放出来的还是这一套 + content = _record_call(ctx.node.id, exec_no, call_id, name, args, outcome, record, + queries, entries) + transcript.append(record) messages.append(ToolMessage(content=content, tool_call_id=call_id)) if guard: guard.record(step, name, args, outcome["ok"], content) @@ -699,6 +748,21 @@ async def run_agent(state: GraphState, ctx: NodeContext) -> dict[str, Any]: final_text = f"{final_text}\n\n({hint})".strip() if final_text else f"({hint})" ctx.emit(EventType.LOG, level="warn", message=hint, code="step_limit", **limited) + cited: tuple[dict[str, Any], dict[str, Any]] | None = None + schema = _output_schema(ctx) if evidence_on and ctx.cfg("cite_fields") is True else None + if schema is not None and (fields := cited_fields(schema)): + # 结构化抽取只能追加在循环之后:循环里的 task 按调用位置从 checkpoint 取回,插一个进去, + # 停在审批上的运行恢复时位置就错开了 + extracted: dict[str, Any] = {"error": "这个节点没有查成功的库,没有可以核对出处的数据"} + if queries: + extracted = await extract_step(_extract_messages(final_text, queries, fields), cited_schema(schema)) + for key in ("input_tokens", "output_tokens", "calls"): + total_usage[key] += int(extracted.get(key) or 0) + total_usage["cost_usd"] = round(total_usage["cost_usd"] + float(extracted.get("cost_usd") or 0), 6) + cited = verify_cited_fields(extracted, fields, queries) + for code, message, names in _field_warnings(*cited, extracted.get("error")): + await once(ctx, EventType.LOG, level="warn", code=code, message=message, fields=names) + total_usage["total_tokens"] = total_usage["input_tokens"] + total_usage["output_tokens"] result = { "text": final_text, @@ -708,13 +772,405 @@ async def run_agent(state: GraphState, ctx: NodeContext) -> dict[str, Any]: # 没等到模型自己给结论就收了尾:下游校验失败时拿它说明根源(human.py) **({"limited": settle_reason} if settle_reason else {}), } + if cited is not None: + result["data"], result["data_evidence"] = cited updates: dict[str, Any] = { "nodes": {ctx.node.id: result}, "usage": total_usage, } + if entries: + updates["evidence"] = entries if ctx.cfg("emit_message", True): updates["messages"] = [AIMessage(content=final_text or "(空)")] var_name = ctx.cfg("assign_to", "") if var_name: - updates["vars"] = {var_name: final_text} + # 开了 cite_fields 的,变量里是核对过的 data(值以快照为准),下游口径卡按字段取 + updates["vars"] = {var_name: cited[0] if cited is not None else final_text} return updates + + +# -------------------------------------------------------------------------- +# 证据台账与 cite_fields:agent 查到的数怎么变成能核对的证据 +# -------------------------------------------------------------------------- + + +def _record_call(node_id: str, exec_no: int, call_id: str, name: str, args: dict[str, Any], + outcome: dict[str, Any], record: dict[str, Any], queries: list[dict[str, Any]], + entries: list[dict[str, Any]]) -> str: + """把一次执行过的工具调用记进台账、调用记录和本节点的查询清单,返回给模型看的内容。 + + 查库成功的结果前面加一行「【证据 Q1】」:模型在循环里就知道每次查询的编号,cite_fields + 抽取时照这个编号写出处。编号只在本节点内有效;报告目录里的 Q1… 按全局台账另编,两边 + 靠工件 id 对应,不靠编号。 + """ + content = outcome["content"] + record["call_id"] = call_id + snapshot, artifact = outcome.get("snapshot"), outcome.get("query_artifact") + if snapshot: + record["via"] = snapshot + entries.append({"kind": "tool", "node_id": node_id, "exec": exec_no, "artifact": snapshot, + "call_id": call_id, "tool": name}) + if not artifact or not name.startswith(QUERY_PREFIX): + return content + try: + data = json.loads(content) + except ValueError: + data = None + fields = query_entry_fields(data) + if fields is None: + return content + alias = f"Q{len(queries) + 1}" + record.update(alias=alias, artifact=artifact) + entries.append({"kind": "query", "node_id": node_id, "exec": exec_no, "artifact": artifact, + **({"via": snapshot} if snapshot else {}), "call_id": call_id, "tool": name, + "source": fields["source"], "columns": fields["columns"], "rows": fields["rows"], + "truncated": fields["truncated"]}) + queries.append({"alias": alias, "call_id": call_id, "tool": name, "sql": str(args.get("sql") or ""), + "artifact": artifact, "via": snapshot, "columns": fields["columns"], + "rows": data.get("rows") or [], "truncated": fields["truncated"]}) + return f"【证据 {alias}】\n{content}" + + +def _output_schema(ctx: NodeContext) -> dict[str, Any] | None: + """节点的 output_schema(对象形状)。画布里存成 JSON 文本的也认。""" + schema = ctx.cfg("output_schema") + if isinstance(schema, str) and schema.strip(): + try: + schema = json.loads(schema) + except ValueError: + return None + if isinstance(schema, dict) and (schema.get("type") == "object" or "properties" in schema): + return schema + return None + + +def _is_object(schema: Any) -> bool: + return isinstance(schema, dict) and (schema.get("type") == "object" or "properties" in schema) + + +def cited_fields(schema: dict[str, Any], prefix: str = "") -> list[tuple[str, dict[str, Any]]]: + """output_schema 里要标出处的字段:(点号路径, 字段的 schema),按声明顺序,嵌套对象展开。""" + out: list[tuple[str, dict[str, Any]]] = [] + for key, sub in (schema.get("properties") or {}).items(): + path = f"{prefix}{key}" + if _is_object(sub): + out.extend(cited_fields(sub, f"{path}.")) + else: + out.append((path, sub if isinstance(sub, dict) else {})) + return out + + +#: 一个值在哪次查询的哪一格 +_FROM_CELL: dict[str, Any] = { + "type": "object", + "description": "这个值在哪次查询的哪一格:call 是【证据 Qn】里的编号,row 是行号(从 0 数),column 是列名", + "properties": {"call": {"type": "string"}, "row": {"type": "integer"}, "column": {"type": "string"}}, + "required": ["call", "row", "column"], +} + + +def _is_array(schema: Any) -> bool: + return isinstance(schema, dict) and schema.get("type") == "array" + + +def _row_columns(schema: dict[str, Any]) -> list[str] | None: + """数组里是对象时,对象的字段(都得是标量);不是对象返回 None。""" + items = schema.get("items") + if not _is_object(items): + return None + return list((items.get("properties") or {}).keys()) + + +def _from_rows(schema: dict[str, Any]) -> dict[str, Any]: + """数组字段按行映射:一段连续的行,标量数组对应一列,对象数组的每个字段各对应一列。""" + fields = _row_columns(schema) + where = ({"columns": {"type": "object", "description": "对象的每个字段取自哪一列:{字段: 列名}", + "properties": {f: {"type": "string"} for f in fields}, "required": fields}} + if fields is not None else {"column": {"type": "string"}}) + return {"type": "object", + "description": "这组值在哪次查询的哪几行:call 是【证据 Qn】里的编号,rows 写行号范围(从 0 数," + "如 0-4,数组第 k 个元素就是第 起始+k 行)", + "properties": {"call": {"type": "string"}, "rows": {"type": "string"}, **where}, + "required": ["call", "rows", *where]} + + +def cited_schema(schema: dict[str, Any]) -> dict[str, Any]: + """抽取用的 schema:每个标量字段改写成 {value, from},查不到时两个都是 null。 + + 数组字段整个是一个 {value, from}:from 写一段连续的行(rows: "0-4")和对应的列。 + """ + + def rewrite(node: dict[str, Any]) -> dict[str, Any]: + if _is_object(node): + props = node.get("properties") or {} + return {"type": "object", **({"description": node["description"]} if node.get("description") else {}), + "properties": {k: rewrite(v if isinstance(v, dict) else {}) for k, v in props.items()}, + "required": list(props)} + source = _from_rows(node) if _is_array(node) else _FROM_CELL + return {"type": "object", + "properties": {"value": {"anyOf": [node, {"type": "null"}]}, + "from": {"anyOf": [source, {"type": "null"}]}}, + "required": ["value", "from"]} + + # 走工具调用的结构化输出要求函数名只含字母、数字、下划线 + return {"title": "cited_fields", **rewrite(schema)} + + +EXTRACT_SYSTEM = ( + "你是数据抽取员:把智能体查到的数据按给定结构填好,每个字段都写出处。\n" + "- value 是字段的值;from 写它在哪次查询的哪一格:call 是【证据 Qn】里的编号,row 是行号(从 0 数)," + "column 是列名。\n" + "- 只能用下面查询结果里真实出现的值。查不到就把 value 和 from 都填 null,禁止估算," + "禁止用 0 或空字符串代替没查到的值。\n" + "- 不要做计算:比率、增幅、合计这类要算出来的量填 null,交给口径卡去算。" +) +#: 抽取提示里每次查询最多列出几行、整段最多多少字 +_DIGEST_ROWS, _DIGEST_BUDGET = 50, 16000 + + +def _digest(queries: list[dict[str, Any]]) -> str: + parts: list[str] = [] + for q in queries: + lines = [f"【证据 {q['alias']}】{q['tool']}"] + if q["sql"]: + lines.append(f"SQL:{q['sql'][:300]}") + columns = q["columns"] + lines.append(f"列:{', '.join(columns)}") + for r, record in enumerate(q["rows"][:_DIGEST_ROWS]): + values = record if isinstance(record, list) else [record.get(c) for c in columns] \ + if isinstance(record, dict) else [record] + lines.append(f"r{r}:" + ",".join(f"{c}={json.dumps(v, ensure_ascii=False, default=str)}" + for c, v in zip(columns, values))) + if len(q["rows"]) > _DIGEST_ROWS: + lines.append(f"…共 {len(q['rows'])} 行,只列出前 {_DIGEST_ROWS} 行") + elif not q["rows"]: + lines.append("(0 行)") + parts.append("\n".join(lines)) + text = "\n\n".join(parts) + return text if len(text) <= _DIGEST_BUDGET else text[:_DIGEST_BUDGET] + "\n…(查询结果太长,后面的省略了)" + + +def _extract_messages(final_text: str, queries: list[dict[str, Any]], + fields: list[tuple[str, dict[str, Any]]]) -> list[BaseMessage]: + wanted = "\n".join( + f"- {path}({sub.get('type') or '任意'})" + (f":{sub['description']}" if sub.get("description") else "") + for path, sub in fields) + return [ + SystemMessage(content=EXTRACT_SYSTEM), + HumanMessage(content=( + f"智能体的结论(只作参考,数以查询结果为准):\n{final_text or '(没有结论)'}\n\n" + f"查询结果:\n{_digest(queries)}\n\n要填的字段:\n{wanted}" + )), + ] + + +def _split_structured(got: Any) -> tuple[BaseMessage | None, Any]: + """with_structured_output(include_raw=True) 的结果拆成 (原始回复, 解析结果)。 + + 不认 include_raw 的模型(比如 mock)直接给解析结果。 + """ + if isinstance(got, dict) and isinstance(got.get("raw"), BaseMessage) and "parsed" in got: + raw, parsed = got["raw"], got["parsed"] + else: + raw, parsed = None, got + if parsed is not None and not isinstance(parsed, (dict, list)) and hasattr(parsed, "model_dump"): + parsed = parsed.model_dump() + return raw, parsed + + +def _typed(raw: Any, schema: Any, kind: str | None = None) -> Any: + """快照里的一格 → 按字段声明的类型给出的值(data 里放的就是它)。 + + 数据源把 DECIMAL 落成文本,小数位为 0 的是 "45678":声明成 number / integer 的换成数,下游口径卡 + 才能拿它做除法;声明成 string 的原样是文本,"2026" 不会变成 2026。没声明类型的按 cell_value, + kind(查询时记下的列类型)是 text 的原样是文本;老快照没记类型就按值猜。 + 声明成 integer 而快照里是 3.0 这样的整值,给 3;是 45678.5 就照实给 45678.5,不替它取整。 + """ + declared = schema.get("type") if isinstance(schema, dict) else None + types = {declared} if isinstance(declared, str) else set(declared) if isinstance(declared, list) else set() + value = cell_value(raw, kind) + if types & {"number", "integer"}: + loose = numeric_text(raw, loose=True) + value = value if loose is None else loose + if "integer" in types and isinstance(value, float) and value.is_integer(): + value = int(value) + elif "string" in types and isinstance(raw, str): + value = raw + return value + + +def _dig(data: Any, path: str) -> Any: + for key in path.split("."): + data = data.get(key) if isinstance(data, dict) else None + return data + + +def _plant(data: dict[str, Any], path: str, value: Any) -> None: + *heads, last = path.split(".") + for key in heads: + data = data.setdefault(key, {}) + data[last] = value + + +def verify_cited_fields(extracted: dict[str, Any], fields: list[tuple[str, dict[str, Any]]], + queries: list[dict[str, Any]], loader: Any = None + ) -> tuple[dict[str, Any], dict[str, Any]]: + """确定性核对:按 from 取出快照里的那一格,以快照为准。返回 (data, data_evidence)。 + + 每个字段的结果是四种之一: + - verified:和模型报的值一致,取快照的值 + - mismatch:不一致,取快照的值,model_value 留着模型报的 + - unresolved:出处取不到(没有这次查询、行列不存在、快照取不回来),值为 None,reason 写原因 + - missing:模型说查不到(from 为 null),值为 None + 值为 None 就是 None:永远不兜底成 0,也不采信没有出处的模型值。 + """ + load = loader or artifact_store.load + by_alias = {q["alias"]: q for q in queries} + snapshots: dict[str, tuple[Any, str | None]] = {} + + def snapshot_of(q: dict[str, Any]) -> tuple[Any, str | None]: + if q["artifact"] not in snapshots: + try: + content = load(q["artifact"]) + snapshots[q["artifact"]] = (content, None if content is not None else "查询快照取不回来") + except ValueError: + snapshots[q["artifact"]] = (None, "查询快照和哈希对不上,疑似被改过") + return snapshots[q["artifact"]] + + parsed = extracted.get("parsed") + failed = extracted.get("error") or (None if isinstance(parsed, dict) else "抽取结果不是一个对象") + data: dict[str, Any] = {} + evidence: dict[str, Any] = {} + for path, sub in fields: + _plant(data, path, None) + if failed: + evidence[path] = {"status": "unresolved", "reason": str(failed)} + continue + node = _dig(parsed, path) + if not isinstance(node, dict) or "from" not in node: + evidence[path] = {"status": "unresolved", "reason": "抽取结果里没有按 {value, from} 写出这个字段"} + continue + told, source = node.get("value"), node.get("from") + if source is None: + evidence[path] = {"ref": None, "status": "missing", + **({"model_value": told} if told is not None else {})} + continue + if _is_array(sub): + value, evidence[path] = _verify_rows(sub, told, source, by_alias, snapshot_of) + _plant(data, path, value) + continue + call = str(source.get("call") or "").strip() if isinstance(source, dict) else "" + row, column = (source.get("row"), source.get("column")) if isinstance(source, dict) else (None, None) + if isinstance(row, str) and row.strip().isdigit(): + row = int(row) # 有的模型把整数写成 "0":意思没有歧义 + elif isinstance(row, float) and row.is_integer(): + row = int(row) + ref = f"{call}.r{row}.{column}" if call and isinstance(row, int) and column not in (None, "") else None + q = by_alias.get(call) + if q is None: + evidence[path] = {"ref": ref, "call": call, "status": "unresolved", + "reason": f"本节点没有 {call or '(空)'} 这次查询,能用的是 " + + ("、".join(by_alias) or "(没有)")} + continue + base = {"ref": ref, "call": call, "artifact": q["artifact"], "via": q["via"]} + content, why = snapshot_of(q) + if why: + evidence[path] = {**base, "status": "unresolved", "reason": f"{call} 的{why}"} + continue + try: + raw, name = locate_cell(content, row, column) + except CellError as e: + evidence[path] = {**base, "status": "unresolved", "reason": f"{call} {e}"} + continue + truth = _typed(raw, sub, column_kind(content, name)) + locator = {"row": row, "column": name} + cited = {**base, "ref": f"{call}.r{row}.{name}", "locator": locator, + "eid": cell_eid(q["artifact"], row, name)} + if same_value(told, truth): + cited["status"] = "verified" + else: + cited.update(status="mismatch", model_value=told) + _plant(data, path, truth) + evidence[path] = cited + return data, evidence + + +_ROWS = re.compile(r"^\s*(\d+)\s*(?:-\s*(\d+))?\s*$") + + +def _verify_rows(schema: dict[str, Any], told: Any, source: Any, by_alias: dict[str, dict[str, Any]], + snapshot_of: Any) -> tuple[Any, dict[str, Any]]: + """数组字段:按 from 的行范围和列从快照里取出整组值,以快照为准。返回 (值, 出处)。""" + if not isinstance(source, dict): + return None, {"status": "unresolved", "reason": "数组的 from 要写成 {call, rows, column(或 columns)}"} + call = str(source.get("call") or "").strip() + span = _ROWS.match(str(source.get("rows") or "")) + fields = _row_columns(schema) + wanted = source.get("columns") if fields is not None else source.get("column") + if fields is not None and not (isinstance(wanted, dict) and all(isinstance(wanted.get(f), str) for f in fields)): + return None, {"call": call, "status": "unresolved", "reason": "对象数组要写 columns:每个字段取自哪一列"} + if fields is None and not isinstance(wanted, str): + return None, {"call": call, "status": "unresolved", "reason": "数组要写 column:这组值取自哪一列"} + if span is None: + return None, {"call": call, "status": "unresolved", "reason": "rows 要写成 0-4 这样的行号范围(从 0 数)"} + first, last = int(span.group(1)), int(span.group(2) or span.group(1)) + q = by_alias.get(call) + if q is None or last < first: + reason = (f"rows 要从小到大写,写的是 {source.get('rows')}" if q is not None else + f"本节点没有 {call or '(空)'} 这次查询,能用的是 " + ("、".join(by_alias) or "(没有)")) + return None, {"call": call, "status": "unresolved", "reason": reason} + base = {"call": call, "artifact": q["artifact"], "via": q["via"]} + content, why = snapshot_of(q) + if why: + return None, {**base, "status": "unresolved", "reason": f"{call} 的{why}"} + columns = {f: wanted[f] for f in fields} if fields is not None else {"": wanted} + items = schema.get("items") if isinstance(schema.get("items"), dict) else {} + kinds = ({f: (items.get("properties") or {}).get(f) for f in fields} if fields is not None else {"": items}) + truth: list[Any] = [] + names: dict[str, str] = {} + try: + for r in range(first, last + 1): + one = {} + for f, col in columns.items(): + raw, names[f] = locate_cell(content, r, col) + one[f] = _typed(raw, kinds[f], column_kind(content, names[f])) + truth.append(one if fields is not None else one[""]) + except CellError as e: + return None, {**base, "status": "unresolved", "reason": f"{call} {e}"} + locator: dict[str, Any] = {"rows": [first, last], + **({"columns": names} if fields is not None else {"column": names[""]})} + cited = {"ref": f"{call}.r{first}-{last}" + ("" if fields is not None else f".{names['']}"), **base, + "locator": locator, "eid": make_eid("rows", q["artifact"], locator)} + same = isinstance(told, list) and len(told) == len(truth) and all( + (isinstance(m, dict) and all(same_value(m.get(f), t[f]) for f in t)) if isinstance(t, dict) + else same_value(m, t) + for m, t in zip(told, truth)) + cited.update({"status": "verified"} if same else {"status": "mismatch", "model_value": told}) + return truth, cited + + +def _shown(value: Any) -> str: + text = json.dumps(value, ensure_ascii=False, default=str) if not isinstance(value, str) else value + return text if len(text) <= 40 else text[:40] + "…" + + +def _field_warnings(data: dict[str, Any], evidence: dict[str, Any], + failed: str | None) -> list[tuple[str, str, list[str]]]: + """核对结果里要让人看见的:(code, message, 字段)。mismatch 和 unresolved 各一条;missing 是模型 + 照实说没查到,值已经是空的,不另发警告。""" + out: list[tuple[str, str, list[str]]] = [] + mismatched = [(p, e) for p, e in evidence.items() if e.get("status") == "mismatch"] + if mismatched: + detail = ";".join(f"{p} 模型报 {_shown(e.get('model_value'))},快照是 {_shown(_dig(data, p))}" + for p, e in mismatched[:6]) + out.append(("agent_field_mismatch", + f"有 {len(mismatched)} 个字段和查询快照对不上,已按快照取值:{detail}", [p for p, _ in mismatched])) + unresolved = [(p, e) for p, e in evidence.items() if e.get("status") == "unresolved"] + if unresolved: + if failed: + message = f"{failed}。{len(unresolved)} 个字段都记为空值(没有兜底成 0)" + else: + detail = ";".join(f"{p}({e.get('reason')})" for p, e in unresolved[:6]) + message = f"有 {len(unresolved)} 个字段核对不了出处,记为空值(没有兜底成 0):{detail}" + out.append(("agent_field_unverified", message, [p for p, _ in unresolved])) + return out diff --git a/backend/app/engine/nodes/metrics.py b/backend/app/engine/nodes/metrics.py index 599aa387..18ccb461 100644 --- a/backend/app/engine/nodes/metrics.py +++ b/backend/app/engine/nodes/metrics.py @@ -1,14 +1,36 @@ from __future__ import annotations +import json import math import re from typing import Any +from app.core import artifact_store from app.core.errors import describe_exception from app.core.events import EventType from app.engine.context import NodeContext, NodeError -from app.engine.evidence import DEFAULT_FORMAT, FORMATS, MISSING, RenderError, is_number, render_number -from app.engine.expressions import ExpressionError, eval_expression, leaf_refs, parse_expression, substitute +from app.engine.evidence import ( + DEFAULT_FORMAT, + FORMATS, + MISSING, + RenderError, + cell_eid, + is_number, + next_exec, + render_number, +) +from app.engine.expressions import ( + CellError, + ExpressionError, + cell_parts, + cell_value, + eval_expression, + leaf_refs, + locate_cell, + parse_expression, + same_value, + substitute, +) from app.engine.state import GraphState, template_context @@ -24,12 +46,18 @@ async def run_metrics(state: GraphState, ctx: NodeContext) -> dict[str, Any]: 数字点开,一路能追到这里。 """ definitions = ctx.cfg("metrics", []) or [] + caliber_raw, caliber_version = ctx.cfg("caliber", "") or ctx.node.title, str(ctx.cfg("caliber_version", "") or "v1") + source: dict[str, Any] | None = None + if ctx.config.get("caliber_from") not in (None, "", {}): + # 钉住别的工作流某一版里的口径卡:名字、版本、指标定义都按那一版,本地写的不算 + pinned, source = await _pinned_caliber(ctx) + definitions = pinned["metrics"] + caliber_raw, caliber_version = pinned["caliber"], pinned["caliber_version"] if not definitions: raise NodeError(ctx.node.id, "口径卡没有定义任何指标") tctx = template_context(state) - caliber = ctx.render_str(ctx.cfg("caliber", "") or ctx.node.title, state) - caliber_version = str(ctx.cfg("caliber_version", "") or "v1") + caliber = ctx.render_str(caliber_raw, state) # 缺输入时怎么办。默认 fail:和以前一样整张卡失败;null 则这个指标记为空值、 # 交给出具契约的 required / expected 去判档 on_missing = str(ctx.cfg("on_missing", "fail") or "fail") @@ -98,7 +126,7 @@ async def run_metrics(state: GraphState, ctx: NodeContext) -> dict[str, Any]: "decimals": decimals, "format": display["format"], "rendered": _rendered(value, definition, display), - "inputs": [_input_of(path, values[path], state, ctx) for path in paths], + "inputs": [_input_of(path, values[path], state, ctx, tctx) for path in paths], "substituted": substituted, "recompute_ok": _recompute(substituted, tctx, value, decimals), # 值是空的就是缺输入,不论 on_missing 是哪一种:fail 模式下直接取到空值的 @@ -123,6 +151,9 @@ async def run_metrics(state: GraphState, ctx: NodeContext) -> dict[str, Any]: "caliber_version": caliber_version, "metrics": metrics, } + if source: + # 指标定义来自哪个工作流的哪一版:证据面板的指标步骤据此写出处,升版处置按它比版本 + card["source"] = source artifact = await _store(card, caliber, caliber_version, ctx) result = { **card, @@ -137,16 +168,15 @@ async def run_metrics(state: GraphState, ctx: NodeContext) -> dict[str, Any]: updates: dict[str, Any] = {"nodes": {ctx.node.id: result}} if artifact: result["artifact"] = artifact - executed = sum(1 for t in state.get("trail") or [] - if t.get("node_id") == ctx.node.id and "error" not in t) updates["evidence"] = [{ "kind": "metric_set", "node_id": ctx.node.id, - "exec": executed + 1, + "exec": next_exec(state, ctx.node.id), "artifact": artifact, "caliber": caliber, "version": caliber_version, "metrics": [m["id"] for m in metrics], + **({"source": source} if source else {}), }] var_name = ctx.cfg("assign_to", "") if var_name: @@ -154,6 +184,60 @@ async def run_metrics(state: GraphState, ctx: NodeContext) -> dict[str, Any]: return updates +async def _pinned_caliber(ctx: NodeContext) -> tuple[dict[str, Any], dict[str, Any]]: + """caliber_from 指向的那张口径卡:({caliber, caliber_version, metrics}, 来源)。 + + 从那个工作流的 WorkflowVersion 快照里取——版本快照不可变,同一份引用每次取到的定义都一样, + 审批恢复后节点重放也还是这一份。取不到就让节点失败并说清是哪一环断了:悄悄退回本地 + 定义,等于换了口径还照常出具。 + """ + from sqlalchemy import select + + from app.db.base import SessionLocal + from app.db.models import Workflow, WorkflowVersion + from app.engine.schema import type_label + + ref = ctx.config.get("caliber_from") + wf_id = str(ref.get("workflow_id") or "").strip() if isinstance(ref, dict) else "" + node_id = str(ref.get("node_id") or "").strip() if isinstance(ref, dict) else "" + version = _integer(ref.get("workflow_version")) if isinstance(ref, dict) else None + if not wf_id or not node_id or version is None or version < 1: + raise NodeError(ctx.node.id, "caliber_from 要写 {workflow_id, workflow_version, node_id}:钉住哪个工作流" + f"的哪一版里的哪张口径卡,写的是 {ref!r}") + async with SessionLocal() as session: + workflow = await session.get(Workflow, wf_id) + if workflow is None: + raise NodeError(ctx.node.id, f"caliber_from 钉住的工作流 {wf_id} 不存在(可能已经被删了):" + "在节点里重新选一张口径卡") + snapshot = (await session.execute(select(WorkflowVersion).where( + WorkflowVersion.workflow_id == wf_id, WorkflowVersion.version == version))).scalar_one_or_none() + name = workflow.name + if snapshot is None: + raise NodeError(ctx.node.id, f"caliber_from 钉住的「{name}」没有 v{version} 这个版本:在节点里重新选版本") + found = next((n for n in (snapshot.graph or {}).get("nodes") or [] + if isinstance(n, dict) and n.get("id") == node_id), None) + if found is None: + raise NodeError(ctx.node.id, f"「{name}」v{version} 里没有节点 {node_id}:在节点里重新选一张口径卡") + data = found.get("data") if isinstance(found.get("data"), dict) else {} + title = data.get("label") or node_id + if found.get("type") != "metrics": + raise NodeError(ctx.node.id, f"caliber_from 指向的「{name}」v{version} 里的「{title}」不是口径卡," + f"是「{type_label(found.get('type'))}」") + cfg = data.get("config") if isinstance(data.get("config"), dict) else {} + origin = cfg.get("caliber_from") + if origin not in (None, "", {}): + root = origin.get("workflow_id") if isinstance(origin, dict) else None + async with SessionLocal() as session: + upstream = await session.get(Workflow, str(root)) if root else None + where = (f"「{upstream.name}」v{origin.get('workflow_version')} 的 {origin.get('node_id')}" + if upstream is not None else f"{origin!r}") + raise NodeError(ctx.node.id, f"「{name}」v{version} 里的「{title}」自己也是钉住别处的口径卡(源头是 {where}):" + "直接钉住源头那张,升版处置才比得对版本") + source = {"workflow_id": wf_id, "workflow_version": version, "node_id": node_id} + return {"caliber": cfg.get("caliber") or title, "caliber_version": str(cfg.get("caliber_version") or "v1"), + "metrics": cfg.get("metrics") or []}, source + + #: 新写法的小数位范围。负数是取整到十位、百位(round(x, -2)) _DECIMALS_RANGE = 15 @@ -248,12 +332,19 @@ _VIA = {"input": "input", "code": "code", "agent": "agent", "llm": "llm", "tool" "memory": "memory"} -def _input_of(path: str, value: Any, state: GraphState, ctx: NodeContext) -> dict[str, Any]: +def _input_of(path: str, value: Any, state: GraphState, ctx: NodeContext, + tctx: dict[str, Any] | None = None) -> dict[str, Any]: """一个输入的来历:{path, value, node_id, via, field?, role?, status}。 vars.X 要反查是谁写的:assign_to 为 X 的节点里,按执行顺序最后跑的那一个—— 和状态里 vars 的覆盖顺序一致。入口节点把输入字段也写进 vars。 + + 两种来历能追到查询快照里的那一格: + - cell(nodes.fetch, 0, 'gmv'):via tool_cell,带 locator、artifact、eid,按快照核对 + - agent 开了 cite_fields 的字段:via agent_field,接上它的 data_evidence(eid、核对状态) """ + if (parts := cell_parts(path)) is not None: + return _cell_input(path, value, parts, state, ctx, tctx or template_context(state)) spec = ctx.run.spec nodes = spec.node_map() node_id: str | None = None @@ -281,6 +372,136 @@ def _input_of(path: str, value: Any, state: GraphState, ctx: NodeContext) -> dic # 沙箱算出来的数喂给口径卡:记下它自认是「取数」还是「计算」,治理规则按它判 entry["role"] = str(producer.config.get("evidence_role") or "compute") entry["status"] = "ok" if value is not None else "missing" + if producer is not None and str(producer.type) == "agent" and field: + cited = _agent_field(state, producer.id, root if head else "", field) + if cited is not None: + entry.update(cited) + return entry + + +def _field_key(field: str) -> str: + """vars.kpi['gmv'] 和 vars.kpi.gmv 是同一个字段:下标写法换成点号。""" + return re.sub(r"\[\s*['\"]([^'\"\]]+)['\"]\s*\]", r".\1", field).strip(".") + + +def _agent_field(state: GraphState, node_id: str, root: str, field: str) -> dict[str, Any] | None: + """agent 字段的出处:{via: agent_field, field, ref, artifact, locator, eid, status, model_value?, reason?}。 + + assign_to 写进 vars 的是 data 本身,nodes. 下要多走一层 data。没开 cite_fields + (或者是老运行)的 agent 没有 data_evidence,返回 None,来历照旧记成 agent。 + """ + output = (state.get("nodes") or {}).get(node_id) + evidence = output.get("data_evidence") if isinstance(output, dict) else None + if not isinstance(evidence, dict): + return None + key = _field_key(field) + if root == "nodes": + if not key.startswith("data."): + return None + key = key[len("data."):] + cited = evidence.get(key) + if not isinstance(cited, dict): + return _agent_element(evidence, key) + out: dict[str, Any] = {"via": "agent_field", "field": key, "status": cited.get("status") or "unresolved"} + for k in ("ref", "artifact", "locator", "eid", "model_value", "reason"): + if k in cited: + out[k] = cited[k] + return out + + +_ELEMENT = re.compile(r"^(?P[^\[\]]+)\[(?P\d+)\](?:\.(?P[^\[\]]+))?$") + + +def _agent_element(evidence: dict[str, Any], key: str) -> dict[str, Any] | None: + """数组字段里的一个元素(vars.kpi.weeks[1].amount):按行映射落到快照里的一格。 + + 数组的出处是一段行 rows=[a, b] 加列;第 k 个元素就是第 a+k 行。整组的核对状态说的是整组: + 别的元素对不上,不等于这一个也对不上。所以这一个元素按它自己那一格再核一遍——模型报的是 + model_value 里的第 k 个(整组 verified 时模型报的就是快照里的值),和快照里那一格比。 + """ + m = _ELEMENT.match(key) + cited = evidence.get(m.group("base")) if m else None + locator = cited.get("locator") if isinstance(cited, dict) else None + if not (isinstance(locator, dict) and isinstance(locator.get("rows"), list) and len(locator["rows"]) == 2): + return None + first, last = locator["rows"] + index, sub = int(m.group("index")), m.group("sub") + row = first + index + column = (locator.get("columns") or {}).get(sub) if sub else locator.get("column") + if row > last or not isinstance(column, str): + return None + call, artifact = str(cited.get("call") or ""), cited.get("artifact") + out: dict[str, Any] = {"via": "agent_field", "field": key, "ref": f"{call}.r{row}.{column}", "artifact": artifact, + "locator": {"row": row, "column": column}, "eid": cell_eid(artifact, row, column)} + try: + truth, _ = locate_cell(artifact_store.load(artifact), row, column) + except (ValueError, CellError, OSError) as e: + out.update(status="unresolved", reason=f"查询快照取不回来或对不上:{e}") + return out + if cited.get("status") == "verified": + out["status"] = "verified" + return out + told = cited.get("model_value") + item = told[index] if isinstance(told, list) and index < len(told) else None + reported = isinstance(told, list) and index < len(told) and (not sub or (isinstance(item, dict) and sub in item)) + element = (item[sub] if sub else item) if reported else None + if reported and same_value(element, truth): + out["status"] = "verified" + else: + # 模型没报这个元素(给的数组短了)就没有 model_value 可留 + out.update(status="mismatch", **({"model_value": element} if reported else {})) + return out + + +def _cell_input(path: str, value: Any, parts: tuple[str, str, str], state: GraphState, ctx: NodeContext, + tctx: dict[str, Any]) -> dict[str, Any]: + """cell() 取的那一格:它来自哪次查询(台账里的 query 条目)、第几行哪一列、快照里是不是这个值。 + + 查询结果经 transform 解析过也认得出来:数据源工具交回的 JSON 里带着快照的工件 id。 + 取不到快照、或者快照里那一格和算的时候用的值不一样,status 如实写,不替它圆。 + """ + source, row_src, column_src = parts + entry: dict[str, Any] = {"path": path, "value": value if value is None or isinstance(value, (int, float, str, bool)) + else _brief(value), "node_id": None, "via": "tool_cell"} + table = _value_of(source, tctx) + row, column = _value_of(row_src, tctx), _value_of(column_src, tctx) + data = table + if isinstance(data, str): + try: + data = json.loads(data) + except ValueError: + data = None + artifact = data.get("artifact") if isinstance(data, dict) else None + queries = [e for e in state.get("evidence") or [] if isinstance(e, dict) and e.get("kind") == "query"] + ledger = next((e for e in reversed(queries) if artifact and e.get("artifact") == artifact), None) + head = _REF_HEAD.match(source) + if ledger is None and head and head.group(1) == "nodes": + # 台账是按工件认的;工具交回的不是带工件 id 的 JSON 时,按取值链上的节点找它最后一次查询 + ledger = next((e for e in reversed(queries) if e.get("node_id") == head.group(2)), None) + if ledger is None: + entry.update(node_id=head.group(2) if head and head.group(1) == "nodes" else None, + status="unresolved", reason="找不到这份数据对应的查询快照:cell() 要取查询工具交回的结果") + return entry + entry["node_id"] = ledger.get("node_id") + artifact = ledger.get("artifact") + try: + raw, name = locate_cell(data if isinstance(data, dict) else table, row, column) + except CellError as e: + entry.update(status="unresolved", reason=f"cell() {e}", artifact=artifact) + return entry + locator = {"row": row, "column": name} + entry.update(locator=locator, artifact=artifact, eid=cell_eid(artifact, row, name)) + try: + snapshot = artifact_store.load(artifact) + truth, _ = locate_cell(snapshot, row, name) + except (ValueError, CellError, OSError) as e: + entry.update(status="unresolved", reason=f"查询快照取不回来或对不上:{e}") + return entry + if cell_value(truth) != cell_value(raw): + # 算的时候用的值和快照不一样(上游改写过这份结果):照实记下快照里的值 + entry.update(status="mismatch", snapshot_value=cell_value(truth)) + return entry + entry["status"] = "verified" return entry diff --git a/backend/app/engine/nodes/report.py b/backend/app/engine/nodes/report.py index 0d34c33b..99b55f45 100644 --- a/backend/app/engine/nodes/report.py +++ b/backend/app/engine/nodes/report.py @@ -25,12 +25,14 @@ from app.core.events import EventType from app.db.base import SessionLocal from app.engine.context import NodeContext, NodeError from app.engine.evidence import ( + CELLS_REASON, MARKER_RULES, StreamRenderer, build_catalog, catalog_prompt, compose_doc, describe_violations, + ledger_enabled, ) from app.engine.nodes.llm import _invoke_streaming, _model_spec, _report_call from app.engine.schema import GraphNode, GraphSpec, NodeType, _ancestors @@ -104,6 +106,36 @@ def report_allowance(spec: GraphSpec, node: GraphNode) -> list[Any]: return allowed +def report_cells_allowed(spec: GraphSpec, node: GraphNode, *, governed: bool) -> bool: + """报告能不能直接引用查询单元格:出口契约的 report_from 指着它、而那份契约不允许的,写作时就不给。 + + 出口复核按契约判(io.py),这里提前照同一条规则建目录:不然写作者照着提示写了单元格、自查 + 通过,到出口才整段判成解析不了——受管出具直接不予出具。 + """ + from app.engine.governance import cells_allowed + + contracts = [other.config.get("contract") for other in spec.nodes if other.type == NodeType.OUTPUT] + return all(cells_allowed(c, governed=governed) for c in contracts + if isinstance(c, dict) and c.get("report_from") == node.id) + + +#: 目录里能被报告引用的几种来源(沙箱代码节点的登记只为说清为什么不能引用,不算) +_CITABLE = {"metric": "口径卡指标", "query": "查询结果", "retrieval": "知识库检索", "input": "运行输入"} + + +def _no_evidence(catalog: dict[str, Any], cells: bool) -> str | None: + """目录里没有可引用的来源时给一句话,说清缺的是哪种;有就返回 None。""" + kinds = {entry.get("kind") for entry in catalog.values()} + if not kinds & set(_CITABLE): + return ("报告撰写节点的上游没有可引用的来源:" + "、".join(_CITABLE.values()) + "都没有," + "报告里的数字都会被判为没有出处。在它前面接取数节点或口径卡,或者检查 metrics_from / evidence_from") + if not cells and not kinds & {"metric", "input"}: + # 只有查询和检索:单元格引用不了,检索片段本来就只能当依据,一个数都没处引 + return (f"报告撰写节点的上游只有查询结果、没有口径卡指标,而这次{CELLS_REASON}:单元格引用不了," + "报告里的数字都会被判为没有出处。在出口契约里写 \"cells\": true,或者把要写的数登记进口径卡") + return None + + # -------------------------------------------------------------------------- # 执行器 # -------------------------------------------------------------------------- @@ -116,10 +148,11 @@ class _RenderedStream: 中间,StreamRenderer 攒到没有未闭合的 [[ 再放;调用结束时 flush 掉剩下的。 """ - def __init__(self, ctx: NodeContext, catalog: dict[str, Any]) -> None: + def __init__(self, ctx: NodeContext, catalog: dict[str, Any], cells: bool = True) -> None: self._ctx = ctx self._catalog = catalog - self._renderer = StreamRenderer(catalog) + self._cells = cells + self._renderer = StreamRenderer(catalog, cells_allowed=cells) def __getattr__(self, name: str) -> Any: return getattr(self._ctx, name) @@ -131,7 +164,7 @@ class _RenderedStream: return if event_type == EventType.LOG and data.get("code") == "stream_fallback": # 流断在半路、退回非流式重来:攒着的半截不再放出去,免得和重来的那份接在一起 - self._renderer = StreamRenderer(self._catalog) + self._renderer = StreamRenderer(self._catalog, cells_allowed=self._cells) self._ctx.emit(event_type, **data) def flush(self) -> None: @@ -183,14 +216,14 @@ def _repair_request(violations: list[dict[str, Any]]) -> str: ) -def _messages(ctx: NodeContext, state: GraphState, catalog: dict[str, Any]) -> list[BaseMessage]: +def _messages(ctx: NodeContext, state: GraphState, catalog: dict[str, Any], cells: bool = True) -> list[BaseMessage]: system = ctx.render_str(ctx.cfg("system", ""), state) instructions = ctx.render_str(ctx.cfg("instructions", ""), state).strip() \ or "根据下面的证据写一份简洁的报告,先总后分。" return [ SystemMessage(content="\n\n".join(p for p in (system, ROLE) if p)), HumanMessage(content=( - f"{instructions}\n\n{catalog_prompt(catalog)}\n\n{MARKER_RULES}\n\n" + f"{instructions}\n\n{catalog_prompt(catalog, cells_allowed=cells)}\n\n{MARKER_RULES}\n\n" "只输出报告正文(Markdown),不要写撰写说明。" )), ] @@ -223,19 +256,23 @@ async def run_report(state: GraphState, ctx: NodeContext) -> dict[str, Any]: spec = ctx.run.spec catalog = report_catalog(state, spec, ctx.node) allow = report_allowance(spec, ctx.node) - if not any(entry.get("kind") == "metric" for entry in catalog.values()): - ctx.emit(EventType.LOG, level="warn", code="report_no_evidence", - message="报告撰写节点的上游没有可引用的口径卡指标:报告里的数字都会被判为没有出处。" - "在它前面接一张口径卡,或者检查 metrics_from") + cells = True + if ledger_enabled(ctx.run): + # 升级前发起的运行没有查询条目,单元格无从谈起,也就不多查这一次 + from app.engine.governance import governed_formal - messages = _messages(ctx, state, catalog) + cells = report_cells_allowed(spec, ctx.node, governed=await governed_formal(ctx.run.run_id)) + if warning := _no_evidence(catalog, cells): + ctx.emit(EventType.LOG, level="warn", code="report_no_evidence", message=warning) + + messages = _messages(ctx, state, catalog, cells) started = time.perf_counter() spent: list[dict[str, Any]] = [] nudged = False async def draft(payload: list[BaseMessage]) -> tuple[BaseMessage, str]: nonlocal nudged - stream = _RenderedStream(ctx, catalog) + stream = _RenderedStream(ctx, catalog, cells) began = time.perf_counter() response = await _invoke_streaming(model, payload, stream, model_id) # type: ignore[arg-type] stream.flush() @@ -255,7 +292,8 @@ async def run_report(state: GraphState, ctx: NodeContext) -> dict[str, Any]: return response, text def compose(raw: str) -> dict[str, Any]: - return compose_doc(raw, catalog, node_id=ctx.node.id, run_id=ctx.run.run_id, allow_numbers=allow) + return compose_doc(raw, catalog, node_id=ctx.node.id, run_id=ctx.run.run_id, allow_numbers=allow, + cells_allowed=cells) response, raw = await draft(messages) doc = compose(raw) diff --git a/backend/app/engine/nodes/tools.py b/backend/app/engine/nodes/tools.py index 548b1641..cf1cf13e 100644 --- a/backend/app/engine/nodes/tools.py +++ b/backend/app/engine/nodes/tools.py @@ -9,13 +9,16 @@ from app.core.events import EventType from app.db.base import SessionLocal from langgraph.func import task +from app.core.artifact_store import canonical_json, content_hash from app.engine.approval import read_decision from app.engine.context import NodeContext, NodeError +from app.engine.evidence import ledger_enabled, next_exec, query_entry_fields from app.engine.replay import ask, once from app.engine.state import GraphState from app.engine.toolcalls import ToolTimeout, limit_fields, limit_of, run_bounded from app.sandbox.base import SandboxLimits from app.sandbox.manager import sandbox_manager +from app.tools.datasource import QUERY_PREFIX from app.tools.registry import ( ToolArgsError, ToolBuildError, @@ -27,6 +30,12 @@ from app.tools.registry import ( ) from app.tools.trust import ask_gate, asks_trustable, call_policy, node_task +#: 数据源查询工具没查成时交回的话的开头(tools/datasource.py)。agent 里原话喂回模型让它改 SQL; +#: tool 节点没有下一轮可改,照常往下走的话,下游拿到的是一句报错当查询结果 +QUERY_FAILED = ("查询失败:", "SQL 被拒绝:") +#: code 节点在证据链里的角色:取数(产出本身就是源数据)还是计算。缺省按计算算 +EVIDENCE_ROLES = ("source", "compute") + def _tool_ctx(ctx: NodeContext) -> ToolContext: return ToolContext( @@ -146,6 +155,14 @@ async def run_tool(state: GraphState, ctx: NodeContext) -> dict[str, Any]: result = {"error": reason, "detail": raw_detail(e)} elapsed = int((time.perf_counter() - started) * 1000) + evidence_on = ledger_enabled(ctx.run) + if evidence_on and name.startswith(QUERY_PREFIX) and isinstance(result, str) and result.startswith(QUERY_FAILED): + # 没查成:原话就是报错(「查询失败:no such table: x」),照写,别再套一层 + ctx.emit(EventType.TOOL_ERROR, tool=name, error=result[:2000], duration_ms=elapsed) + if ctx.cfg("fail_fast", True): + raise NodeError(ctx.node.id, f"工具 {name} 没查成——{result}。按这句话改节点里的 SQL 再跑") + result = {"error": result} + # 取数快照:query(args)和结果集一起进工件库,完整出具时数字回指的就是它 from app.core.artifact_store import put_json @@ -156,11 +173,26 @@ async def run_tool(state: GraphState, ctx: NodeContext) -> dict[str, Any]: ) except Exception: # noqa: BLE001 snapshot_id = None + # 只认数据源工具交回的查询:别的工具(MCP、自定义)拼出同样形状的 JSON,里面的工件 id 不是我们落的 + query = query_entry_fields(result) if evidence_on and name.startswith(QUERY_PREFIX) else None ctx.emit(EventType.TOOL_END, tool=name, duration_ms=elapsed, preview=json.dumps(result, ensure_ascii=False, default=str)[:2000], - artifact=snapshot_id) + artifact=snapshot_id, **({"query_artifact": query["artifact"]} if query else {})) updates: dict[str, Any] = {"nodes": {ctx.node.id: result}} + if evidence_on: + # 证据台账:工具快照一条;查库的再记一条查询快照(外面包着的是工具快照)。报告按台账编 Q1… + exec_no = next_exec(state, ctx.node.id) + entries: list[dict[str, Any]] = [] + if snapshot_id: + entries.append({"kind": "tool", "node_id": ctx.node.id, "exec": exec_no, "artifact": snapshot_id, + "tool": name}) + if query: + entries.append({"kind": "query", "node_id": ctx.node.id, "exec": exec_no, "artifact": query["artifact"], + **({"via": snapshot_id} if snapshot_id else {}), "tool": name, "source": query["source"], + "columns": query["columns"], "rows": query["rows"], "truncated": query["truncated"]}) + if entries: + updates["evidence"] = entries var_name = ctx.cfg("assign_to", "") if var_name: updates["vars"] = {var_name: result} @@ -172,6 +204,10 @@ async def run_code(state: GraphState, ctx: NodeContext) -> dict[str, Any]: code = ctx.render_str(ctx.cfg("code", ""), state) if not code.strip(): raise NodeError(ctx.node.id, "代码节点是空的") + evidence_on = ledger_enabled(ctx.run) + role = str(ctx.cfg("evidence_role", "") or "compute") + if evidence_on and role not in EVIDENCE_ROLES: + raise NodeError(ctx.node.id, f"evidence_role 只能是 source(取数)或 compute(计算),写的是 {role!r}") language = ctx.cfg("language", "python") limits = SandboxLimits( @@ -269,6 +305,14 @@ async def run_code(state: GraphState, ctx: NodeContext) -> dict[str, Any]: payload["text"] = text updates: dict[str, Any] = {"nodes": {ctx.node.id: payload}} + if evidence_on: + # 实际送进沙箱的那份代码(渲染之后、审批改过之后)的指纹:同一张图两次运行算出不同的数, + # 先看这个就知道代码本身变没变 + payload["code_sha"] = content_hash(code) + # 台账的工件就是节点产出工件:外层包装按同样的内容落盘,id 相同 + updates["evidence"] = [{"kind": "node_output", "node_id": ctx.node.id, "exec": next_exec(state, ctx.node.id), + "artifact": content_hash(canonical_json(payload)), "code_sha": payload["code_sha"], + "role": role, "language": language}] var_name = ctx.cfg("assign_to", "") if var_name: # {{ vars.x }} 和 {{ nodes.x.text }} 必须是同一个值:两种写法给不同的 diff --git a/backend/app/engine/review.py b/backend/app/engine/review.py index 8df794a0..224b25ba 100644 --- a/backend/app/engine/review.py +++ b/backend/app/engine/review.py @@ -91,6 +91,10 @@ WARN_CODES: dict[str, tuple[str, str]] = { "report_no_evidence": ("report_no_evidence", DEGRADED), # 口径卡的指标集、报告文档没能落进工件库:答案照常,只是点不开证据 "evidence_store_failed": ("evidence_store_failed", DEGRADED), + # agent 的结构化字段:模型报的值和查询快照对不上(已按快照取值),或者出处核对不了 + # (记成空值,不兜底成 0)。答案能用,缺口要说清是哪一种 + "agent_field_mismatch": ("agent_field_mismatch", DEGRADED), + "agent_field_unverified": ("agent_field_unverified", DEGRADED), } # 这些 kind 重跑一次大概率能好:原因说得清,且不是「问题本身没问明白」。 diff --git a/backend/app/engine/schema.py b/backend/app/engine/schema.py index 0aa2b4df..f9eddc14 100644 --- a/backend/app/engine/schema.py +++ b/backend/app/engine/schema.py @@ -154,6 +154,11 @@ class ValidationIssue(BaseModel): #: 节点配置里出问题的那个字段:prompt、cases[1].condition、agents[0].system。 #: 检查器靠它把问题落到具体的输入框下面;说不清是哪个字段的(连线、图级)为空 field: str | None = None + #: 机器认的问题编号(governed.agent_approval_never、contract.required_missing…)。发布前自动修复 + #: 靠它认出是哪一类问题;文案会改,编号不改。没有编号的问题只能人看着修 + code: str | None = None + #: 这条问题对应的修复 id(engine/autofix.py 算出来的)。只在发布检查的结果里填 + fix: str | None = None class ValidationResult(BaseModel): @@ -161,10 +166,10 @@ class ValidationResult(BaseModel): issues: list[ValidationIssue] = Field(default_factory=list) def add(self, message: str, *, level: str = "error", node_id: str | None = None, - edge_id: str | None = None, field: str | None = None) -> None: + edge_id: str | None = None, field: str | None = None, code: str | None = None) -> None: self.issues.append( ValidationIssue(level=level, message=message, node_id=node_id, # type: ignore[arg-type] - edge_id=edge_id, field=field) + edge_id=edge_id, field=field, code=code) ) if level == "error": self.ok = False @@ -395,9 +400,10 @@ def _check_report_sources(node: GraphNode, spec: GraphSpec, result: ValidationRe return # 不写就取所有上游口径卡 if isinstance(sources, str): sources = [sources] + code = "report.metrics_from_invalid" if not isinstance(sources, list) or not all(isinstance(s, str) and s for s in sources): result.add("「报告撰写」的 metrics_from 要写口径卡节点的 id 列表", node_id=node.id, - field="metrics_from") + field="metrics_from", code=code) return nodes = spec.node_map() upstream = _ancestors(spec, node.id) @@ -405,36 +411,36 @@ def _check_report_sources(node: GraphNode, spec: GraphSpec, result: ValidationRe target = nodes.get(src) if target is None: result.add(f"「报告撰写」的 metrics_from 指向 {src!r},但工作流里不存在这个节点", - node_id=node.id, field="metrics_from") + node_id=node.id, field="metrics_from", code=code) elif target.type != NodeType.METRICS: result.add(f"「报告撰写」的 metrics_from 指向「{target.title}」({src}),它不是「口径卡」" f"节点,而是「{type_label(target.type)}」:报告里的指标只能从口径卡来", - node_id=node.id, field="metrics_from") + node_id=node.id, field="metrics_from", code=code) elif src not in upstream: result.add(f"「报告撰写」的 metrics_from 指向的口径卡「{target.title}」不在它的上游:报告跑的" "时候那张卡还没算,目录里不会有它的指标。把口径卡连到报告前面", - node_id=node.id, field="metrics_from") + node_id=node.id, field="metrics_from", code=code) def _check_report_from(node: GraphNode, report_from: Any, spec: GraphSpec, result: ValidationResult) -> None: """引用模式的契约:report_from 必须是出口上游的报告撰写节点。""" - field = "contract.report_from" + field, code = "contract.report_from", "contract.report_from_invalid" if not isinstance(report_from, str): - result.add("出具契约的 report_from 要写「报告撰写」节点的 id", node_id=node.id, field=field) + result.add("出具契约的 report_from 要写「报告撰写」节点的 id", node_id=node.id, field=field, code=code) return target = spec.node_map().get(report_from) if target is None: result.add(f"出具契约的 report_from 指向 {report_from!r},但工作流里不存在这个节点", - node_id=node.id, field=field) + node_id=node.id, field=field, code=code) elif target.type != NodeType.REPORT: result.add(f"出具契约的 report_from 指向「{target.title}」({report_from}),它不是「报告撰写」" f"节点,而是「{type_label(target.type)}」:引用模式只核对报告撰写节点产出的文档", - node_id=node.id, field=field) + node_id=node.id, field=field, code=code) elif report_from not in _ancestors(spec, node.id): result.add(f"出具契约的 report_from 指向的「{target.title}」不在这个出口的上游:出口核对的时候" "那份报告还没写出来。把报告撰写节点连到出口前面", - node_id=node.id, field=field) + node_id=node.id, field=field, code=code) def validate_graph(spec: GraphSpec) -> ValidationResult: @@ -491,7 +497,8 @@ def validate_graph(spec: GraphSpec) -> ValidationResult: result.add("「结构校验」节点需要一个 JSON Schema", node_id=node.id, field="schema") elif node.type == NodeType.METRICS: defs = cfg.get("metrics") or [] - if not defs: + # 钉住别的工作流里的口径卡时,指标定义运行时从那一版里取,本地不用再写 + if not defs and not cfg.get("caliber_from"): result.add("口径卡还没有定义指标", node_id=node.id, field="metrics") for i, d in enumerate(defs): if not d.get("id") or not d.get("expression"): @@ -501,16 +508,21 @@ def validate_graph(spec: GraphSpec) -> ValidationResult: _check_report_sources(node, spec, result) elif node.type == NodeType.OUTPUT: contract = cfg.get("contract") - if contract and not contract.get("metrics_from"): + if contract and not isinstance(contract, dict): + # 字符串、列表、数字:后面的 .get 会崩,发布、发布检查、发起运行一起 500。 + # 报成 error,两档发布和运行前都挡住 + result.add("出具契约必须是一个 JSON 对象", node_id=node.id, field="contract", + code="contract.not_object") + elif contract and not contract.get("metrics_from"): result.add("出具契约缺 metrics_from(指标来自哪个「口径卡」节点)", - node_id=node.id, field="contract.metrics_from") + node_id=node.id, field="contract.metrics_from", code="contract.metrics_from_missing") if isinstance(contract, dict) and contract.get("report_from") not in (None, ""): _check_report_from(node, contract["report_from"], spec, result) elif node.type == NodeType.LOOP: if not spec.outgoing(node.id): result.add("「循环」节点没有循环体", node_id=node.id) - if node.type in (NodeType.LLM, NodeType.AGENT, NodeType.SUPERVISOR): + if node.type in (NodeType.LLM, NodeType.AGENT, NodeType.SUPERVISOR, NodeType.REPORT): if not (cfg.get("model") or spec.defaults.get("model")): result.add( "没有指定模型,将回退到默认 provider", @@ -568,6 +580,8 @@ def validate_graph(spec: GraphSpec) -> ValidationResult: ) _check_named_tools(spec, result) + for issue in [*text_parse_issues(spec), *evidence_issues(spec)]: + result.add(issue.message, level=issue.level, node_id=issue.node_id, field=issue.field, code=issue.code) # 孤儿节点 for node in spec.nodes: @@ -786,3 +800,240 @@ def _check_named_tools(spec: GraphSpec, result: ValidationResult) -> None: result.add(f"协作目标提到了「{tool}」,但没有哪个成员绑定了它:如果是要成员" "调用它,谁都拿不到。需要就给负责的成员加上", level="warning", node_id=node.id, field="goal") + + +# -------------------------------------------------------------------------- +# 不按 JSON 解析模型写的文字 +# +# 真实踩过的一张图:agent(自由文字)→ transform `{{ nodes.query_logs.text }}`(按 JSON +# 解析)→ 口径卡。模型在 JSON 字符串里夹了没转义的英文引号,整形节点一解析就失败。模型写的 +# 文字不是数据:数要交给口径卡,就让 agent 按 output_schema 交结构化字段、开 cite_fields +# 逐个标出处。这里只报 warning(旧工作流照跑);Copilot 自查把它当成要打回的问题。 +# -------------------------------------------------------------------------- + +_MODEL_TEXT_TYPES = (NodeType.AGENT, NodeType.LLM, NodeType.SUPERVISOR) +_TEXT_REF = re.compile( + r"nodes\s*(?:\.\s*(?P[\w-]+)|\[\s*['\"](?P[^'\"]+)['\"]\s*\])" + r"\s*(?:\.\s*text(?![\w-])|\[\s*['\"]text['\"]\s*\])") +_VAR_REF = re.compile(r"vars\s*(?:\.\s*(?P[\w-]+)|\[\s*['\"](?P[^'\"]+)['\"]\s*\])") + + +def _writes_text(node: GraphNode) -> bool: + """这个节点的产出是不是模型写的文字(而不是系统序列化的结构化数据)。 + + llm 配了 output_schema 走原生结构化输出,text 是系统 json.dumps 的;agent 只有同时 + 开了 cite_fields,assign_to 才拿到核对过的 data——它的 .text 仍然是模型的原话。 + """ + if node.type == NodeType.LLM: + return not node.config.get("output_schema") + return node.type in _MODEL_TEXT_TYPES + + +def _text_sources(text: str, spec: GraphSpec, *, template: bool) -> list[GraphNode]: + """一段模板(或表达式)读了哪些节点的模型原话。模板里过了 | json 的不算:那是合法的 JSON 字符串。""" + nodes = spec.node_map() + pieces = [m.group(0)[2:-2] for m in _TEMPLATE_SPAN.finditer(text)] if template else [text] + found: dict[str, GraphNode] = {} + for piece in pieces: + head, *filters = [p.strip() for p in piece.split("|")] if template else [piece] + if "json" in filters: + continue + for m in _TEXT_REF.finditer(head): + node = nodes.get(m.group("a") or m.group("b")) + if node is not None and _writes_text(node): + found.setdefault(node.id, node) + for m in _VAR_REF.finditer(head): + var = m.group("a") or m.group("b") + for node in spec.nodes: + if str(node.config.get("assign_to") or "").strip() != var or node.type not in _MODEL_TEXT_TYPES: + continue + structured = (node.type == NodeType.LLM and node.config.get("output_schema")) or ( + node.type == NodeType.AGENT and node.config.get("output_schema") + and node.config.get("cite_fields") is True) + if not structured: + found.setdefault(node.id, node) + return list(found.values()) + + +def _descendants(spec: GraphSpec, node_id: str) -> set[str]: + children: dict[str, list[str]] = defaultdict(list) + for e in spec.edges: + children[e.source].append(e.target) + seen: set[str] = set() + stack = list(children[node_id]) + while stack: + cur = stack.pop() + if cur not in seen: + seen.add(cur) + stack.extend(children[cur]) + seen.discard(node_id) + return seen + + +def _feeds_caliber(node: GraphNode, spec: GraphSpec) -> bool: + """下游有口径卡读了这个节点的产出(nodes. 或 vars.)。""" + var = str(node.config.get("assign_to") or "").strip() + reads = [rf"nodes\s*(?:\.\s*{re.escape(node.id)}(?![\w-])|\[\s*['\"]{re.escape(node.id)}['\"]\s*\])"] + if var: + reads.append(rf"vars\s*(?:\.\s*{re.escape(var)}(?![\w-])|\[\s*['\"]{re.escape(var)}['\"]\s*\])") + pattern = re.compile("|".join(reads)) + nodes = spec.node_map() + for nid in _descendants(spec, node.id): + card = nodes.get(nid) + if card is not None and card.type == NodeType.METRICS and any( + pattern.search(str((d or {}).get("expression") or "")) for d in card.config.get("metrics") or []): + return True + return False + + +def _structured_fix(source: GraphNode) -> str: + if source.type == NodeType.AGENT: + var = str(source.config.get("assign_to") or "").strip() or "变量名" + return (f"给「{source.title}」配 output_schema 并开 cite_fields,口径卡直接读 vars.{var}.字段" + "(每个字段都核对到查询结果里的那一格)") + if source.type == NodeType.LLM: + return f"给「{source.title}」配 output_schema;数要进口径卡的,改用 agent 的 output_schema + cite_fields" + return "数要进口径卡的,改用 agent 的 output_schema + cite_fields" + + +def _json_spans(template: str) -> list[tuple[str, bool]]: + """模板里每个 {{ }}:(里面的表达式, 它是不是落在 JSON 的引号里)。只数模板自己写的字,跳过 {{ }}。""" + out: list[tuple[str, bool]] = [] + inside, last = False, 0 + for m in _TEMPLATE_SPAN.finditer(template): + text, i = template[last:m.start()], 0 + while i < len(text): + if inside and text[i] == "\\": + i += 2 + continue + if text[i] == '"': + inside = not inside + i += 1 + out.append((m.group(0)[2:-2].strip(), inside)) + last = m.end() + return out + + +def text_parse_issues(spec: GraphSpec) -> list[ValidationIssue]: + """整形节点解析 agent / llm 写的文字、产出流向口径卡或按 JSON 解析:一个节点一条 warning。""" + out: list[ValidationIssue] = [] + for node in spec.nodes: + if node.type != NodeType.TRANSFORM: + continue + mode = node.config.get("mode", "expression") + field = "expression" if mode == "expression" else "template" + sources = _text_sources(str(node.config.get(field) or ""), spec, template=field == "template") + if not sources: + continue + who = "、".join(f"「{s.title}」({type_label(s.type)})" for s in sources[:3]) + spans = [(expr, quoted) for expr, quoted in _json_spans(str(node.config.get(field) or "")) + if _text_sources(f"{{{{ {expr} }}}}", spec, template=True)] if mode == "json" else [] + if spans and all(quoted for _, quoted in spans): + # 模型的文字放在模板的引号里:不是在解析它,是在拼字符串,少的是 | json + expr = spans[0][0] + message = (f"模板里 {{{{ {expr} }}}} 放在 JSON 的引号里:{who}写的文字只要带英文引号、换行,JSON 就被" + f"撑破,整个节点失败。写成 {{{{ {expr} | json }}}}(外面不要再加引号)") + elif mode == "json": + message = (f"整形节点按 JSON 解析{who}写的文字:模型写的 JSON 常夹着没转义的英文引号,解析一失败" + f"整个节点就失败。改成让它直接交结构化数据:{_structured_fix(sources[0])}") + elif _feeds_caliber(node, spec): + message = (f"口径卡的数要经这个整形节点从{who}写的文字里取:文字不是结构化数据,取不准,也核对不了出处。" + f"{_structured_fix(sources[0])}") + else: + continue + out.append(ValidationIssue(level="warning", node_id=node.id, field=field, message=message)) + return out + + +# -------------------------------------------------------------------------- +# 证据链相关的配置:agent 的结构化字段、沙箱代码的角色、钉住别处的口径卡 +# -------------------------------------------------------------------------- + +EVIDENCE_ROLES = ("source", "compute") +_REF_HEAD = re.compile(r"^(vars|nodes)\.([\w-]+)") + + +def _caliber_inputs(card: GraphNode, spec: GraphSpec) -> list[tuple[int, GraphNode]]: + """口径卡每个指标表达式里读到的节点:(指标下标, 产出它的节点)。""" + from app.engine.expressions import ExpressionError, leaf_refs, parse_expression + + nodes = spec.node_map() + out: list[tuple[int, GraphNode]] = [] + for i, d in enumerate(card.config.get("metrics") or []): + try: + tree, _, _ = parse_expression(str((d or {}).get("expression") or "")) + except ExpressionError: + continue + for path in leaf_refs(tree): + m = _REF_HEAD.match(path.split("cell(", 1)[-1].lstrip()) + if not m: + continue + root, name = m.groups() + if root == "nodes": + producers = [nodes[name]] if name in nodes else [] + else: + producers = [n for n in spec.nodes if str(n.config.get("assign_to") or "").strip() == name] + out.extend((i, n) for n in producers) + return out + + +def evidence_issues(spec: GraphSpec) -> list[ValidationIssue]: + from app.engine.governance import UPGRADE_POLICIES, UPGRADE_POLICY_LABELS + + out: list[ValidationIssue] = [] + + def add(message: str, node: GraphNode, field: str, level: str = "warning") -> None: + out.append(ValidationIssue(level=level, node_id=node.id, field=field, message=message)) # type: ignore[arg-type] + + for node in spec.nodes: + cfg = node.config + if node.type == NodeType.AGENT and cfg.get("output_schema") and cfg.get("cite_fields") is not True: + add("output_schema 要开 cite_fields 才生效,会多一次抽取调用:开了之后循环结束再按 Schema " + "抽一次字段,每个字段都核对到查询结果里的那一格;不开的话 assign_to 拿到的仍是自由文字", + node, "output_schema") + elif node.type == NodeType.CODE: + role = cfg.get("evidence_role") + if role not in (None, "") and role not in EVIDENCE_ROLES: + add(f"evidence_role 只能是 source(取数)或 compute(计算),写的是 {role!r}", node, + "evidence_role", level="error") + elif node.type == NodeType.METRICS: + if cfg.get("caliber_from") not in (None, "", {}): + out.extend(_caliber_from_issues(node, UPGRADE_POLICIES, UPGRADE_POLICY_LABELS)) + warned: set[str] = set() + for i, producer in _caliber_inputs(node, spec): + role = str(producer.config.get("evidence_role") or "compute") + if producer.type != NodeType.CODE or role == "source" or producer.id in warned: + continue + warned.add(producer.id) + add(f"口径卡的输入来自沙箱代码「{producer.title}」,它的角色是计算(evidence_role 不是 source):" + "沙箱里算出来的数核对不了出处。负责取数的代码节点把 evidence_role 标成 source;" + "业务计算挪进口径卡的表达式", node, f"metrics[{i}].expression") + return out + + +def _caliber_from_issues(node: GraphNode, policies: set[str], labels: dict[str, str]) -> list[ValidationIssue]: + """钉住别的工作流里一张口径卡:{workflow_id, workflow_version, node_id} 三项都要写对。""" + cfg = node.config + ref = cfg.get("caliber_from") + out: list[ValidationIssue] = [] + + def add(message: str, field: str, level: str = "error") -> None: + out.append(ValidationIssue(level=level, node_id=node.id, field=field, message=message)) # type: ignore[arg-type] + + if not isinstance(ref, dict) or not str(ref.get("workflow_id") or "").strip() \ + or not str(ref.get("node_id") or "").strip(): + add("caliber_from 要写 {workflow_id, workflow_version, node_id}:钉住哪个工作流的哪一版里的哪张口径卡", + "caliber_from") + return out + version = ref.get("workflow_version") + if isinstance(version, bool) or not (isinstance(version, int) or (isinstance(version, str) and version.isdigit())) \ + or int(version) < 1: + add(f"caliber_from 的 workflow_version 要写具体的版本号(1、2…),写的是 {version!r}:不钉版本,口径会跟着" + "上游漂移", "caliber_from.workflow_version") + if cfg.get("metrics"): + add("写了 caliber_from 就按钉住的那张卡算,这里自己写的 metrics 不会生效", "metrics", level="warning") + policy = cfg.get("upgrade_policy") + if policy not in (None, "") and policy not in policies: + add("upgrade_policy 只能是 " + " / ".join(f"{k}({v})" for k, v in labels.items()) + + f",写的是 {policy!r}", "upgrade_policy") + return out diff --git a/backend/app/seed.py b/backend/app/seed.py index 37ff61a7..21adb2d9 100644 --- a/backend/app/seed.py +++ b/backend/app/seed.py @@ -181,8 +181,9 @@ TEMPLATES: list[dict[str, Any]] = [ "nodes": [ _n("start", "input", "周期", fields=[ {"name": "week", "default": "2026-W37", "description": "报告周期"}]), + # 取数节点:产出本身就是源数据(evidence_role=source),口径卡读它记得下出处 _n("fetch", "code", "取数(数据底座)", language="python", network=False, - assign_to="agg", + assign_to="agg", evidence_role="source", code=( "# 固定层:取数与汇总。真实场景换成对数据库/API 的查询,\n" "# 结果会作为快照落工件库,出具溯源指回这里。\n" diff --git a/backend/app/tools/datasource.py b/backend/app/tools/datasource.py index fae3fe23..63bfea9d 100644 --- a/backend/app/tools/datasource.py +++ b/backend/app/tools/datasource.py @@ -20,7 +20,7 @@ from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession from app.data import introspect -from app.data.engine import query_timeout, run_query +from app.data.engine import masked_columns, query_timeout, run_query from app.data.guard import QueryLimits, SqlRejected, is_write from app.db.models import DataSource from app.tools.registry import ToolContext @@ -148,8 +148,12 @@ def _make_query_tool(source: DataSource, ctx: ToolContext) -> StructuredTool: try: from app.core.artifact_store import put_json + # 查询当时数据源设的遮罩列一起记进快照:数据源事后改名、删掉,证据面板仍按当时的遮。 + # 只进快照,不进交给模型的结果(模型看到的是原值,多个字段只会让它以为数据被遮了); + # 没设遮罩的快照和原来一字不差 + mask = masked_columns(source.options) payload["artifact"] = await put_json( - payload, kind="query_snapshot", + {**payload, "mask_columns": mask} if mask else payload, kind="query_snapshot", run_id=ctx.run_id or "", node_id=ctx.node_id or "", ) except Exception: # noqa: BLE001 - 存不下不影响查询本身 diff --git a/backend/tests/test_agent_cited_fields.py b/backend/tests/test_agent_cited_fields.py new file mode 100644 index 00000000..e22cfb41 --- /dev/null +++ b/backend/tests/test_agent_cited_fields.py @@ -0,0 +1,560 @@ +"""agent 的 cite_fields:结构化字段逐个标出处,值以快照为准。 + +以前 agent 的 output_schema 根本不生效,给口径卡供数只能靠 transform 从自由文本里抠, +抠不到的字段模型会顺手填个 0——报告里「退款 0 单」看着像查过,其实是没查到。二期: + +- 写了 output_schema 并开 cite_fields: true 时,循环结束后做一次结构化抽取:每个字段 + 写 {value, from: {call: "Q1", row: 0, column: "gmv"}},查不到就填 null +- 系统按 from 去快照里取那一格,以快照为准: + verified(一致)/ mismatch(不一致,按快照取值、发警告)/ unresolved(取不到,记空值、 + 发警告)/ from 为 null(记空值)——永远不兜底成 0 +- 口径卡的来历接上字段的出处:via agent_field,带 eid 和核对状态 +- 审批恢复时节点整个重放:抽取不重复调用、编号不错位 +- 升级前发起的运行一字不改 +""" +from __future__ import annotations + +import asyncio +import json +import sqlite3 + +import pytest +from langchain_core.messages import AIMessage, ToolMessage +from sqlalchemy import select + +from app.core import artifact_store +from app.data.engine import engines +from app.db.base import SessionLocal +from app.db.models import DataSource, Run, RunEvent +from app.engine import runner as runner_mod +from app.engine.evidence import make_eid +from app.engine.runner import run_manager + +TOOL = "db_query__shop" +SQLS = [ + "SELECT SUM(amount) AS gmv, COUNT(*) AS orders FROM orders WHERE week = '2026-W37'", + "SELECT COUNT(*) AS refunds, MAX(week) AS week FROM orders WHERE refunded = 1", +] +FINAL = "本周销售额 600.5,3 单。" +SCHEMA = { + "type": "object", + "properties": { + "gmv": {"type": "number", "description": "本周销售额"}, + "orders": {"type": "integer"}, + "refunds": {"type": "integer"}, + "region": {"type": "string"}, + "week": {"type": "string"}, + }, + "required": ["gmv", "orders"], +} +EXTRACTED = { + "gmv": {"value": 600.5, "from": {"call": "Q1", "row": 0, "column": "gmv"}}, + "orders": {"value": 30, "from": {"call": "Q1", "row": 0, "column": "orders"}}, # 快照里是 3 + "refunds": {"value": 0, "from": None}, # 模型说没查到 + "region": {"value": "华东", "from": {"call": "Q9", "row": 0, "column": "region"}}, # 本节点没有 Q9 + "week": {"value": "2026-W37", "from": {"call": "Q2", "row": 0, "column": "week"}}, +} + + +@pytest.fixture(autouse=True) +async def engine_up(): + await run_manager.setup() + yield + await run_manager.shutdown() + + +@pytest.fixture(autouse=True) +async def shop(tmp_path): + path = tmp_path / "shop.db" + db = sqlite3.connect(path) + db.executescript("CREATE TABLE orders (id INTEGER PRIMARY KEY, week TEXT, amount REAL, refunded INTEGER);") + db.executemany("INSERT INTO orders VALUES (?, ?, ?, ?)", [ + (1, "2026-W37", 100.5, 0), (2, "2026-W37", 200.0, 1), (3, "2026-W37", 300.0, 0), (4, "2026-W36", 50.0, 0)]) + db.commit() + db.close() + async with SessionLocal() as session: + row = (await session.execute(select(DataSource).where(DataSource.name == "shop"))).scalar_one_or_none() + if row is None: + row = DataSource(name="shop", kind="sqlite", database=str(path), readonly=True, enabled=True) + session.add(row) + else: + row.database, row.kind, row.enabled, row.readonly = str(path), "sqlite", True, True + await session.commit() + source_id = row.id + # 连接池按数据源 id 缓存:换了库文件不作废,查的还是上一个测试的库 + await engines.invalidate(source_id) + yield + await engines.invalidate(source_id) + + +class Script: + """agent 依次查 SQLS 里的两条,再给结论;结构化抽取按 extracted 回。""" + + def __init__(self, monkeypatch, extracted=EXTRACTED, *, fail_extract=False, sqls=SQLS): + from app.providers import mock_model + + self.loop: list[list] = [] + self.extract: list[list] = [] + + def decide(model, messages): + if model.response_format: + self.extract.append(list(messages)) + if fail_extract: + raise RuntimeError("网关超时") + return AIMessage(content=json.dumps(extracted, ensure_ascii=False)) + if not model.tools: + return AIMessage(content="收尾") + self.loop.append(list(messages)) + done = sum(isinstance(m, ToolMessage) for m in messages) + if done < len(sqls): + return AIMessage(content="查。", tool_calls=[ + {"name": TOOL, "args": {"sql": sqls[done]}, "id": f"call_{done}"}]) + return AIMessage(content=FINAL) + + monkeypatch.setattr(mock_model.MockChatModel, "_decide", decide) + + +def node(nid, ntype, **config): + return {"id": nid, "type": ntype, "position": {"x": 0, "y": 0}, "data": {"label": nid, "config": config}} + + +def graph(*, card=True, **agent): + cfg = {"prompt": "查本周销售额、订单和退款", "tools": [TOOL], "approval": "never", + "output_schema": SCHEMA, "cite_fields": True, "assign_to": "kpi", **agent} + nodes = [node("start", "input"), node("bot", "agent", **cfg)] + if card: + nodes.append(node("card", "metrics", caliber="周报口径", metrics=[ + {"id": "gmv", "name": "销售额", "unit": "元", "decimals": 1, "format": "thousands", "expression": "vars.kpi.gmv"}, + {"id": "orders", "name": "订单", "unit": "单", "format": "thousands", "expression": "vars.kpi.orders"}])) + nodes.append(node("out", "output", fields=[{"name": "kpi", "value": "{{ vars.kpi | json }}"}])) + return {"nodes": nodes, "edges": [{"source": a["id"], "target": b["id"]} for a, b in zip(nodes, nodes[1:])]} + + +async def wait(run_id: str, statuses=("succeeded", "failed")) -> Run: + for _ in range(400): + await asyncio.sleep(0.05) + async with SessionLocal() as session: + row = await session.get(Run, run_id) + if row.status in statuses and (row.status == "interrupted" or row.manifest_seq is not None): + return row + raise AssertionError(f"run {run_id} 没跑完:{row.status}") + + +async def events(run_id: str, etype: str | None = None, node_id: str | None = None) -> list[RunEvent]: + async with SessionLocal() as session: + q = select(RunEvent).where(RunEvent.run_id == run_id) + if etype: + q = q.where(RunEvent.type == etype) + if node_id: + q = q.where(RunEvent.node_id == node_id) + return list((await session.execute(q.order_by(RunEvent.seq))).scalars()) + + +async def output_of(run_id: str, node_id: str) -> dict: + [finished] = await events(run_id, "node.finished", node_id) + return artifact_store.load(finished.data["artifact"]) + + +def logs(evts, code): + return [e.data for e in evts if e.type == "log" and e.data.get("code") == code] + + +def text_of(message) -> str: + return message.content if isinstance(message.content, str) else json.dumps(message.content, ensure_ascii=False) + + +# -------------------------------------------------------------------------- +# 四种核对结果 +# -------------------------------------------------------------------------- + + +async def test_fields_are_checked_against_the_snapshot(monkeypatch): + script = Script(monkeypatch) + run = await run_manager.start(graph=graph(), input_payload={}) + row = await wait(run.id) + assert row.status == "succeeded", row.error + + bot = await output_of(row.id, "bot") + assert bot["text"] == FINAL + # 以快照为准:orders 模型报 30,快照是 3;没查到的是 None,不是 0 + assert bot["data"] == {"gmv": 600.5, "orders": 3, "refunds": None, "region": None, "week": "2026-W37"} + assert json.loads(row.output["kpi"]) == bot["data"], "assign_to 拿到的应该是核对过的 data" + + ends = [e.data for e in await events(row.id, "tool.end", "bot")] + q1, q2 = ends[0]["query_artifact"], ends[1]["query_artifact"] + ev = bot["data_evidence"] + assert ev["gmv"] == {"ref": "Q1.r0.gmv", "call": "Q1", "artifact": q1, "via": ends[0]["artifact"], + "locator": {"row": 0, "column": "gmv"}, + "eid": make_eid("cell", q1, {"row": 0, "column": "gmv"}), "status": "verified"} + assert ev["orders"]["status"] == "mismatch" and ev["orders"]["model_value"] == 30 + assert ev["orders"]["eid"] == make_eid("cell", q1, {"row": 0, "column": "orders"}) + assert ev["refunds"]["status"] == "missing" and ev["refunds"].get("ref") is None + assert ev["region"]["status"] == "unresolved" and "Q9" in ev["region"]["reason"] + assert ev["week"]["status"] == "verified" and ev["week"]["artifact"] == q2 + + evts = await events(row.id) + [mismatch] = logs(evts, "agent_field_mismatch") + assert mismatch["level"] == "warn" and mismatch["fields"] == ["orders"] + assert "模型报 30" in mismatch["message"] and "快照是 3" in mismatch["message"] + [unverified] = logs(evts, "agent_field_unverified") + assert unverified["level"] == "warn" and unverified["fields"] == ["region"] + + # 给模型的工具结果带本节点内的编号;抽取只调一次,提示里有数据、有「禁止估算」 + tool_msgs = [m for m in script.loop[-1] if isinstance(m, ToolMessage)] + assert [text_of(m).split("\n", 1)[0] for m in tool_msgs] == ["【证据 Q1】", "【证据 Q2】"] + assert len(script.extract) == 1 + prompt = "\n".join(text_of(m) for m in script.extract[0]) + assert "禁止估算" in prompt and "【证据 Q1】" in prompt and "600.5" in prompt and FINAL in prompt + assert len(await events(row.id, "llm.end", "bot")) == len(script.loop) + 1 + + # 口径卡的来历接上字段的出处 + card = await output_of(row.id, "card") + gmv = next(m for m in card["metrics"] if m["id"] == "gmv") + [src] = gmv["inputs"] + assert src["via"] == "agent_field" and src["node_id"] == "bot" and src["field"] == "gmv" + assert src["eid"] == ev["gmv"]["eid"] and src["status"] == "verified" and src["ref"] == "Q1.r0.gmv" + assert src["artifact"] == q1 and src["locator"] == {"row": 0, "column": "gmv"} + orders = next(m for m in card["metrics"] if m["id"] == "orders") + assert orders["value"] == 3 and orders["inputs"][0]["status"] == "mismatch" + assert orders["inputs"][0]["model_value"] == 30 + + +# -------------------------------------------------------------------------- +# 小数位为 0 的 DECIMAL:驱动落成没有小数点的 "45678" +# -------------------------------------------------------------------------- + + +def test_integer_decimal_text_follows_the_declared_type(): + """MySQL 整数列 SUM()、Postgres SUM(bigint) 落进快照是 "45678"。声明成数的字段要拿到数(下游口径卡 + 要拿它做除法),模型报 45678 或 45678.0 都算一致;声明成文本的照旧是文本。""" + from app.engine.expressions import eval_expression + from app.engine.nodes.llm import cited_fields, verify_cited_fields + + snap = {"columns": ["gmv", "refunds", "week", "code"], "rows": [["45678", "29", "2026", "00123"]]} + schema = {"type": "object", "properties": { + "gmv": {"type": "number"}, "refunds": {"type": "integer"}, "week": {"type": "string"}, + "code": {"type": ["integer", "null"]}}} + queries = [{"alias": "Q1", "artifact": "a" * 64, "via": "b" * 64}] + + def at(column): + return {"call": "Q1", "row": 0, "column": column} + + for told in (45678, 45678.0, "45678"): + parsed = {"gmv": {"value": told, "from": at("gmv")}, "refunds": {"value": 29.0, "from": at("refunds")}, + "week": {"value": "2026", "from": at("week")}, "code": {"value": 123, "from": at("code")}} + data, ev = verify_cited_fields({"parsed": parsed}, cited_fields(schema), queries, loader=lambda a: snap) + assert {k: e["status"] for k, e in ev.items()} == dict.fromkeys(schema["properties"], "verified"), (told, ev) + assert data == {"gmv": 45678, "refunds": 29, "week": "2026", "code": 123} + assert type(data["gmv"]) is int and type(data["refunds"]) is int and type(data["week"]) is str + assert eval_expression("vars.kpi.gmv / 2", {"vars": {"kpi": data}}) == 22839.0 + + rows = {"columns": ["week", "refunds"], "rows": [["2026-W36", "4"], ["2026-W37", "29"]]} + arrays = {"type": "object", "properties": { + "weeks": {"type": "array", "items": {"type": "object", "properties": { + "week": {"type": "string"}, "refunds": {"type": "integer"}}}}, + "counts": {"type": "array", "items": {"type": "number"}}}} + parsed = {"weeks": {"value": [{"week": "2026-W36", "refunds": 4}, {"week": "2026-W37", "refunds": 29.0}], + "from": {"call": "Q1", "rows": "0-1", "columns": {"week": "week", "refunds": "refunds"}}}, + "counts": {"value": [4, 29], "from": {"call": "Q1", "rows": "0-1", "column": "refunds"}}} + data, ev = verify_cited_fields({"parsed": parsed}, cited_fields(arrays), queries, loader=lambda a: rows) + assert ev["weeks"]["status"] == "verified" and ev["counts"]["status"] == "verified" + assert data == {"weeks": [{"week": "2026-W36", "refunds": 4}, {"week": "2026-W37", "refunds": 29}], + "counts": [4, 29]} + + +async def test_integer_decimal_text_feeds_a_card_that_divides(monkeypatch): + """sqlite 没有 DECIMAL:CAST(… AS TEXT) 落进快照的,和 MySQL 整数列 SUM() 的 str(Decimal) 一模一样。""" + sql = ("SELECT CAST(SUM(refunded) AS TEXT) AS refunds, CAST(COUNT(*) AS TEXT) AS orders " + "FROM orders WHERE week = '2026-W37'") + extracted = {"refunds": {"value": 1.0, "from": {"call": "Q1", "row": 0, "column": "refunds"}}, + "orders": {"value": 3, "from": {"call": "Q1", "row": 0, "column": "orders"}}} + Script(monkeypatch, extracted, sqls=[sql]) + g = graph(card=False, output_schema={"type": "object", "properties": { + "refunds": {"type": "integer"}, "orders": {"type": "integer"}}}) + g["nodes"].insert(2, node("card", "metrics", caliber="周报口径", metrics=[ + {"id": "rate", "name": "退款率", "unit": "%", "decimals": 1, "format": "plain", + "expression": "vars.kpi.refunds / vars.kpi.orders * 100"}])) + g["edges"] = [{"source": a["id"], "target": b["id"]} for a, b in zip(g["nodes"], g["nodes"][1:])] + run = await run_manager.start(graph=g, input_payload={}) + row = await wait(run.id) + assert row.status == "succeeded", row.error + + bot = await output_of(row.id, "bot") + assert bot["data"] == {"refunds": 1, "orders": 3} + assert {e["status"] for e in bot["data_evidence"].values()} == {"verified"} + assert not logs(await events(row.id), "agent_field_mismatch") + [rate] = (await output_of(row.id, "card"))["metrics"] + assert rate["value"] == pytest.approx(33.3) and rate["status"] == "ok" + assert [i["status"] for i in rate["inputs"]] == ["verified", "verified"] + + +async def test_extraction_failure_leaves_fields_empty_not_zero(monkeypatch): + Script(monkeypatch, fail_extract=True) + run = await run_manager.start(graph=graph(card=False), input_payload={}) + row = await wait(run.id) + assert row.status == "succeeded", row.error + bot = await output_of(row.id, "bot") + assert bot["data"] == {k: None for k in SCHEMA["properties"]} + assert all(e["status"] == "unresolved" for e in bot["data_evidence"].values()) + [warn] = logs(await events(row.id), "agent_field_unverified") + assert warn["fields"] == list(SCHEMA["properties"]) and "网关超时" in warn["message"] + + +async def test_output_schema_alone_changes_nothing_but_the_ledger(monkeypatch): + """只写 output_schema、没开 cite_fields:不抽取,变量照旧是文本。""" + script = Script(monkeypatch) + run = await run_manager.start(graph=graph(card=False, cite_fields=False), input_payload={}) + row = await wait(run.id) + assert row.status == "succeeded", row.error + assert script.extract == [] + bot = await output_of(row.id, "bot") + assert "data" not in bot and "data_evidence" not in bot + assert row.output["kpi"] == json.dumps(FINAL, ensure_ascii=False, indent=2) + [finished] = await events(row.id, "node.finished", "bot") + assert [e["kind"] for e in finished.data["evidence"]] == ["tool", "query", "tool", "query"] + + +# -------------------------------------------------------------------------- +# 审批恢复重放 +# -------------------------------------------------------------------------- + + +async def test_replay_after_approvals_does_not_repeat_or_renumber(monkeypatch): + script = Script(monkeypatch) + run = await run_manager.start(graph=graph(approval="always"), input_payload={}) + for _ in SQLS: + row = await wait(run.id, ("interrupted", "failed", "succeeded")) + assert row.status == "interrupted", row.error + await run_manager.resume(run.id, {"approved": True}) + row = await wait(run.id, ("succeeded", "failed", "interrupted")) + assert row.status == "succeeded", row.error + + assert len(script.extract) == 1, "抽取在重放时又调了一次" + assert len(await events(row.id, "human.requested", "bot")) == 2 + ends = [e.data for e in await events(row.id, "tool.end", "bot")] + assert len(ends) == 2, "工具在重放时又执行了一次" + assert len(await events(row.id, "llm.end", "bot")) == len(script.loop) + 1 + + bot = await output_of(row.id, "bot") + ev = bot["data_evidence"] + assert ev["gmv"]["artifact"] == ends[0]["query_artifact"] and ev["gmv"]["call"] == "Q1" + assert ev["week"]["artifact"] == ends[1]["query_artifact"] and ev["week"]["call"] == "Q2" + assert bot["data"]["gmv"] == 600.5 and bot["data"]["orders"] == 3 + + # 重放之后模型看到的编号还是同一套 + tool_msgs = [m for m in script.loop[-1] if isinstance(m, ToolMessage)] + assert [text_of(m).split("\n", 1)[0] for m in tool_msgs] == ["【证据 Q1】", "【证据 Q2】"] + [finished] = await events(row.id, "node.finished", "bot") + queries = [e for e in finished.data["evidence"] if e["kind"] == "query"] + assert [q["artifact"] for q in queries] == [ends[0]["query_artifact"], ends[1]["query_artifact"]] + + +async def test_continuing_after_a_failure_past_the_extraction_does_not_extract_again(monkeypatch): + """抽取之后节点挂了,接着跑:节点整个重放,抽取的结果取自断点,不再调用、不再计费。 + + 审批恢复那条测不到这一点:抽取在最后一次审批之后才跑,不进 checkpoint 也只调一次。 + """ + from app.engine.nodes import llm as llm_mod + + script = Script(monkeypatch) + real = llm_mod.verify_cited_fields + broken = {"on": True} + + def flaky(*args, **kwargs): + if broken["on"]: + raise RuntimeError("核对时断了") + return real(*args, **kwargs) + + monkeypatch.setattr(llm_mod, "verify_cited_fields", flaky) + run = await run_manager.start(graph=graph(card=False), input_payload={}) + row = await wait(run.id) + assert row.status == "failed" and "核对时断了" in (row.error or ""), row.error + assert len(script.extract) == 1 + await run_manager.wait_idle(run.id) + + broken["on"] = False + await run_manager.continue_failed(run.id) + row = await wait(run.id) + assert row.status == "succeeded", row.error + assert len(script.extract) == 1, "接着跑时抽取又调了一次:extract_step 的结果没进 checkpoint" + assert len(await events(row.id, "tool.end", "bot")) == 2 + assert len([e for e in await events(row.id, "llm.end", "bot") if e.data.get("purpose") == "cite_fields"]) == 1 + bot = await output_of(row.id, "bot") + assert bot["data"]["gmv"] == 600.5 and bot["data_evidence"]["gmv"]["status"] == "verified" + + +async def test_run_paused_under_the_previous_code_resumes_cleanly(monkeypatch): + """护栏之后、二期之前发起的运行(有 agent_limits 快照)停在审批上,升级后恢复:checkpoint 里 + 缓存的 tool_step 是旧形状(没有 snapshot / query_artifact)。恢复不能崩、不能重复执行、不能 + 重复问人;旧的那次调用不编号、不进台账,之后的照常编号。""" + from app.engine.nodes import llm as llm_mod + + script = Script(monkeypatch) + monkeypatch.setattr(llm_mod, "ledger_enabled", lambda run: False) # 模拟上一版代码 + run = await run_manager.start(graph=graph(card=False, approval="always"), input_payload={}) + row = await wait(run.id, ("interrupted", "failed", "succeeded")) + await run_manager.resume(run.id, {"approved": True}) # 第一次调用在旧代码下执行 + row = await wait(run.id, ("interrupted", "failed", "succeeded")) + assert row.status == "interrupted", row.error + monkeypatch.undo() # 升级 + script = Script(monkeypatch) + await run_manager.resume(run.id, {"approved": True}) + row = await wait(run.id, ("succeeded", "failed", "interrupted")) + assert row.status == "succeeded", row.error + + assert len(await events(row.id, "human.requested", "bot")) == 2 + ends = [e.data for e in await events(row.id, "tool.end", "bot")] + assert len(ends) == 2 and "query_artifact" not in ends[0] and ends[1].get("query_artifact") + tool_msgs = [m for m in script.loop[-1] if isinstance(m, ToolMessage)] + assert text_of(tool_msgs[0]).startswith("{") and text_of(tool_msgs[1]).startswith("【证据 Q1】") + [finished] = await events(row.id, "node.finished", "bot") + assert [(e["kind"], e["call_id"]) for e in finished.data["evidence"]] == [("tool", "call_1"), ("query", "call_1")] + assert len(script.extract) == 1 + + +# -------------------------------------------------------------------------- +# 升级前发起的运行 +# -------------------------------------------------------------------------- + + +async def test_runs_from_before_the_upgrade_are_untouched(monkeypatch): + async def no_snapshot(session): + return None + + monkeypatch.setattr(runner_mod, "_agent_limits", no_snapshot) + script = Script(monkeypatch) + run = await run_manager.start(graph=graph(card=False), input_payload={}) + row = await wait(run.id) + assert row.status == "succeeded", row.error + + assert script.extract == [], "老运行不该多出抽取调用" + tool_msgs = [m for m in script.loop[-1] if isinstance(m, ToolMessage)] + assert tool_msgs and not any("【证据" in text_of(m) for m in tool_msgs) + assert all(text_of(m).startswith("{") for m in tool_msgs) + + bot = await output_of(row.id, "bot") + assert set(bot) == {"text", "steps", "tool_calls", "model"} + assert all(set(t) == {"tool", "args", "ok", "duration_ms", "result"} for t in bot["tool_calls"]) + assert row.output["kpi"] == json.dumps(FINAL, ensure_ascii=False, indent=2) + [finished] = await events(row.id, "node.finished", "bot") + assert "evidence" not in finished.data + assert all("query_artifact" not in e.data for e in await events(row.id, "tool.end", "bot")) + assert not logs(await events(row.id), "agent_field_mismatch") + + +# -------------------------------------------------------------------------- +# 抽取的形状 +# -------------------------------------------------------------------------- + + +def test_cited_schema_wraps_every_scalar_field(): + from app.engine.nodes.llm import cited_schema + + schema = cited_schema({"type": "object", "properties": { + "gmv": {"type": "number", "description": "销售额"}, + "meta": {"type": "object", "properties": {"week": {"type": "string"}}}, + "tags": {"type": "array", "items": {"type": "string"}}}}) + gmv = schema["properties"]["gmv"] + assert gmv["required"] == ["value", "from"] + assert {"type": "null"} in gmv["properties"]["value"]["anyOf"] + assert gmv["properties"]["value"]["anyOf"][0] == {"type": "number", "description": "销售额"} + source = gmv["properties"]["from"]["anyOf"] + assert {"type": "null"} in source + assert source[0]["required"] == ["call", "row", "column"] + week = schema["properties"]["meta"]["properties"]["week"] + assert week["required"] == ["value", "from"] + assert schema["title"] and schema["type"] == "object" + + +# -------------------------------------------------------------------------- +# 数组字段按行映射 +# -------------------------------------------------------------------------- + +WEEKLY = "SELECT week, SUM(amount) AS amount FROM orders GROUP BY week ORDER BY week" +ROW_SCHEMA = {"type": "object", "properties": { + "weeks": {"type": "array", "items": {"type": "object", "properties": { + "week": {"type": "string"}, "amount": {"type": "number"}}}}, + "labels": {"type": "array", "items": {"type": "string"}}, + "far": {"type": "array", "items": {"type": "string"}}, +}} +ROW_EXTRACTED = { + "weeks": {"value": [{"week": "2026-W36", "amount": 50}, {"week": "2026-W37", "amount": 600.5}], + "from": {"call": "Q1", "rows": "0-1", "columns": {"week": "week", "amount": "amount"}}}, + "labels": {"value": ["2026-W36", "上周"], "from": {"call": "Q1", "rows": "0-1", "column": "week"}}, + "far": {"value": ["x"], "from": {"call": "Q1", "rows": "0-5", "column": "week"}}, +} + + +async def test_array_fields_map_to_a_range_of_rows(monkeypatch): + Script(monkeypatch, ROW_EXTRACTED, sqls=[WEEKLY]) + g = graph(card=False, output_schema=ROW_SCHEMA) + g["nodes"].insert(2, node("card", "metrics", metrics=[ + {"id": "latest", "name": "本周", "format": "thousands", "decimals": 1, "expression": "vars.kpi.weeks[1].amount"}])) + g["edges"] = [{"source": a["id"], "target": b["id"]} for a, b in zip(g["nodes"], g["nodes"][1:])] + run = await run_manager.start(graph=g, input_payload={}) + row = await wait(run.id) + assert row.status == "succeeded", row.error + + bot = await output_of(row.id, "bot") + q1 = (await events(row.id, "tool.end", "bot"))[0].data["query_artifact"] + assert bot["data"]["weeks"] == [{"week": "2026-W36", "amount": 50.0}, {"week": "2026-W37", "amount": 600.5}] + weeks = bot["data_evidence"]["weeks"] + assert weeks["status"] == "verified" and weeks["ref"] == "Q1.r0-1" + assert weeks["locator"] == {"rows": [0, 1], "columns": {"week": "week", "amount": "amount"}} + assert weeks["eid"] == make_eid("rows", q1, weeks["locator"]) + # 以快照为准:标签第二个模型写成「上周」,按快照取 2026-W37 + assert bot["data"]["labels"] == ["2026-W36", "2026-W37"] + assert bot["data_evidence"]["labels"]["status"] == "mismatch" + assert bot["data_evidence"]["labels"]["model_value"] == ["2026-W36", "上周"] + assert bot["data"]["far"] is None and bot["data_evidence"]["far"]["status"] == "unresolved" + assert "只有 2 行" in bot["data_evidence"]["far"]["reason"] + + # 口径卡取数组里的一个元素:落到快照里的那一格 + card = await output_of(row.id, "card") + [src] = card["metrics"][0]["inputs"] + assert src["via"] == "agent_field" and src["field"] == "weeks[1].amount" and src["status"] == "verified" + assert src["locator"] == {"row": 1, "column": "amount"} + assert src["eid"] == make_eid("cell", q1, {"row": 1, "column": "amount"}) + assert card["metrics"][0]["value"] == 600.5 + + +def test_cited_schema_for_arrays(): + from app.engine.nodes.llm import cited_schema + + schema = cited_schema(ROW_SCHEMA) + weeks = schema["properties"]["weeks"]["properties"]["from"]["anyOf"][0] + assert weeks["required"] == ["call", "rows", "columns"] + assert weeks["properties"]["columns"]["required"] == ["week", "amount"] + labels = schema["properties"]["labels"]["properties"]["from"]["anyOf"][0] + assert labels["required"] == ["call", "rows", "column"] + + +async def test_an_array_element_is_checked_on_its_own(monkeypatch): + """整组对不上,不等于每个元素都对不上:口径卡取的那一个元素按它自己那一格核对。""" + schema = {"type": "object", "properties": {"weeks": ROW_SCHEMA["properties"]["weeks"]}} + extracted = {"weeks": {"value": [{"week": "2026-W36", "amount": 51}, {"week": "2026-W37", "amount": 600.5}], + "from": {"call": "Q1", "rows": "0-1", "columns": {"week": "week", "amount": "amount"}}}} + Script(monkeypatch, extracted, sqls=[WEEKLY]) + g = graph(card=False, output_schema=schema) + g["nodes"].insert(2, node("card", "metrics", metrics=[ + {"id": "latest", "name": "本周", "format": "thousands", "decimals": 1, "expression": "vars.kpi.weeks[1].amount"}, + {"id": "first", "name": "上周", "format": "thousands", "decimals": 1, "expression": "vars.kpi.weeks[0].amount"}, + {"id": "label", "name": "周", "format": "plain", "expression": "len(vars.kpi.weeks[1].week)"}])) + g["edges"] = [{"source": a["id"], "target": b["id"]} for a, b in zip(g["nodes"], g["nodes"][1:])] + run = await run_manager.start(graph=g, input_payload={}) + row = await wait(run.id) + assert row.status == "succeeded", row.error + + bot = await output_of(row.id, "bot") + assert bot["data_evidence"]["weeks"]["status"] == "mismatch" # 整组:第 0 个元素报错了 + q1 = (await events(row.id, "tool.end", "bot"))[0].data["query_artifact"] + metrics = {m["id"]: m for m in (await output_of(row.id, "card"))["metrics"]} + [latest] = metrics["latest"]["inputs"] + assert latest["status"] == "verified" and "model_value" not in latest + assert latest["locator"] == {"row": 1, "column": "amount"} + assert latest["eid"] == make_eid("cell", q1, {"row": 1, "column": "amount"}) + [first] = metrics["first"]["inputs"] + assert first["status"] == "mismatch" and first["model_value"] == 51 + assert metrics["first"]["value"] == 50.0 # 值以快照为准 + [label] = metrics["label"]["inputs"] + assert label["status"] == "verified" and label["locator"] == {"row": 1, "column": "week"} diff --git a/backend/tests/test_autofix_assist.py b/backend/tests/test_autofix_assist.py new file mode 100644 index 00000000..5360aa7e --- /dev/null +++ b/backend/tests/test_autofix_assist.py @@ -0,0 +1,278 @@ +"""自动修复的 Copilot 兜底(autofix 的 assist: true)。 + +确定性修复修不了的 error 交给 Copilot:模型给剧本,看采纳规则—— +- 正常的修复(error 变少、没有新 error、不降低要求)被采纳,只是预览,不落库 +- 试图删契约的被拒 +- 让错误变多的被拒 +- 要人拿主意的放进 questions,不硬改 +""" +from __future__ import annotations + +import json + +import pytest +from httpx import ASGITransport, AsyncClient +from sqlalchemy import select + +from app.db.base import SessionLocal +from app.db.models import Workflow, WorkflowVersion +from app.main import app + + +@pytest.fixture +async def client(): + async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as c: + yield c + + +def node(nid, ntype, label=None, **config): + return {"id": nid, "type": ntype, "position": {"x": 40, "y": 80}, "data": {"label": label or nid, "config": config}} + + +CONTRACT = {"report_from": "write", "metrics_from": ["card"], "required": ["gmv"], "strict": True} + + +def two_exits() -> dict: + """受管模板没有带契约的出口,而且出口有两个:该给哪个写契约说不准,确定性修复只能交给 Copilot。""" + nodes = [ + node("start", "input", "周期"), + node("fetch", "tool", "取数", tool="db_query__shop", args={"sql": "SELECT SUM(amount) AS gmv FROM orders"}), + node("card", "metrics", "周报口径卡", caliber="周报口径", metrics=[ + {"id": "gmv", "name": "销售额", "unit": "元", "expression": "cell(nodes.fetch, 0, 'gmv')"}]), + node("write", "report", "报告撰写", instructions="写周报"), + node("done", "output", "出具", fields=[{"name": "周报", "value": "{{ nodes.write.text }}"}]), + node("raw", "output", "原始数", fields=[{"name": "数", "value": "{{ nodes.fetch }}"}]), + ] + edges = [{"id": f"e{i}", "source": a, "target": b} for i, (a, b) in enumerate( + [("start", "fetch"), ("fetch", "card"), ("card", "write"), ("write", "done"), ("fetch", "raw")])] + return {"nodes": nodes, "edges": edges, "viewport": {"x": 3, "y": 4, "zoom": 1.2}} + + +class Scripted: + """按剧本吐操作流的模型;记下每次收到的请求。""" + + def __init__(self, ops: list[dict]) -> None: + self.lines = [json.dumps(o, ensure_ascii=False) for o in ops] + self.calls: list[list] = [] + + async def astream(self, messages): + from langchain_core.messages import AIMessageChunk + + self.calls.append(messages) + for line in self.lines: + yield AIMessageChunk(content=line + "\n") + + +def script(monkeypatch, ops: list[dict]) -> Scripted: + import app.api.copilot as copilot + + model = Scripted(ops) + + async def _model(*_a, **_k): + return model, "mock-fast" + + monkeypatch.setattr(copilot, "get_chat_model", _model) + return model + + +async def create(client, graph: dict) -> str: + return (await client.post("/api/workflows", json={"name": "周报-兜底", "graph": graph})).json()["id"] + + +async def rows(wf: str) -> list: + async with SessionLocal() as session: + w = await session.get(Workflow, wf) + vs = (await session.execute(select(WorkflowVersion).where(WorkflowVersion.workflow_id == wf))).scalars() + return [{c.name: getattr(w, c.name) for c in Workflow.__table__.columns}, + [{c.name: getattr(v, c.name) for c in WorkflowVersion.__table__.columns} for v in vs]] + + +async def assist(client, wf: str, **extra) -> dict: + r = await client.post(f"/api/workflows/{wf}/autofix", json={"level": "governed", "apply": [], "assist": True, + **extra}) + assert r.status_code == 200, r.text + return r.json() + + +def done(ops: list[dict]) -> list[dict]: + return [{"op": "plan", "summary": "给出具节点补契约"}, *ops, {"op": "done", "explanation": "补上了出具契约"}] + + +async def test_a_sound_fix_is_adopted_as_a_preview(client, monkeypatch): + model = script(monkeypatch, done([{"op": "update_node", "id": "done", "config": {"contract": CONTRACT}}])) + wf = await create(client, two_exits()) + before = await rows(wf) + out = await assist(client, wf) + assert out["assist"]["ok"] is True and out["assist"]["summary"] == "补上了出具契约" + assert out["applied"] == ["assist"] and out["ok"] is True, out + fixed = out["graph"] + assert next(n for n in fixed["nodes"] if n["id"] == "done")["data"]["config"]["contract"] == CONTRACT + assert fixed["viewport"] == two_exits()["viewport"] and [e["id"] for e in fixed["edges"]] == [ + e["id"] for e in two_exits()["edges"]] + assert out["ops"] == [{"op": "update_node", "id": "done", "config": {"contract": CONTRACT}}] + assert [(c["fix_id"], c["node_id"], c["field"]) for c in out["changes"]] == [("assist", "done", "contract")] + assert await rows(wf) == before # 只是预览 + # 交给模型的请求里有剩下的问题和不许降低要求、不许替人选的约定 + human = next(text for role, text in model.calls[0] if role == "human") + assert "受管模板至少要有一个「成果 / 出具」节点声明出具契约" in human + assert "不许降低要求" in human and "不替人选" in human and "question" in human + + +async def test_deleting_the_contract_is_refused(client, monkeypatch): + graph = two_exits() + graph["nodes"][4]["data"]["config"]["contract"] = {**CONTRACT, "report_from": "fetch"} + graph["nodes"][3] = node("write", "llm", "写周报", prompt="写周报") # 没有报告撰写节点:只能交给 Copilot + script(monkeypatch, done([{"op": "update_node", "id": "done", "config": {"contract": None}}])) + wf = await create(client, graph) + out = await assist(client, wf) + assert out["assist"]["ok"] is False and "assist" not in out["applied"] + [rejected] = [r for r in out["rejected"] if r["fix_id"] == "assist"] + assert "契约" in rejected["reason"] and "降低" in rejected["reason"] + assert out["graph"] == graph and out["ok"] is False + + +async def test_a_fix_that_makes_errors_grow_is_refused(client, monkeypatch): + script(monkeypatch, done([ + {"op": "update_node", "id": "done", "config": {"contract": CONTRACT}}, + {"op": "update_node", "id": "card", "config": {"metrics": [{"id": "gmv"}]}}, # 口径卡的表达式被写丢了 + ])) + wf = await create(client, two_exits()) + out = await assist(client, wf) + assert out["assist"]["ok"] is False + [rejected] = [r for r in out["rejected"] if r["fix_id"] == "assist"] + assert "新的问题" in rejected["reason"] and "expression" in rejected["reason"] + assert out["graph"] == two_exits() + + +async def test_removing_a_node_is_refused_even_if_it_helps(client, monkeypatch): + script(monkeypatch, done([ + {"op": "remove_node", "id": "raw"}, + {"op": "update_node", "id": "done", "config": {"contract": CONTRACT}}, + ])) + wf = await create(client, two_exits()) + out = await assist(client, wf) + [rejected] = [r for r in out["rejected"] if r["fix_id"] == "assist"] + assert "删" in rejected["reason"] and out["graph"] == two_exits() + + +async def test_questions_are_passed_on_without_changes(client, monkeypatch): + script(monkeypatch, [ + {"op": "plan", "summary": "要先问清楚"}, + {"op": "question", "node_id": "done", "text": "两个出口里,哪个是要出具的?「出具」还是「原始数」?"}, + {"op": "question", "node_id": "card", "text": "required 要包括哪些指标?候选:gmv"}, + {"op": "question", "text": " "}, + {"op": "done", "explanation": "等你决定出具的是哪个出口"}, + ]) + wf = await create(client, two_exits()) + out = await assist(client, wf) + # 一句一条的文字,前面标上是哪个节点的事(句子里已经点了名的不重复标) + assert out["assist"] == {"ok": False, "summary": "等你决定出具的是哪个出口", "questions": [ + "两个出口里,哪个是要出具的?「出具」还是「原始数」?", "「周报口径卡」:required 要包括哪些指标?候选:gmv"]} + assert out["rejected"] == [] and out["graph"] == two_exits() + + +async def test_deterministic_fixes_run_first_and_copilot_sees_what_is_left(client, monkeypatch): + graph = two_exits() + graph["nodes"].insert(2, node("ask", "agent", "查数员", tools=["db_query__shop"], approval="never", + prompt="用 db_query__shop 查")) + graph["edges"] += [{"source": "start", "target": "ask"}, {"source": "ask", "target": "card"}] + model = script(monkeypatch, done([{"op": "update_node", "id": "done", "config": {"contract": CONTRACT}}])) + wf = await create(client, graph) + out = await assist(client, wf, apply=["governed.agent_approval_never:ask"]) + assert out["applied"] == ["governed.agent_approval_never:ask", "assist"] and out["ok"] is True + human = next(text for role, text in model.calls[0] if role == "human") + assert "的审批策略是「全部自动放行」" not in human and '"approval": "dangerous"' in human + + +async def test_nothing_left_means_no_model_call(client, monkeypatch): + model = script(monkeypatch, []) + graph = two_exits() + graph["nodes"][4]["data"]["config"]["contract"] = CONTRACT + wf = await create(client, graph) + out = await assist(client, wf) + assert out["assist"]["ok"] is True and model.calls == [] + + +async def test_an_unconfigured_model_is_said_plainly(client, monkeypatch): + import app.api.copilot as copilot + from app.providers.factory import ProviderNotConfigured + + async def _nothing(*_a, **_k): + raise ProviderNotConfigured("没有可用的 provider") + + monkeypatch.setattr(copilot, "get_chat_model", _nothing) + wf = await create(client, two_exits()) + out = await assist(client, wf) + assert out["assist"]["ok"] is False and "设置" in out["assist"]["summary"] + assert out["rejected"] == [] + + +async def test_blocking_issues_only_add_the_gate_when_asked(): + from app.api.copilot import _blocking_issues + + graph = two_exits() + nodes = {n["id"]: n for n in graph["nodes"]} + assert _blocking_issues(nodes, graph["edges"]) == [] + gated = _blocking_issues(nodes, graph["edges"], level="governed") + assert [i["code"] for i in gated] == ["governed.no_contract"] + assert _blocking_issues(nodes, graph["edges"], level="published") == [] + + +def guarded() -> dict: + """受管图里只剩一处 error:协作团队节点。团队后面还有一个「人工把关」节点。""" + nodes = [ + node("start", "input", "周期"), + node("fetch", "tool", "取数", tool="db_query__shop", args={"sql": "SELECT SUM(amount) AS gmv FROM orders"}), + node("card", "metrics", "周报口径卡", caliber="周报口径", metrics=[ + {"id": "gmv", "name": "销售额", "unit": "元", "expression": "cell(nodes.fetch, 0, 'gmv')"}]), + node("team", "supervisor", "复核团队", goal="复核", agents=[{"name": "checker", "tools": []}]), + node("gate", "human", "人工把关", prompt="确认数字"), + node("write", "report", "报告撰写", instructions="写周报"), + node("done", "output", "出具", fields=[{"name": "周报", "value": "{{ nodes.write.text }}"}], contract=CONTRACT), + ] + edges = [{"id": f"e{i}", "source": a, "target": b} for i, (a, b) in enumerate( + [("start", "fetch"), ("fetch", "card"), ("card", "team"), ("team", "gate"), ("gate", "write"), + ("write", "done")])] + return {"nodes": nodes, "edges": edges} + + +@pytest.mark.parametrize("ops, word", [ + # 同一个 id 换了类型:团队换成模型调用,顺手把人工把关换成整形——等于删了重建 + ([{"op": "add_node", "node": {"id": "team", "type": "llm", "label": "复核团队", "config": {"prompt": "复核"}}}, + {"op": "add_node", "node": {"id": "gate", "type": "transform", "label": "人工把关", + "config": {"mode": "expression", "expression": "vars"}}}], "人工把关"), + # 类型没变,也是整个覆盖掉旧节点(配置、位置全丢) + ([{"op": "add_node", "node": {"id": "gate", "type": "human", "label": "人工把关", "config": {"prompt": "随便"}}}, + {"op": "add_node", "node": {"id": "team", "type": "llm", "label": "复核团队", "config": {"prompt": "复核"}}}], + "覆盖"), +]) +async def test_overwriting_an_existing_node_with_add_node_is_refused(client, monkeypatch, ops, word): + graph = guarded() + script(monkeypatch, done(ops)) + wf = await create(client, graph) + before = await rows(wf) + out = await assist(client, wf) + assert out["assist"]["ok"] is False and "assist" not in out["applied"] and out["ok"] is False + [rejected] = [r for r in out["rejected"] if r["fix_id"] == "assist"] + assert "降低" in rejected["reason"] and word in rejected["reason"], rejected + assert out["graph"] == graph and out["changes"] == [] and out["ops"] == [] + assert [(n["id"], n["type"]) for n in out["graph"]["nodes"]] == [(n["id"], n["type"]) for n in graph["nodes"]] + assert await rows(wf) == before + + +async def test_renaming_a_node_while_fixing_another_error_is_adopted(client, monkeypatch): + """改了名的节点上没修好的 error 文案跟着变了,但还是同一处问题:不算冒出新问题。""" + graph = guarded() + graph["nodes"][6]["data"]["config"].pop("contract") # 两处 error:协作团队、没有带契约的出口 + script(monkeypatch, done([ + {"op": "update_node", "id": "team", "label": "复核团队(待拆成固定步骤)"}, + {"op": "update_node", "id": "done", "config": {"contract": CONTRACT}}, + ])) + wf = await create(client, graph) + out = await assist(client, wf) + assert out["applied"] == ["assist"] and out["assist"]["ok"] is True, out["rejected"] + assert [(i["code"], i["node_id"]) for i in out["remaining"] if i["level"] == "error"] == [ + ("governed.supervisor", "team")] + assert "复核团队(待拆成固定步骤)" in out["remaining"][0]["message"] or any( + "复核团队(待拆成固定步骤)" in i["message"] for i in out["remaining"]) + assert {(c["node_id"], c["field"]) for c in out["changes"]} == {("team", "label"), ("done", "contract")} diff --git a/backend/tests/test_autofix_graph_defaults.py b/backend/tests/test_autofix_graph_defaults.py new file mode 100644 index 00000000..b2770d66 --- /dev/null +++ b/backend/tests/test_autofix_graph_defaults.py @@ -0,0 +1,251 @@ +"""受管门禁看全图默认的审批策略,自动修复改的也是全图默认。 + +agent 自己没写 approval 时,运行时跟随图级 defaults(context.approval_mode:节点 > 图级 defaults > +全局设置)。全图默认写着「全部自动放行」,带工具的 agent 就是全部自动放行,门禁以前只看节点 +自己写的值,漏了这一类。 + +- 门禁:每个跟随它的 agent 各报一条(能定位到节点),code 是 governed.default_approval_never; +- 修复:只有一条 auto 修复(落在全图上,不落在节点上),把 defaults.approval 改成 dangerous, + 跟随它的几个节点一起好;节点自己的配置一个字不动; +- 放宽方向照旧禁止。 +""" +from __future__ import annotations + +import copy + +import pytest +from httpx import ASGITransport, AsyncClient + +from app.engine import autofix +from app.engine.autofix import annotate, apply_fixes, check, forbidden_changes, plan_fixes +from app.engine.governance import lint_for_publish, publish_issues +from app.engine.schema import GraphSpec +from app.main import app + +CODE = "governed.default_approval_never" +FIX = f"{CODE}:graph" + + +def node(nid, ntype, label=None, **config): + return {"id": nid, "type": ntype, "position": {"x": 10, "y": 20}, "width": 240, + "data": {"label": label or nid, "config": config}} + + +def weekly(default: str | None = "never", **ask_config) -> dict: + """input → 查数员(agent,自己没写审批)→ 口径卡 → 报告撰写 → 出口,其余受管要求都满足。""" + ask = {"tools": ["db_query__shop"], "prompt": "用 db_query__shop 查 orders 的 gmv", **ask_config} + nodes = [ + node("start", "input", "周期"), + node("ask", "agent", "查数员", **ask), + node("card", "metrics", "周报口径卡", caliber="周报口径", metrics=[ + {"id": "gmv", "name": "销售额", "unit": "元", "expression": "vars.gmv"}]), + node("write", "report", "报告撰写", instructions="写周报"), + node("done", "output", "出具", fields=[{"name": "周报", "value": "{{ nodes.write.text }}"}], + contract={"report_from": "write", "metrics_from": ["card"], "required": ["gmv"], "strict": True}), + ] + edges = [{"id": f"e{i}", "source": a["id"], "target": b["id"]} for i, (a, b) in enumerate(zip(nodes, nodes[1:]))] + graph = {"nodes": nodes, "edges": edges, "viewport": {"x": 1, "y": 2, "zoom": 1}} + if default is not None: + graph["defaults"] = {"approval": default, "model": "gpt-4o-mini"} + return graph + + +def add_agent(graph: dict, nid: str = "ask2", label: str = "复核员", **config) -> dict: + graph["nodes"].insert(2, node(nid, "agent", label, prompt="用 db_query__shop 复核", **config)) + graph["edges"] += [{"source": "start", "target": nid}, {"source": nid, "target": "card"}] + return graph + + +def issues_of(graph: dict, level: str = "governed", code: str = CODE) -> list: + return [i for i in publish_issues(GraphSpec.model_validate(graph), level=level) if i.code == code] + + +def config(graph: dict, nid: str) -> dict: + return next(n for n in graph["nodes"] if n["id"] == nid)["data"]["config"] + + +# -------------------------------------------------------------------------- +# 门禁 +# -------------------------------------------------------------------------- + + +def test_an_agent_following_a_never_default_is_blocked_on_the_governed_level(): + found = issues_of(weekly()) + assert len(found) == 1 + issue = found[0] + assert issue.level == "error" and issue.node_id == "ask" and issue.field == "approval" + assert "跟随全图默认" in issue.message and "全部自动放行" in issue.message and "「查数员」" in issue.message + # 已发布档照旧只给警告 + assert [i.level for i in issues_of(weekly(), level="published")] == ["warning"] + + +@pytest.mark.parametrize("graph", [ + weekly(default="dangerous"), + weekly(default="always"), + weekly(default=None), # 没写全图默认:退到全局设置,门禁不查全局 + weekly(approval="dangerous"), # 节点自己写明了,不跟随全图默认 + weekly(approval="always"), + weekly(tools=[]), # 没配工具,没有可放行的 +], ids=["default-dangerous", "default-always", "no-default", "own-dangerous", "own-always", "no-tools"]) +def test_nothing_to_report_when_the_agent_does_not_run_wide_open(graph): + assert issues_of(graph) == [] + + +def test_an_empty_approval_counts_as_not_written(): + """运行时 ctx.cfg 把 None 和空串都当没写、往全图默认回退:门禁同一个口径。""" + assert [i.node_id for i in issues_of(weekly(approval=""))] == ["ask"] + assert [i.node_id for i in issues_of(weekly(approval=None))] == ["ask"] + + +def test_an_explicit_never_keeps_its_own_code(): + """节点自己写着全部自动放行的,照旧是 agent_approval_never(修的是节点),不重复报一条跟随默认的。""" + graph = weekly(approval="never") + assert [i.node_id for i in issues_of(graph, code="governed.agent_approval_never")] == ["ask"] + assert issues_of(graph) == [] + + +def test_every_following_agent_is_named_on_its_own_node(): + graph = add_agent(weekly(), tools=["db_query__shop"]) + assert sorted(i.node_id for i in issues_of(graph)) == ["ask", "ask2"] + + +# -------------------------------------------------------------------------- +# 修复 +# -------------------------------------------------------------------------- + + +def test_one_graph_level_auto_fix_serves_every_follower(): + graph = add_agent(weekly(), tools=["db_query__shop"]) + spec = GraphSpec.model_validate(graph) + fixes = {f["id"]: f for f in plan_fixes(spec, publish_issues(spec, level="governed"), level="governed")} + assert list(fixes) == [FIX] + fix = fixes[FIX] + assert fix["kind"] == "auto" and fix["node_id"] is None and fix["code"] == CODE + assert fix["preview"] == {"field": "defaults.approval", "before": "never", "after": "dangerous"} + assert "全图默认" in fix["label"] and "「查数员」" in fix["label"] and "「复核员」" in fix["label"] + # 两条问题都挂上同一个修复 id(问题在节点上,修复不在) + out = check(spec, level="governed") + assert {i["node_id"]: i["fix"] for i in out["issues"] if i["code"] == CODE} == {"ask": FIX, "ask2": FIX} + assert [f["id"] for f in out["fixes"]] == [FIX] + + +def test_applying_it_changes_the_graph_default_and_nothing_else(): + graph = add_agent(weekly(), tools=["db_query__shop"]) + before = copy.deepcopy(graph) + spec = GraphSpec.model_validate(graph) + label = plan_fixes(spec, publish_issues(spec, level="governed"), level="governed")[0]["label"] + out = apply_fixes(graph, [FIX], level="governed") + assert out["applied"] == [FIX] and out["rejected"] == [] + assert out["graph"]["defaults"] == {"approval": "dangerous", "model": "gpt-4o-mini"} + assert out["ok"] is True and CODE not in {i["code"] for i in out["remaining"]} + # 节点一个字不动:没给它们写上自己的审批,位置、尺寸、连线、视口原样 + assert out["graph"]["nodes"] == before["nodes"] and out["graph"]["edges"] == before["edges"] + assert out["graph"]["viewport"] == before["viewport"] + assert graph == before # 入参没被改 + assert out["changes"] == [{"fix_id": FIX, "node_id": None, "node_title": "", "field": "defaults.approval", + "before": "never", "after": "dangerous", "label": label}] + assert out["ops"] == [{"op": "update_defaults", "defaults": {"approval": "dangerous"}}] + assert forbidden_changes(before, out["graph"]) == [] + + +def test_a_graph_without_defaults_gets_one(): + """defaults 缺成别的类型也补成对象再写(编辑器不崩、不丢修复)。""" + graph = weekly() + out = apply_fixes(graph, [FIX], level="governed") + assert out["graph"]["defaults"]["approval"] == "dangerous" + editor = autofix._EDITORS[CODE] + trial = {"nodes": [], "edges": [], "defaults": None} + edits = editor(trial, {"_set": {"defaults.approval": "dangerous"}, "kind": "auto"}, None) + assert trial["defaults"] == {"approval": "dangerous"} + assert edits == [(None, "defaults.approval", None, "dangerous")] + + +def test_mixed_explicit_and_following_agents_get_one_fix_each_way(): + graph = add_agent(weekly(), approval="never", tools=["db_query__shop"]) + spec = GraphSpec.model_validate(graph) + ids = [f["id"] for f in plan_fixes(spec, publish_issues(spec, level="governed"), level="governed")] + assert sorted(ids) == sorted([FIX, "governed.agent_approval_never:ask2"]) + out = apply_fixes(graph, ids, level="governed") + assert sorted(out["applied"]) == sorted(ids) and out["ok"] is True + assert out["graph"]["defaults"]["approval"] == "dangerous" and config(out["graph"], "ask2")["approval"] == "dangerous" + assert "approval" not in config(out["graph"], "ask") + + +def test_a_rule_that_does_not_really_fix_it_is_rejected(monkeypatch): + """图级修复要管住这个编号下挂在各个节点上的每一条:改完还在就不采纳,不能因为问题都挂在 + 节点上、修复不在节点上,就当成「本来就没有这处 error」放行。""" + monkeypatch.setitem(autofix._EDITORS, CODE, lambda graph, fix, value: []) + out = apply_fixes(weekly(), [FIX], level="governed") + assert out["applied"] == [] and out["rejected"][0]["fix_id"] == FIX + assert "还在" in out["rejected"][0]["reason"] + + +@pytest.mark.parametrize("prev, now, word", [ + ("dangerous", "never", "全部自动放行"), + ("always", "never", "全部自动放行"), + ("always", "dangerous", "放宽"), + ("dangerous", None, "全图默认"), +]) +def test_loosening_the_graph_default_is_still_forbidden(prev, now, word): + before = weekly(default=prev) + after = copy.deepcopy(before) + if now is None: + after["defaults"].pop("approval") + else: + after["defaults"]["approval"] = now + reasons = forbidden_changes(before, after) + assert reasons and any(word in r for r in reasons), reasons + + +def test_annotate_hands_a_graph_level_fix_to_node_issues_only_by_code(): + """图级修复认同一编号的每一条问题;别的编号、没有编号的不认。""" + fixes = [{"id": FIX, "code": CODE, "node_id": None}] + issues = [{"level": "error", "message": "a", "code": CODE, "node_id": "ask"}, + {"level": "error", "message": "b", "code": "governed.agent_approval_never", "node_id": "ask"}, + {"level": "error", "message": "c", "code": None, "node_id": "ask"}] + assert [i["fix"] for i in annotate(issues, fixes)] == [FIX, None, None] + + +# -------------------------------------------------------------------------- +# 接口:发布前检查和自动修复都认,而且只读 +# -------------------------------------------------------------------------- + + +@pytest.fixture +async def client(): + async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as c: + yield c + + +async def test_the_endpoints_report_and_fix_the_graph_default(client): + graph = add_agent(weekly(), tools=["db_query__shop"]) + r = await client.post("/api/workflows", json={"name": "周报-全图默认", "graph": graph}) + assert r.status_code == 201, r.text + wid = r.json()["id"] + stored = (await client.get(f"/api/workflows/{wid}")).json() + + r = await client.post(f"/api/workflows/{wid}/publish-check", json={"level": "governed"}) + body = r.json() + assert r.status_code == 200 and body["ok"] is False + assert {i["node_id"]: i["fix"] for i in body["issues"] if i["code"] == CODE} == {"ask": FIX, "ask2": FIX} + assert [f["id"] for f in body["fixes"]] == [FIX] + + r = await client.post(f"/api/workflows/{wid}/autofix", json={"level": "governed", "apply": [FIX]}) + out = r.json() + assert r.status_code == 200 and out["ok"] is True and out["applied"] == [FIX] + assert out["graph"]["defaults"]["approval"] == "dangerous" + # 只读:库里还是原来那张 + after = (await client.get(f"/api/workflows/{wid}")).json() + assert after["graph"] == stored["graph"] and after["version"] == stored["version"] + + # 真发布同一个口径:原图被拦,修好的图存下去能发成受管 + blocked = (await client.post(f"/api/workflows/{wid}/publish", json={"level": "governed"})).json() + assert blocked["ok"] is False and CODE in {i.get("code") for i in blocked["issues"]} + await client.patch(f"/api/workflows/{wid}", json={"graph": out["graph"]}) + assert (await client.post(f"/api/workflows/{wid}/publish", json={"level": "governed"})).json()["ok"] is True + + +def test_lint_reads_defaults_off_the_spec_not_the_node(): + """GraphSpec 的 defaults 缺省是空对象:老图没有这个键,门禁照旧。""" + spec = GraphSpec.model_validate({k: v for k, v in weekly().items() if k != "defaults"}) + assert [i for i in lint_for_publish(spec, level="governed").issues if i.code == CODE] == [] diff --git a/backend/tests/test_autofix_rules.py b/backend/tests/test_autofix_rules.py new file mode 100644 index 00000000..2c36caf0 --- /dev/null +++ b/backend/tests/test_autofix_rules.py @@ -0,0 +1,517 @@ +"""发布前自动修复的确定性规则(engine/autofix.py)。 + +原则:只修「答案唯一、不降低要求」的;要人拿主意的给候选、不替人选;修完重跑 validate 和门禁, +错误必须变少、不能冒出新错误;删节点连线、删空契约、审批改成全部放行、strict 改成 false +这些降低要求的操作一律拒绝。 +""" +from __future__ import annotations + +import copy + +import pytest + +from app.engine import autofix +from app.engine.autofix import apply_fixes, forbidden_changes, judge, plan_fixes +from app.engine.governance import publish_issues +from app.engine.schema import GraphSpec, ValidationIssue + + +def node(nid, ntype, label=None, **config): + return {"id": nid, "type": ntype, "position": {"x": 10, "y": 20}, "width": 240, + "data": {"label": label or nid, "config": config}} + + +def weekly(**contract_overrides) -> dict: + """input → 查库 → 口径卡 → 报告撰写 → 出口(引用模式契约),受管要求都满足。""" + contract = {"report_from": "write", "metrics_from": ["card"], "required": ["gmv"], "strict": True} + contract.update(contract_overrides) + nodes = [ + node("start", "input", "周期"), + node("fetch", "tool", "取数", tool="db_query__shop", + args={"sql": "SELECT SUM(amount) AS gmv, COUNT(*) AS orders FROM orders"}), + node("card", "metrics", "周报口径卡", caliber="周报口径", metrics=[ + {"id": "gmv", "name": "销售额", "unit": "元", "expression": "cell(nodes.fetch, 0, 'gmv')"}, + {"id": "orders", "name": "订单数", "unit": "单", "expression": "cell(nodes.fetch, 0, 'orders')"}]), + node("write", "report", "报告撰写", instructions="写周报"), + node("done", "output", "出具", fields=[{"name": "周报", "value": "{{ nodes.write.text }}"}], + contract={k: v for k, v in contract.items() if v is not None}), + ] + edges = [{"id": f"e{i}", "source": a["id"], "target": b["id"]} for i, (a, b) in enumerate(zip(nodes, nodes[1:]))] + return {"nodes": nodes, "edges": edges, "viewport": {"x": 1, "y": 2, "zoom": 1}} + + +def find(graph: dict, nid: str) -> dict: + return next(n for n in graph["nodes"] if n["id"] == nid) + + +def config(graph: dict, nid: str) -> dict: + return find(graph, nid)["data"]["config"] + + +def plan(graph: dict, level: str = "governed", **kw) -> dict[str, dict]: + spec = GraphSpec.model_validate(graph) + return {f["id"]: f for f in plan_fixes(spec, publish_issues(spec, level=level), level=level, **kw)} + + +def errors(graph: dict, level: str = "governed") -> list: + return [i for i in publish_issues(GraphSpec.model_validate(graph), level=level) if i.level == "error"] + + +def codes(issues) -> set[str]: + return {i["code"] if isinstance(i, dict) else i.code for i in issues} + + +def add_card(graph: dict, nid: str = "card2") -> dict: + """第二张口径卡:接在查库后面、报告前面。""" + graph["nodes"].insert(3, node(nid, "metrics", "第二张口径卡", caliber="周报口径", metrics=[ + {"id": "refunds", "name": "退款数", "expression": "cell(nodes.fetch, 0, 'orders')"}])) + graph["edges"] += [{"source": "fetch", "target": nid}, {"source": nid, "target": "write"}] + return graph + + +def add_report(graph: dict, nid: str = "write2") -> dict: + graph["nodes"].insert(4, node(nid, "report", "第二份报告", instructions="写月报")) + graph["edges"] += [{"source": "card", "target": nid}, {"source": nid, "target": "done"}] + return graph + + +def test_the_baseline_graph_passes_the_governed_gate(): + assert errors(weekly()) == [] + assert plan(weekly()) == {} + + +# -------------------------------------------------------------------------- +# auto:一键修好 +# -------------------------------------------------------------------------- + + +def test_agent_approval_never_becomes_dangerous(): + graph = weekly() + graph["nodes"].insert(2, node("ask", "agent", "查数员", tools=["db_query__shop"], approval="never", + prompt="用 db_query__shop 查")) + graph["edges"] += [{"source": "start", "target": "ask"}, {"source": "ask", "target": "card"}] + fid = "governed.agent_approval_never:ask" + fix = plan(graph)[fid] + assert fix["kind"] == "auto" and fix["node_id"] == "ask" + assert fix["preview"] == {"field": "approval", "before": "never", "after": "dangerous"} + out = apply_fixes(graph, [fid], level="governed") + assert out["applied"] == [fid] and out["rejected"] == [] + assert config(out["graph"], "ask")["approval"] == "dangerous" + assert out["ok"] is True and "governed.agent_approval_never" not in codes(out["remaining"]) + assert out["changes"] == [{"fix_id": fid, "node_id": "ask", "node_title": "查数员", "field": "approval", + "before": "never", "after": "dangerous", "label": fix["label"]}] + assert out["ops"] == [{"op": "update_node", "id": "ask", "config": {"approval": "dangerous"}}] + + +def test_an_unpinned_subgraph_is_pinned_to_the_published_version(): + graph = weekly() + graph["nodes"].insert(1, node("lib", "subgraph", "方法卡", workflow_id="wf-lib")) + graph["edges"] += [{"source": "start", "target": "lib"}, {"source": "lib", "target": "fetch"}] + fid = "governed.subgraph_unpinned:lib" + fix = plan(graph, versions={"wf-lib": 3})[fid] + assert fix["kind"] == "auto" and fix["preview"] == {"field": "workflow_version", "before": None, "after": 3} + out = apply_fixes(graph, [fid], level="governed", versions={"wf-lib": 3}) + assert config(out["graph"], "lib")["workflow_version"] == 3 and out["ok"] is True + # 上游没有发布过:不替人挑一个草稿版本 + assert plan(graph, versions={"wf-lib": None})[fid]["kind"] == "assist" + assert plan(graph)[fid]["kind"] == "assist" + + +def test_a_missing_metrics_from_takes_the_only_upstream_card(): + graph = weekly(metrics_from=None) + fid = "contract.metrics_from_missing:done" + fix = plan(graph)[fid] + assert fix["kind"] == "auto" and fix["preview"]["after"] == ["card"] + out = apply_fixes(graph, [fid], level="governed") + assert config(out["graph"], "done")["contract"]["metrics_from"] == ["card"] + assert out["ok"] is True and len(errors(graph)) >= 2 # validate 和门禁各报一条,都消掉了 + + +def test_several_cards_make_metrics_from_a_choice(): + graph = add_card(weekly(metrics_from=None)) + fid = "contract.metrics_from_missing:done" + fix = plan(graph)[fid] + assert fix["kind"] == "choice" and fix["multiple"] is True and "preview" not in fix + assert [o["value"] for o in fix["options"]] == ["card", "card2"] + assert fix.get("default") is None + # 没选就不应用 + out = apply_fixes(graph, [fid], level="governed") + assert out["applied"] == [] and out["rejected"][0]["fix_id"] == fid + assert "metrics_from" not in config(out["graph"], "done")["contract"] + out = apply_fixes(graph, [fid], {fid: ["card2"]}, level="governed") + assert config(out["graph"], "done")["contract"]["metrics_from"] == ["card2"] and out["applied"] == [fid] + # 选了候选之外的 + out = apply_fixes(graph, [fid], {fid: ["ghost"]}, level="governed") + assert out["applied"] == [] and "候选" in out["rejected"][0]["reason"] + + +def test_a_missing_report_from_takes_the_only_upstream_report(): + graph = weekly(report_from=None) + fid = "contract.report_from_missing:done" + fix = plan(graph)[fid] + assert fix["kind"] == "auto" and fix["preview"] == {"field": "contract.report_from", "before": None, + "after": "write"} + out = apply_fixes(graph, [fid], level="governed") + assert config(out["graph"], "done")["contract"]["report_from"] == "write" and out["ok"] is True + + +def test_several_reports_make_report_from_a_choice(): + graph = add_report(weekly(report_from="fetch")) + fid = "contract.report_from_invalid:done" + fix = plan(graph)[fid] + assert fix["kind"] == "choice" and not fix.get("multiple") + assert [o["value"] for o in fix["options"]] == ["write", "write2"] + out = apply_fixes(graph, [fid], {fid: "write2"}, level="governed") + assert config(out["graph"], "done")["contract"]["report_from"] == "write2" and out["ok"] is True + + +def test_a_wrong_report_from_takes_the_only_upstream_report(): + graph = weekly(report_from="fetch") + fid = "contract.report_from_invalid:done" + fix = plan(graph)[fid] + assert fix["kind"] == "auto" and fix["preview"]["before"] == "fetch" and fix["preview"]["after"] == "write" + out = apply_fixes(graph, [fid], level="governed") + assert out["ok"] is True and config(out["graph"], "done")["contract"]["report_from"] == "write" + + +def test_without_a_report_node_the_narrative_is_a_choice_over_model_nodes(): + graph = weekly(report_from=None) + write = find(graph, "write") + write.update(type="llm", data={"label": "写周报", "config": {"prompt": "写周报", "assign_to": "report"}}) + fid = "contract.report_from_missing:done" + fix = plan(graph)[fid] + assert fix["kind"] == "choice" and fix["field"] == "contract.narrative" + assert [o["value"] for o in fix["options"]] == ["{{ nodes.write.text }}"] + out = apply_fixes(graph, [fid], {fid: "{{ nodes.write.text }}"}, level="governed") + assert config(out["graph"], "done")["contract"]["narrative"] == "{{ nodes.write.text }}" + assert "contract.report_from_missing" not in codes(out["remaining"]) + + +def test_strict_off_is_turned_on(): + graph = weekly(strict=False) + fid = "contract.strict_off:done" + fix = plan(graph)[fid] + assert fix["kind"] == "auto" and fix["preview"] == {"field": "contract.strict", "before": False, "after": True} + out = apply_fixes(graph, [fid], level="governed") + assert config(out["graph"], "done")["contract"]["strict"] is True + assert "contract.strict_off" not in codes(out["remaining"]) + + +def test_report_metrics_from_drops_bad_entries_and_adds_the_only_card(): + graph = weekly() + config(graph, "write")["metrics_from"] = ["ghost", "fetch"] + fid = "report.metrics_from_invalid:write" + fix = plan(graph)[fid] + assert fix["kind"] == "auto" and fix["preview"] == {"field": "metrics_from", "before": ["ghost", "fetch"], + "after": ["card"]} + out = apply_fixes(graph, [fid], level="governed") + assert config(out["graph"], "write")["metrics_from"] == ["card"] and out["ok"] is True + # 留下有效的那几项,不重复补 + graph = add_card(weekly()) + config(graph, "write")["metrics_from"] = ["card2", "ghost"] + fix = plan(graph)[fid] + assert fix["preview"]["after"] == ["card2"] + + +def test_a_contract_metrics_from_pointing_nowhere_is_repaired(): + graph = weekly(metrics_from=["ghost"]) + fid = "contract.metrics_from_invalid:done" + fix = plan(graph)[fid] + assert fix["kind"] == "auto" and fix["preview"]["after"] == ["card"] + assert apply_fixes(graph, [fid], level="governed")["ok"] is True + + +# -------------------------------------------------------------------------- +# choice:要人拿主意,不替人选 +# -------------------------------------------------------------------------- + + +def test_required_is_a_choice_over_the_contract_cards_metrics(): + graph = weekly(required=None) + fid = "contract.required_missing:done" + fix = plan(graph)[fid] + assert fix["kind"] == "choice" and fix["multiple"] is True and fix.get("default") is None + assert [o["value"] for o in fix["options"]] == ["gmv", "orders"] + assert all("周报口径卡" in (o.get("hint") or "") for o in fix["options"]) + # 「一键修复」只应用 auto 的:required 不会被顺手填上 + autos = [f for f, v in plan(graph).items() if v["kind"] == "auto"] + assert fid not in autos + out = apply_fixes(graph, autos + [fid], level="governed") + assert "required" not in config(out["graph"], "done")["contract"] + assert any(r["fix_id"] == fid for r in out["rejected"]) + out = apply_fixes(graph, [fid], {fid: ["gmv"]}, level="governed") + assert config(out["graph"], "done")["contract"]["required"] == ["gmv"] and out["ok"] is True + # 空选择不算选了 + out = apply_fixes(graph, [fid], {fid: []}, level="governed") + assert out["applied"] == [] + + +def test_required_candidates_follow_pinned_cards(): + graph = weekly(required=None) + card = config(graph, "card") + card.pop("metrics") + card["caliber_from"] = {"workflow_id": "wf-lib", "workflow_version": 2, "node_id": "kpi"} + fid = "contract.required_missing:done" + fix = plan(graph, cards={"card": [{"id": "net", "name": "净额"}]})[fid] + assert fix["kind"] == "choice" and [o["value"] for o in fix["options"]] == ["net"] + # 查不到钉住那张卡的指标:列不出候选,交给 Copilot(它只能提问,不能编) + assert plan(graph)[fid]["kind"] == "assist" + + +def test_a_governed_graph_without_a_contract_gets_a_skeleton(): + graph = weekly() + config(graph, "done").pop("contract") + fid = "governed.no_contract:done" + fix = plan(graph)[fid] + assert fix["kind"] == "choice" and fix["node_id"] == "done" and fix["multiple"] is True + assert [o["value"] for o in fix["options"]] == ["gmv", "orders"] + out = apply_fixes(graph, [fid], {fid: ["orders"]}, level="governed") + assert config(out["graph"], "done")["contract"] == { + "report_from": "write", "metrics_from": ["card"], "required": ["orders"], "strict": True} + assert out["ok"] is True + # 口径卡不止一张:骨架该指向哪张说不准,交给 Copilot + graph = add_card(graph) + assert plan(graph)[fid]["kind"] == "assist" + + +def test_a_skeleton_issue_carries_the_fix_on_the_graph_level_issue(): + graph = weekly() + config(graph, "done").pop("contract") + spec = GraphSpec.model_validate(graph) + issues = publish_issues(spec, level="governed") + marked = autofix.annotate(issues, plan_fixes(spec, issues, level="governed")) + [issue] = [i for i in marked if i["code"] == "governed.no_contract"] + assert issue["node_id"] is None and issue["fix"] == "governed.no_contract:done" + + +def test_structural_problems_go_to_copilot(): + graph = weekly() + graph["nodes"].insert(1, node("team", "supervisor", "协作团队", goal="查数", agents=[{"name": "a"}])) + graph["edges"] += [{"source": "start", "target": "team"}, {"source": "team", "target": "fetch"}] + fix = plan(graph)["governed.supervisor:team"] + assert fix["kind"] == "assist" and "固定编排" in fix["label"] and fix["node_id"] == "team" + out = apply_fixes(graph, ["governed.supervisor:team"], level="governed") + assert out["applied"] == [] and out["rejected"][0]["fix_id"] == "governed.supervisor:team" + + +# -------------------------------------------------------------------------- +# 修完必须更好;降低要求的一律拒 +# -------------------------------------------------------------------------- + + +def broken() -> dict: + graph = weekly(metrics_from=None, report_from="fetch", strict=False) + graph["nodes"].insert(2, node("ask", "agent", "查数员", tools=["db_query__shop"], approval="never", + prompt="用 db_query__shop 查")) + graph["edges"] += [{"source": "start", "target": "ask"}, {"source": "ask", "target": "card"}] + return graph + + +def test_all_auto_fixes_together_leave_fewer_errors_and_keep_the_rest_of_the_graph(): + graph = broken() + before = errors(graph) + autos = [f for f, v in plan(graph).items() if v["kind"] == "auto"] + assert len(autos) == 4, autos + out = apply_fixes(graph, autos, level="governed") + assert sorted(out["applied"]) == sorted(autos) and out["rejected"] == [] + assert len(errors(out["graph"])) < len(before) and out["ok"] is True + # 只动了修复涉及的字段:坐标、宽度、连线 id、视口原样 + assert out["graph"]["viewport"] == graph["viewport"] + assert [e.get("id") for e in out["graph"]["edges"]] == [e.get("id") for e in graph["edges"]] + assert all(n.get("width") == 240 and n["position"] == {"x": 10, "y": 20} for n in out["graph"]["nodes"]) + # 原图没被就地改掉 + assert graph == broken() + + +def test_a_stale_fix_id_is_rejected(): + out = apply_fixes(weekly(), ["contract.strict_off:done"], level="governed") + assert out["applied"] == [] and out["rejected"][0]["fix_id"] == "contract.strict_off:done" + + +def test_an_edit_that_makes_things_worse_is_discarded(monkeypatch): + """修复规则自己出错(比如顺手把 metrics_from 删了):复核发现冒出新错误,整条丢弃。""" + real = autofix._EDITORS["contract.strict_off"] + + def sloppy(graph, fix, value): + edits = real(graph, fix, value) + contract = autofix._node(graph, fix["node_id"])["data"]["config"]["contract"] + contract.pop("metrics_from") + return edits + + monkeypatch.setitem(autofix._EDITORS, "contract.strict_off", sloppy) + graph = weekly(strict=False) + out = apply_fixes(graph, ["contract.strict_off:done"], level="governed") + assert out["applied"] == [] and out["graph"] == graph + [rejected] = out["rejected"] + assert "新" in rejected["reason"], rejected + + +def test_judge_wants_fewer_errors_and_nothing_new(): + a = ValidationIssue(level="error", message="a", node_id="n", code="x.a") + b = ValidationIssue(level="error", message="b", node_id="n", code="x.b") + c = ValidationIssue(level="error", message="c", node_id="m", code="x.c") + w = ValidationIssue(level="warning", message="w", node_id="n", code="x.w") + assert judge([a, b], [b]) is None + assert judge([a, b], [a, b]) is not None # 没变少 + assert judge([a, b], [b, c]) is not None # 冒出新的 + assert judge([a, w], [a], target=("x.w", "n")) is None # 只修警告:错误不增加、那条警告没了 + assert judge([a, w], [a, w], target=("x.w", "n")) is not None + + +def test_judge_knows_an_issue_by_its_code_not_its_wording(): + """节点改了名,它上面没修好的 error 文案跟着变:有编号的按编号认,还是同一处问题。""" + a = ValidationIssue(level="error", message="「复核团队」不允许", node_id="n", code="x.a") + renamed = ValidationIssue(level="error", message="「复核团队(待拆)」不允许", node_id="n", code="x.a") + b = ValidationIssue(level="error", message="b", node_id="m", code="x.b") + assert judge([a, b], [renamed]) is None + assert judge([a, b], [renamed, ValidationIssue(level="error", message="c", node_id="n", code="x.c")]) is not None + # 没编号的问题只能按文案认 + plain = ValidationIssue(level="error", message="旧文案", node_id="n") + assert judge([plain, b], [ValidationIssue(level="error", message="新文案", node_id="n")]) is not None + + +def raised(default: str = "dangerous") -> dict: + """要求拧得比较高的基线:取数节点每次调用都审批,查数员仅危险工具需要审批,全图默认也写明了, + 契约只放行 7 这一个不带出处的数字。""" + graph = weekly(allow_numbers=["7"]) + config(graph, "fetch")["approval"] = "always" + graph["nodes"].insert(2, node("ask", "agent", "查数员", tools=["db_query__shop"], approval="dangerous", + prompt="用 db_query__shop 查")) + graph["defaults"] = {"approval": default} + return graph + + +@pytest.mark.parametrize("mutate, word", [ + (lambda g: g["nodes"].pop(1), "删"), + (lambda g: g["edges"].pop(0), "连线"), + (lambda g: config(g, "done").pop("contract"), "契约"), + (lambda g: config(g, "done").update(contract={}), "契约"), + (lambda g: config(g, "done")["contract"].update(strict=False), "strict"), + (lambda g: config(g, "done")["contract"].update(required=[]), "required"), + (lambda g: config(g, "write").update(approval="never"), "全部自动放行"), + (lambda g: g.setdefault("defaults", {}).update(approval="never"), "全部自动放行"), + # 审批按「每次调用都审批 > 仅危险工具需要审批 > 全部自动放行」排,往宽里改、删掉都算降低要求 + (lambda g: config(g, "fetch").update(approval="dangerous"), "放宽"), + (lambda g: config(g, "fetch").update(approval="Always"), "不认识"), + # 删掉写明的审批,运行时退回全图默认(这里是仅危险工具需要审批),比原来的每次调用都审批宽 + (lambda g: config(g, "fetch").pop("approval"), "去掉了"), + (lambda g: config(g, "fetch").update(approval=None), "去掉了"), + (lambda g: config(g, "fetch").update(approval=""), "去掉了"), + # 全图默认删掉,退回全局设置:可能是全部自动放行 + (lambda g: g["defaults"].pop("approval"), "全图默认"), + # 白名单里的数字不用引用也能过;cells 要作者显式声明,不由模型替人打开 + (lambda g: config(g, "done")["contract"]["allow_numbers"].append("120"), "allow_numbers"), + (lambda g: config(g, "done")["contract"].update(allow_numbers=["7", "37"]), "allow_numbers"), + (lambda g: config(g, "done")["contract"].update(cells=True), "cells"), + # 同一个 id 换了类型:等于删了重建 + (lambda g: find(g, "write").update(type="llm"), "换成"), +]) +def test_lowering_the_bar_is_forbidden(mutate, word): + before = raised() + after = copy.deepcopy(before) + mutate(after) + reasons = forbidden_changes(before, after) + assert reasons and any(word in r for r in reasons), reasons + + +def test_unpinning_a_subgraph_is_forbidden(): + before = weekly() + before["nodes"].insert(1, node("lib", "subgraph", "方法卡", workflow_id="wf-lib", workflow_version=2)) + after = copy.deepcopy(before) + config(after, "lib").pop("workflow_version") + assert any("版本" in r for r in forbidden_changes(before, after)) + + +@pytest.mark.parametrize("op", [{"op": "remove_node", "id": "fetch"}, + {"op": "remove_edge", "source": "start", "target": "fetch"}, + {"op": "publish", "level": "published"}]) +def test_forbidden_ops_are_named(op): + assert forbidden_changes(weekly(), weekly(), [op]) + + +@pytest.mark.parametrize("mutate, word", [ + (lambda g: config(g, "card").update(approval="dangerous"), "跟随全图默认"), # 原来跟全图默认:每次调用都审批 + (lambda g: g["defaults"].update(approval="dangerous"), "全图默认"), + (lambda g: (g["defaults"].pop("approval"), config(g, "card").update(approval="dangerous")), "放宽"), +]) +def test_lowering_a_strict_graph_default_is_forbidden(mutate, word): + before = raised("always") + after = copy.deepcopy(before) + mutate(after) + reasons = forbidden_changes(before, after) + assert reasons and any(word in r for r in reasons), reasons + + +def test_dropping_a_node_approval_under_a_stricter_default_is_fine(): + """全图默认是每次调用都审批:删掉节点上的「仅危险工具需要审批」,运行时反而更严。""" + before = raised("always") + after = copy.deepcopy(before) + config(after, "ask").pop("approval") + assert forbidden_changes(before, after) == [] + + +def test_a_new_contract_cannot_open_cells_or_whitelist_numbers(): + """原来没有契约的出口(或者新加的出口)补上契约:骨架没问题,但 cells、allow_numbers 不许替人写。""" + before = weekly() + config(before, "done").pop("contract") + skeleton = {"report_from": "write", "metrics_from": ["card"], "required": ["gmv"], "strict": True} + after = copy.deepcopy(before) + config(after, "done")["contract"] = dict(skeleton) + assert forbidden_changes(before, after) == [] + config(after, "done")["contract"].update(cells=True, allow_numbers=["2026"]) + reasons = forbidden_changes(before, after) + assert any("cells" in r for r in reasons) and any("allow_numbers" in r for r in reasons), reasons + fresh = copy.deepcopy(before) + fresh["nodes"].append(node("extra", "output", "另一个出口", contract={**skeleton, "cells": True})) + assert any("cells" in r for r in forbidden_changes(before, fresh)) + + +def test_overwriting_an_existing_node_is_forbidden(): + """add_node 用了图里已有的 id:Copilot 的改图流程会拿新节点整个盖掉旧的。""" + before = weekly() + op = {"op": "add_node", "node": {"id": "write", "type": "report", "label": "报告撰写", "config": {}}} + assert any("覆盖" in r and "报告撰写" in r for r in forbidden_changes(before, before, [op])) + # 新 id 的 add_node 没问题 + assert forbidden_changes(before, before, [{**op, "node": {**op["node"], "id": "write2"}}]) == [] + after = copy.deepcopy(before) + find(after, "write")["type"] = "llm" + [reason] = forbidden_changes(before, after) + assert "报告撰写" in reason and "换成" in reason and "删了重建" in reason, reason + + +def test_a_type_change_shows_in_the_preview(): + before = weekly() + after = copy.deepcopy(before) + find(after, "write")["type"] = "llm" + changes = autofix.diff_changes(before, after, fix_id="assist", label="Copilot") + assert {"fix_id": "assist", "node_id": "write", "node_title": "报告撰写", "field": "type", "before": "report", + "after": "llm", "label": "Copilot"} in changes + + +def test_raising_the_bar_is_fine(): + before = weekly(strict=False) + after = copy.deepcopy(before) + config(after, "done")["contract"].update(strict=True, required=["gmv", "orders"]) + assert forbidden_changes(before, after, [{"op": "update_node", "id": "done", "config": {}}]) == [] + # 审批往严里改、去掉一条「全部自动放行」、白名单收窄、cells 关掉,都不算降低要求 + before = raised() + config(before, "write")["approval"] = "never" + config(before, "done")["contract"].update(cells=True, allow_numbers=["7", "37"]) + after = copy.deepcopy(before) + config(after, "ask")["approval"] = "always" + config(after, "write").pop("approval") + config(after, "done")["contract"].update(allow_numbers=["37"]) + config(after, "done")["contract"].pop("cells") + assert forbidden_changes(before, after) == [] + # 原来跟随全局默认(至多「仅危险工具需要审批」)的节点,写明「仅危险工具需要审批」不算放宽 + before, after = weekly(), weekly() + config(after, "fetch")["approval"] = "dangerous" + assert forbidden_changes(before, after) == [] + + +def test_published_level_only_fixes_errors(): + """已发布级别门禁只给警告:不借「一键修复」替人把审批、strict 这些往受管的要求上拧。""" + graph = broken() + fixes = plan(graph, level="published") + assert "governed.agent_approval_never:ask" not in fixes and "contract.strict_off:done" not in fixes + assert {"contract.metrics_from_missing:done", "contract.report_from_invalid:done"} <= set(fixes) diff --git a/backend/tests/test_caliber_from.py b/backend/tests/test_caliber_from.py new file mode 100644 index 00000000..7e4df79f --- /dev/null +++ b/backend/tests/test_caliber_from.py @@ -0,0 +1,260 @@ +"""口径卡钉住另一个已发布工作流某个版本里的口径卡(caliber_from),版本纳入升版处置。 + +用户拍板:口径卡的版本和子工作流一样纳入升版处置,不新造实体。 + +- caliber_from: {workflow_id, workflow_version, node_id}:运行时从那个 WorkflowVersion 的图里 + 取出口径卡的 caliber、caliber_version、metrics 定义,metric_set 里记下来源 +- 引用的工作流、版本或节点不存在:节点失败,报错写清楚 +- 上游有更新的版本时,正式运行前必须声明 upgrade_policy:没声明就挡住,声明了记 + caliber.upgrade 事件(node_id、pinned、latest、policy、policy_label) +- 证据面板的指标步骤带上来源工作流和版本,以及升版处置 +""" +from __future__ import annotations + +import asyncio +import json + +import pytest +from httpx import ASGITransport, AsyncClient +from langchain_core.messages import AIMessage +from sqlalchemy import select + +from app.core import artifact_store +from app.db.base import SessionLocal +from app.db.models import Run, RunEvent, Workflow, WorkflowVersion +from app.engine.evidence import iter_segments +from app.engine.runner import run_manager +from app.main import app + +KPI = "{'gmv': 45678.5, 'orders': 1234}" +STANDARD = [ + {"id": "gmv", "name": "销售额", "unit": "元", "decimals": 1, "format": "thousands", "expression": "vars.kpi.gmv"}, + {"id": "orders", "name": "订单数", "unit": "单", "format": "thousands", "expression": "vars.kpi.orders"}, +] + + +@pytest.fixture(autouse=True) +async def engine_up(): + await run_manager.setup() + yield + await run_manager.shutdown() + + +@pytest.fixture +async def client(): + async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as c: + yield c + + +def node(nid, ntype, label=None, **config): + return {"id": nid, "type": ntype, "position": {"x": 0, "y": 0}, "data": {"label": label or nid, "config": config}} + + +def chain(*nodes): + return {"nodes": list(nodes), "edges": [{"source": a["id"], "target": b["id"]} for a, b in zip(nodes, nodes[1:])]} + + +def library(metrics=STANDARD, version="v3"): + """上游:一张只放口径卡的工作流(口径库)。""" + return chain(node("start", "input"), + node("caliber", "metrics", "周报口径卡", caliber="周报口径", caliber_version=version, metrics=metrics), + node("out", "output", fields=[{"name": "r", "value": "{{ nodes.caliber.text }}"}])) + + +async def publish(graph, *, name="口径库", versions=1, status="published", later=None) -> str: + async with SessionLocal() as session: + wf = Workflow(name=name, graph=graph, status=status, published_version=versions, version=versions) + session.add(wf) + await session.flush() + for v in range(1, versions + 1): + session.add(WorkflowVersion(workflow_id=wf.id, version=v, graph=later if later and v > 1 else graph)) + await session.commit() + return wf.id + + +def weekly(pinned: dict, *, report=False, **card): + nodes = [ + node("start", "input"), + node("fetch", "transform", mode="expression", expression=KPI, assign_to="kpi"), + node("card", "metrics", "本地口径卡", caliber_from=pinned, **card), + ] + if report: + nodes.append(node("write", "report", instructions="写周报", on_violation="flag")) + nodes.append(node("out", "output", fields=[{"name": "周报", "value": "{{ nodes.write.text }}"}])) + else: + nodes.append(node("out", "output", fields=[{"name": "r", "value": "{{ nodes.card.text }}"}])) + return chain(*nodes) + + +async def wait(run_id: str) -> Run: + for _ in range(400): + await asyncio.sleep(0.05) + async with SessionLocal() as session: + row = await session.get(Run, run_id) + if row.status in ("succeeded", "failed") and row.manifest_seq is not None: + return row + raise AssertionError(f"run {run_id} 没跑完:{row.status}") + + +async def events(run_id: str, etype: str, node_id: str | None = None) -> list[RunEvent]: + async with SessionLocal() as session: + q = select(RunEvent).where(RunEvent.run_id == run_id, RunEvent.type == etype) + if node_id: + q = q.where(RunEvent.node_id == node_id) + return list((await session.execute(q.order_by(RunEvent.seq))).scalars()) + + +async def explore(graph) -> Run: + return await wait((await run_manager.start(graph=graph, input_payload={})).id) + + +# -------------------------------------------------------------------------- +# 取定义、记来源 +# -------------------------------------------------------------------------- + + +async def test_definitions_come_from_the_pinned_version(): + wf_id = await publish(library()) + pinned = {"workflow_id": wf_id, "workflow_version": 1, "node_id": "caliber"} + row = await explore(weekly(pinned)) + assert row.status == "succeeded", row.error + + [finished] = await events(row.id, "node.finished", "card") + card = artifact_store.load(finished.data["artifact"]) + assert (card["caliber"], card["caliber_version"]) == ("周报口径", "v3") + assert [(m["id"], m["value"], m["rendered"]) for m in card["metrics"]] == [ + ("gmv", 45678.5, "45,678.5元"), ("orders", 1234, "1,234单")] + assert card["source"] == pinned + # 落进工件库的指标集里也记着来源:证据接口从这里取 + [entry] = finished.data["evidence"] + stored = artifact_store.load(entry["artifact"]) + assert stored["source"] == pinned and entry["source"] == pinned + + +async def test_later_versions_do_not_leak_into_a_pinned_card(): + """钉住 v1 就用 v1 的定义:上游 v2 改了算法,这次运行照旧按 v1 算。""" + changed = [{**STANDARD[0], "expression": "vars.kpi.gmv * 2"}, STANDARD[1]] + wf_id = await publish(library(), versions=2, later=library(changed, version="v4")) + row = await explore(weekly({"workflow_id": wf_id, "workflow_version": 1, "node_id": "caliber"})) + [finished] = await events(row.id, "node.finished", "card") + card = artifact_store.load(finished.data["artifact"]) + assert card["caliber_version"] == "v3" and card["metrics"][0]["value"] == 45678.5 + + +async def test_graphs_without_caliber_from_are_unchanged(): + row = await explore(chain(node("start", "input"), + node("fetch", "transform", mode="expression", expression=KPI, assign_to="kpi"), + node("card", "metrics", caliber="周报口径", metrics=STANDARD), + node("out", "output", fields=[{"name": "r", "value": "{{ nodes.card.text }}"}]))) + [finished] = await events(row.id, "node.finished", "card") + card = artifact_store.load(finished.data["artifact"]) + assert "source" not in card and "source" not in finished.data["evidence"][0] + + +# -------------------------------------------------------------------------- +# 引用不存在 +# -------------------------------------------------------------------------- + + +@pytest.mark.parametrize("pin, words", [ + (lambda wf: {"workflow_id": "no-such-workflow", "workflow_version": 1, "node_id": "caliber"}, + ["no-such-workflow", "不存在"]), + (lambda wf: {"workflow_id": wf, "workflow_version": 7, "node_id": "caliber"}, ["口径库", "v7"]), + (lambda wf: {"workflow_id": wf, "workflow_version": 1, "node_id": "nope"}, ["口径库", "v1", "nope"]), + (lambda wf: {"workflow_id": wf, "workflow_version": 1, "node_id": "out"}, ["不是口径卡"]), +]) +async def test_missing_references_fail_the_node_clearly(pin, words): + wf_id = await publish(library()) + row = await explore(weekly(pin(wf_id))) + assert row.status == "failed" + [failed] = await events(row.id, "node.failed", "card") + assert all(w in failed.data["error"] for w in words), failed.data["error"] + + +async def test_pinning_a_card_that_is_itself_pinned_is_refused(): + base = await publish(library(), name="源头口径") + middle = await publish(chain(node("start", "input"), + node("caliber", "metrics", caliber_from={"workflow_id": base, "workflow_version": 1, + "node_id": "caliber"}), + node("out", "output", fields=[{"name": "r", "value": "x"}])), name="转手口径") + row = await explore(weekly({"workflow_id": middle, "workflow_version": 1, "node_id": "caliber"})) + [failed] = await events(row.id, "node.failed", "card") + assert "源头" in failed.data["error"] and "源头口径" in failed.data["error"], failed.data["error"] + + +# -------------------------------------------------------------------------- +# 升版处置:正式运行 +# -------------------------------------------------------------------------- + + +async def formal(client, graph): + wf_id = await publish(graph, name="周报") + return await client.post("/api/runs", json={"workflow_id": wf_id, "run_class": "formal"}) + + +async def test_a_newer_upstream_blocks_formal_runs_without_a_policy(client): + lib = await publish(library(), versions=2, later=library(version="v4")) + res = await formal(client, weekly({"workflow_id": lib, "workflow_version": 1, "node_id": "caliber"})) + assert res.status_code == 409, res.text + detail = res.json()["detail"] + assert "「本地口径卡」" in detail and "v1" in detail and "v2" in detail and "upgrade_policy" in detail + + +async def test_a_declared_policy_is_recorded(client): + lib = await publish(library(), versions=2, later=library(version="v4")) + res = await formal(client, weekly({"workflow_id": lib, "workflow_version": 1, "node_id": "caliber"}, + upgrade_policy="dual")) + assert res.status_code == 201, res.text + row = await wait(res.json()["id"]) + assert row.status == "succeeded", row.error + [event] = await events(row.id, "caliber.upgrade") + assert event.node_id == "card" + assert {k: event.data.get(k) for k in ("workflow_id", "pinned", "latest", "policy", "policy_label", + "caliber_node")} == { + "workflow_id": lib, "pinned": 1, "latest": 2, "policy": "dual", "policy_label": "并排双印新旧口径", + "caliber_node": "caliber"} + + +async def test_pinned_to_the_latest_needs_no_policy(client): + lib = await publish(library()) + res = await formal(client, weekly({"workflow_id": lib, "workflow_version": 1, "node_id": "caliber"})) + assert res.status_code == 201, res.text + row = await wait(res.json()["id"]) + assert await events(row.id, "caliber.upgrade") == [] + + +# -------------------------------------------------------------------------- +# 证据面板的指标步骤 +# -------------------------------------------------------------------------- + + +async def test_metric_step_shows_the_source_and_the_upgrade(client, monkeypatch): + from app.providers import mock_model + + monkeypatch.setattr(mock_model.MockChatModel, "_decide", + lambda self, messages: AIMessage(content="销售额 [[m:gmv]]。")) + lib = await publish(library(), versions=2, later=library(version="v4")) + pinned = {"workflow_id": lib, "workflow_version": 1, "node_id": "caliber"} + res = await formal(client, weekly(pinned, report=True, upgrade_policy="incomparable")) + row = await wait(res.json()["id"]) + assert row.status == "succeeded", row.error + doc_id = row.output["_evidence"]["doc_artifact"] + doc = artifact_store.load(doc_id) + seg = next(s for s in iter_segments(doc) if s["text"] == "45,678.5元") + body = (await client.get(f"/api/runs/{row.id}/evidence/segments/{seg['id']}")).json() + metric = body["chain"][0] + assert metric["source"] == {**pinned, "workflow_name": "口径库"} + assert (metric["caliber"], metric["version"]) == ("周报口径", "v3") + assert metric["caliber_upgrade"] == {"workflow_id": lib, "caliber_node": "caliber", "pinned": 1, "latest": 2, + "policy": "incomparable", "policy_label": "标注与历史不可比"} + assert json.dumps(metric, ensure_ascii=False) + + +async def test_subgraphs_keep_the_same_upgrade_rule(client): + """子工作流的升版处置原样不变:口径卡是并进同一套规则,不是另起一套。""" + lib = await publish(library(), versions=2, later=library(version="v4")) + graph = chain(node("start", "input"), + node("sub", "subgraph", "方法卡", workflow_id=lib, workflow_version=1), + node("out", "output", fields=[{"name": "r", "value": "{{ nodes.sub.text }}"}])) + res = await formal(client, graph) + assert res.status_code == 409 and "「方法卡」(子工作流)钉在 v1,但上游已有 v2" in res.json()["detail"] diff --git a/backend/tests/test_code_evidence_role.py b/backend/tests/test_code_evidence_role.py new file mode 100644 index 00000000..859d8f2d --- /dev/null +++ b/backend/tests/test_code_evidence_role.py @@ -0,0 +1,159 @@ +"""代码节点在证据链里的位置:evidence_role 和 code_sha。 + +沙箱里跑的代码可以是「取数」(读文件、调接口,产出本身就是源数据),也可以是「计算」 +(拿上游的数加减乘除)。后者正是叙述层不该有的算术权限,只是换了个地方——所以: + +- evidence_role 缺省为 compute,只能写 source / compute +- 产出带 code_sha:实际送进沙箱的那份代码(渲染之后、审批改过之后)的 sha256 +- 台账记一条 node_output,工件就是节点产出工件本身 +- 报告不能直接引用代码节点的产出:那种数要进口径卡 +- 升级前发起的运行一字不改 +""" +from __future__ import annotations + +import asyncio + +import pytest +from sqlalchemy import select + +from app.core.artifact_store import canonical_json, content_hash +from app.db.base import SessionLocal +from app.db.models import Run, RunEvent +from app.engine import runner as runner_mod +from app.engine.context import NodeContext, NodeError, RunContext +from app.engine.evidence import CODE_REASON, build_catalog, compose_doc +from app.engine.nodes.tools import run_code +from app.engine.runner import run_manager +from app.engine.schema import GraphNode, GraphSpec +from app.sandbox.base import ExecResult + +CODE = "print({{ vars.n }} * 2)" + + +class _FakeSandbox: + def __init__(self, result: ExecResult) -> None: + self.result = result + self.calls: list[str] = [] + + async def run(self, code, **kwargs): # noqa: ANN001, ANN003, ANN201 + self.calls.append(code) + return self.result + + +def _ctx(monkeypatch, *, limits=None, stdout="42\n", **config) -> tuple[NodeContext, _FakeSandbox]: + import app.engine.nodes.tools as tools + + sandbox = _FakeSandbox(ExecResult(stdout=stdout)) + monkeypatch.setattr(tools, "sandbox_manager", sandbox) + node = GraphNode(id="calc", type="code", data={"label": "计算", "config": { + "language": "python", "code": CODE, "isolation": "fast", **config}}) + spec = GraphSpec(nodes=[node], edges=[]) + run = RunContext(run_id="r1", thread_id="t1", spec=spec, agent_limits=limits) + return NodeContext(node=node, run=run), sandbox + + +def _run(ctx, state=None) -> dict: + return asyncio.run(run_code(state or {"vars": {"n": 21}}, ctx)) + + +def test_role_defaults_to_compute_and_code_sha_is_of_the_rendered_code(monkeypatch): + ctx, sandbox = _ctx(monkeypatch, limits={}) + updates = _run(ctx) + payload = updates["nodes"]["calc"] + assert sandbox.calls == ["print(21 * 2)"] + assert payload["code_sha"] == content_hash("print(21 * 2)") + [entry] = updates["evidence"] + assert entry == {"kind": "node_output", "node_id": "calc", "exec": 1, + "artifact": content_hash(canonical_json(payload)), "code_sha": payload["code_sha"], + "role": "compute", "language": "python"} + + +def test_source_role_is_recorded(monkeypatch): + ctx, _ = _ctx(monkeypatch, limits={}, evidence_role="source") + [entry] = _run(ctx)["evidence"] + assert entry["role"] == "source" + + +def test_unknown_role_is_refused(monkeypatch): + ctx, sandbox = _ctx(monkeypatch, limits={}, evidence_role="fetch") + with pytest.raises(NodeError) as info: + _run(ctx) + assert "evidence_role" in str(info.value) and "source" in str(info.value) + assert sandbox.calls == [], "角色写错了还去跑沙箱" + + +def test_runs_from_before_the_upgrade_are_untouched(monkeypatch): + ctx, _ = _ctx(monkeypatch, limits=None, evidence_role="source") + updates = _run(ctx) + assert "evidence" not in updates + assert "code_sha" not in updates["nodes"]["calc"] + + +def test_code_entries_cannot_be_cited_by_a_report(monkeypatch): + ctx, _ = _ctx(monkeypatch, limits={}, evidence_role="source") + updates = _run(ctx) + catalog = build_catalog(nodes=updates["nodes"], ledger=updates["evidence"]) + assert not [a for a in catalog if a.startswith("Q")] + doc = compose_doc("总数 [[v:N:calc.text]]。", catalog) + [violation] = doc["violations"] + assert violation["code"] == "unresolved_ref" and CODE_REASON in violation["message"] + + +# -------------------------------------------------------------------------- +# 跑一次真的运行:台账里的工件就是 node.finished 里那件节点产出 +# -------------------------------------------------------------------------- + + +@pytest.fixture +async def engine_up(): + await run_manager.setup() + yield + await run_manager.shutdown() + + +async def _run_graph(monkeypatch) -> Run: + import app.engine.nodes.tools as tools + + monkeypatch.setattr(tools, "sandbox_manager", _FakeSandbox(ExecResult(stdout='{"total": 42}\n'))) + graph = { + "nodes": [ + {"id": "start", "type": "input", "data": {"config": {}}}, + {"id": "calc", "type": "code", "data": {"label": "计算", "config": { + "language": "python", "code": "print('{\"total\": 42}')", "isolation": "fast"}}}, + {"id": "out", "type": "output", "data": {"config": {"fields": [{"name": "r", "value": "{{ nodes.calc.text }}"}]}}}, + ], + "edges": [{"source": "start", "target": "calc"}, {"source": "calc", "target": "out"}], + } + run = await run_manager.start(graph=graph, input_payload={}) + for _ in range(300): + await asyncio.sleep(0.05) + async with SessionLocal() as session: + row = await session.get(Run, run.id) + if row.status in ("succeeded", "failed") and row.manifest_seq is not None: + return row + raise AssertionError("没跑完") + + +async def _finished(run_id: str) -> dict: + async with SessionLocal() as session: + rows = list((await session.execute(select(RunEvent).where( + RunEvent.run_id == run_id, RunEvent.type == "node.finished", RunEvent.node_id == "calc"))).scalars()) + return rows[0].data + + +async def test_ledger_artifact_is_the_node_output_artifact(engine_up, monkeypatch): + row = await _run_graph(monkeypatch) + assert row.status == "succeeded", row.error + finished = await _finished(row.id) + [entry] = finished["evidence"] + assert entry["artifact"] == finished["artifact"] and entry["role"] == "compute" + + +async def test_old_runs_write_no_code_entry(engine_up, monkeypatch): + async def no_snapshot(session): + return None + + monkeypatch.setattr(runner_mod, "_agent_limits", no_snapshot) + row = await _run_graph(monkeypatch) + assert row.status == "succeeded", row.error + assert "evidence" not in await _finished(row.id) diff --git a/backend/tests/test_contract_cells.py b/backend/tests/test_contract_cells.py new file mode 100644 index 00000000..132af5b0 --- /dev/null +++ b/backend/tests/test_contract_cells.py @@ -0,0 +1,328 @@ +"""报告直接引用查询单元格 [[v:Qn.r<行>.<列>]]:受管级别的正式运行要在契约里显式声明 cells。 + +用户拍板:探索运行和已发布级别允许直接引用单元格;受管级别的正式运行,契约里没写 +"cells": true 的,单元格引用按 unresolved 计,原因写「受管出具要在契约里声明 cells」。 + +- 出口复核(io.py)按这条规则独立判,不信报告节点的自查 +- 报告节点写作时也照这条规则建目录:不允许时提示里只给 [[see:Qn]],写了单元格当场就被打回, + 不必等到出口才发现 +- 旧 narrative 契约的 matched 里,单元格引用的数字记成 cell: "Q1.r0.gmv",不是 input: None +""" +from __future__ import annotations + +import asyncio +import sqlite3 + +import pytest +from httpx import ASGITransport, AsyncClient +from langchain_core.messages import AIMessage +from sqlalchemy import select + +from app.data.engine import engines +from app.db.base import SessionLocal +from app.db.models import DataSource, Run, RunEvent, Workflow, WorkflowVersion +from app.engine.evidence import CELLS_REASON +from app.engine.runner import run_manager +from app.main import app + +CELL_TEXT = "本周销售额 [[v:Q1.r0.gmv]],按口径卡是 [[m:gmv]]。" + + +@pytest.fixture(autouse=True) +async def engine_up(): + await run_manager.setup() + yield + await run_manager.shutdown() + + +@pytest.fixture +async def client(): + async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as c: + yield c + + +@pytest.fixture(autouse=True) +async def shop(tmp_path): + path = tmp_path / "shop.db" + db = sqlite3.connect(path) + db.executescript("CREATE TABLE orders (id INTEGER PRIMARY KEY, week TEXT, amount REAL);") + db.executemany("INSERT INTO orders VALUES (?, ?, ?)", [(1, "2026-W37", 100.5), (2, "2026-W37", 200.0)]) + db.commit() + db.close() + async with SessionLocal() as session: + row = (await session.execute(select(DataSource).where(DataSource.name == "shop"))).scalar_one_or_none() + if row is None: + row = DataSource(name="shop", kind="sqlite", database=str(path), readonly=True, enabled=True) + session.add(row) + else: + row.database, row.kind, row.enabled, row.readonly, row.options = str(path), "sqlite", True, True, {} + await session.commit() + source_id = row.id + await engines.invalidate(source_id) + yield + await engines.invalidate(source_id) + + +def node(nid, ntype, **config): + return {"id": nid, "type": ntype, "position": {"x": 0, "y": 0}, "data": {"label": nid, "config": config}} + + +def weekly(*, cells=None, on_violation="flag"): + contract = {"report_from": "write", "metrics_from": ["card"], "required": ["gmv"], "strict": True} + if cells is not None: + contract["cells"] = cells + nodes = [ + node("start", "input"), + node("fetch", "tool", tool="db_query__shop", + args={"sql": "SELECT SUM(amount) AS gmv, COUNT(*) AS orders FROM orders"}), + node("card", "metrics", caliber="周报口径", caliber_version="v2", metrics=[ + {"id": "gmv", "name": "销售额", "unit": "元", "decimals": 1, "format": "thousands", + "expression": "cell(nodes.fetch, 0, 'gmv')"}]), + node("write", "report", instructions="写周报", on_violation=on_violation), + node("out", "output", fields=[{"name": "周报", "value": "{{ nodes.write.text }}"}], contract=contract), + ] + return {"nodes": nodes, "edges": [{"source": a["id"], "target": b["id"]} for a, b in zip(nodes, nodes[1:])]} + + +class Writer: + """报告写作者:每次都照 text 写;记下每次看到的提示。""" + + def __init__(self, monkeypatch, text=CELL_TEXT): + from app.providers import mock_model + + self.prompts: list[str] = [] + + def decide(model, messages): + self.prompts.append("\n".join(str(m.content) for m in messages)) + return AIMessage(content=text) + + monkeypatch.setattr(mock_model.MockChatModel, "_decide", decide) + + +async def wait(run_id: str) -> Run: + for _ in range(400): + await asyncio.sleep(0.05) + async with SessionLocal() as session: + row = await session.get(Run, run_id) + if row.status in ("succeeded", "failed") and row.manifest_seq is not None: + return row + raise AssertionError(f"run {run_id} 没跑完:{row.status}") + + +async def formal(client, graph, *, status: str) -> Run: + """把这张图发布成 status 级别,再从发布版本发起正式运行。""" + async with SessionLocal() as session: + wf = Workflow(name=f"周报-{status}", graph=graph, status=status, published_version=1) + session.add(wf) + await session.flush() + session.add(WorkflowVersion(workflow_id=wf.id, version=1, graph=graph)) + await session.commit() + wf_id = wf.id + res = await client.post("/api/runs", json={"workflow_id": wf_id, "run_class": "formal"}) + assert res.status_code == 201, res.text + return await wait(res.json()["id"]) + + +async def checked(run_id: str) -> dict: + async with SessionLocal() as session: + [event] = (await session.execute(select(RunEvent).where( + RunEvent.run_id == run_id, RunEvent.type == "report.checked"))).scalars() + return event.data + + +# -------------------------------------------------------------------------- +# 出口复核 +# -------------------------------------------------------------------------- + + +async def test_governed_formal_run_without_cells_leaves_cell_refs_unresolved(client, monkeypatch): + Writer(monkeypatch) + row = await formal(client, weekly(), status="governed") + assert row.status == "succeeded", row.error + issuance = row.output["_issuance"] + assert issuance["mode"] == "citations" and issuance["tier"] == "withheld", issuance + [unresolved] = issuance["unresolved"] + assert unresolved["ref"] == "v:Q1.r0.gmv" and CELLS_REASON in unresolved["message"], unresolved + # 指标照常核对过;单元格那个数不算「有出处」 + assert [m.get("metric") for m in issuance["matched"]] == ["gmv"] + + +async def test_governed_formal_run_with_cells_declared_passes(client, monkeypatch): + Writer(monkeypatch) + row = await formal(client, weekly(cells=True), status="governed") + assert row.status == "succeeded", row.error + issuance = row.output["_issuance"] + assert issuance["tier"] == "formal" and issuance["unresolved"] == [], issuance + cell = next(m for m in issuance["matched"] if m.get("cell")) + assert cell["cell"] == "Q1.r0.gmv" and cell["token"] == "300.5" and cell["metric"] is None + assert "input" not in cell and cell["eid"].startswith("ev:cell:") + + +@pytest.mark.parametrize("status", ["published"]) +async def test_published_formal_runs_allow_cells(client, monkeypatch, status): + Writer(monkeypatch) + row = await formal(client, weekly(), status=status) + assert row.output["_issuance"]["tier"] == "formal", row.output["_issuance"] + + +async def test_exploratory_runs_are_not_affected(monkeypatch): + Writer(monkeypatch) + run = await run_manager.start(graph=weekly(), input_payload={}) + row = await wait(run.id) + issuance = row.output["_issuance"] + assert issuance["tier"] == "formal" and issuance["unresolved"] == [], issuance + assert any(m.get("cell") == "Q1.r0.gmv" for m in issuance["matched"]) + + +# -------------------------------------------------------------------------- +# 报告节点写作时就照这条规则 +# -------------------------------------------------------------------------- + + +async def test_report_node_refuses_cells_while_writing_for_governed_formal_runs(client, monkeypatch): + writer = Writer(monkeypatch) + row = await formal(client, weekly(), status="governed") + data = await checked(row.id) + assert data["ok"] is False and data["repairs"] == 1 + assert any(CELLS_REASON in v["message"] for v in data["violations"]), data["violations"] + # 提示里不再教它写单元格,只给依据写法 + assert "[[v:Q1.r0." not in writer.prompts[0] and "[[see:Q1]]" in writer.prompts[0] + + +async def test_report_node_with_on_violation_fail_stops_before_the_exit(client, monkeypatch): + Writer(monkeypatch) + row = await formal(client, weekly(on_violation="fail"), status="governed") + assert row.status == "failed" and CELLS_REASON in (row.error or ""), row.error + + +async def test_report_node_teaches_cells_when_they_are_allowed(client, monkeypatch): + writer = Writer(monkeypatch) + row = await formal(client, weekly(cells=True), status="governed") + data = await checked(row.id) + assert data["ok"] is True and data["repairs"] == 0, data + assert "[[v:Q1.r0." in writer.prompts[0] + + +# -------------------------------------------------------------------------- +# report_no_evidence:有任何一种可引用的来源就不报,报的时候说清缺的是哪种 +# -------------------------------------------------------------------------- + + +async def no_evidence_logs(run_id: str) -> list[dict]: + async with SessionLocal() as session: + rows = (await session.execute(select(RunEvent).where( + RunEvent.run_id == run_id, RunEvent.type == "log"))).scalars() + return [e.data for e in rows if e.data.get("code") == "report_no_evidence"] + + +def chain(*nodes): + return {"nodes": list(nodes), "edges": [{"source": a["id"], "target": b["id"]} for a, b in zip(nodes, nodes[1:])]} + + +FETCH = node("fetch", "tool", tool="db_query__shop", args={"sql": "SELECT SUM(amount) AS gmv FROM orders"}) +WRITE = node("write", "report", instructions="回答问题", on_violation="flag") +OUT = node("out", "output", fields=[{"name": "答案", "value": "{{ nodes.write.text }}"}]) + + +async def test_a_report_over_queries_only_is_not_warned(monkeypatch): + """问数据的图(input → 取数 → report)只有查询条目、没有口径卡:单元格就是它的出处,不该报没有证据。""" + Writer(monkeypatch, "销售额 [[v:Q1.r0.gmv]]。") + row = await wait((await run_manager.start(graph=chain(node("start", "input"), FETCH, WRITE, OUT), + input_payload={})).id) + assert row.status == "succeeded", row.error + assert await no_evidence_logs(row.id) == [] + + +async def test_a_report_with_nothing_upstream_names_every_missing_kind(monkeypatch): + Writer(monkeypatch, "什么都没有。") + row = await wait((await run_manager.start(graph=chain(node("start", "input"), WRITE, OUT), + input_payload={})).id) + [log] = await no_evidence_logs(row.id) + assert all(kind in log["message"] for kind in ("口径卡指标", "查询结果", "知识库检索", "运行输入")), log + + +async def test_queries_without_cells_in_a_governed_formal_run_are_named(client, monkeypatch): + Writer(monkeypatch, "销售额 [[v:Q1.r0.gmv]]。") + card = node("card", "metrics", caliber="周报口径", metrics=[ + {"id": "gmv", "name": "销售额", "expression": "cell(nodes.fetch, 0, 'gmv')"}]) + out = node("out", "output", fields=[{"name": "答案", "value": "{{ nodes.write.text }}"}], + contract={"report_from": "write", "metrics_from": ["card"], "required": ["gmv"]}) + row = await formal(client, chain(node("start", "input"), FETCH, WRITE, card, out), status="governed") + [log] = await no_evidence_logs(row.id) + assert "只有查询结果" in log["message"] and CELLS_REASON in log["message"], log + + +# -------------------------------------------------------------------------- +# 受管与否在发起时定下来:之后改图、改级别,这次运行都按发起时的算 +# -------------------------------------------------------------------------- + + +async def governance_notes(run_id: str) -> list[RunEvent]: + async with SessionLocal() as session: + rows = (await session.execute(select(RunEvent).where( + RunEvent.run_id == run_id, RunEvent.type == "log").order_by(RunEvent.seq))).scalars() + return [e for e in rows if e.data.get("code") == "run_governance"] + + +async def set_status(run_id: str, status: str) -> None: + async with SessionLocal() as session: + run = await session.get(Run, run_id) + wf = await session.get(Workflow, run.workflow_id) + wf.status = status + await session.commit() + + +@pytest.mark.parametrize("status, governed, later", [("governed", True, "draft"), ("published", False, "governed")]) +async def test_the_governed_decision_is_recorded_at_start_and_does_not_follow_later_edits( + client, monkeypatch, status, governed, later): + from app.engine.governance import governed_formal + + Writer(monkeypatch) + row = await formal(client, weekly(cells=True), status=status) + [note] = await governance_notes(row.id) + assert note.data["governed"] is governed and note.data["level"] == "info" and note.node_id is None + assert note.seq <= row.manifest_seq # 在封存范围里 + assert await governed_formal(row.id) is governed + await set_status(row.id, later) # 改图退回草稿 / 事后升成受管 + assert await governed_formal(row.id) is governed + + +async def test_exploratory_runs_record_nothing(monkeypatch): + Writer(monkeypatch) + row = await wait((await run_manager.start(graph=weekly(), input_payload={})).id) + assert await governance_notes(row.id) == [] + + +async def test_runs_started_before_the_record_existed_follow_the_current_status(client, monkeypatch): + from app.engine.governance import governed_formal + + Writer(monkeypatch) + row = await formal(client, weekly(cells=True), status="governed") + async with SessionLocal() as session: + for note in await governance_notes(row.id): + await session.delete(await session.get(RunEvent, note.id)) + await session.commit() + assert await governed_formal(row.id) is True + await set_status(row.id, "draft") + assert await governed_formal(row.id) is False + + +async def test_the_report_node_and_the_exit_check_agree_when_the_status_changes_mid_run(client, monkeypatch): + """报告节点写作时按受管拒了单元格,跑到出口前工作流被改回草稿:出口仍按受管判,两边说法一致。""" + from app.engine import governance + + Writer(monkeypatch) + real, seen = governance.governed_formal, [] + + async def edit_after_first_look(run_id): + result = await real(run_id) + seen.append(result) + if len(seen) == 1: + await set_status(run_id, "draft") + return result + + monkeypatch.setattr(governance, "governed_formal", edit_after_first_look) + row = await formal(client, weekly(), status="governed") + assert seen == [True, True], seen + issuance = row.output["_issuance"] + assert issuance["tier"] == "withheld" and CELLS_REASON in issuance["unresolved"][0]["message"], issuance diff --git a/backend/tests/test_copilot_evidence_guide.py b/backend/tests/test_copilot_evidence_guide.py index a015bbe9..7b3379bf 100644 --- a/backend/tests/test_copilot_evidence_guide.py +++ b/backend/tests/test_copilot_evidence_guide.py @@ -6,6 +6,10 @@ """ from __future__ import annotations +import json + +import pytest + from app.api.copilot import NODE_REFERENCE, GenerateIn, _user_message from app.engine.evidence import render_number @@ -36,5 +40,277 @@ def test_report_model_follows_the_same_model_rule(): def test_answer_branch_mentions_traceable_reports(): text = _user_message(GenerateIn(instruction="上周销售额多少", intent="answer"), patch=False) assert "report" in text and "口径卡" in text and "report_from" in text - # 普通问数不强加报告:本期 agent 的结论还进不了口径卡,强加只会让答案里的数全判成无出处 - assert "普通的问数照旧" in text + # 二期起问数也带报告:agent 查过的单元格能被 report 直接 [[v:]] 引用,答案里的数逐个点得开 + assert "input → agent → report → output" in text + + +# -------------------------------------------------------------------------- +# 二期:agent 的结构化字段、单元格引用、cell()、沙箱代码的角色 +# -------------------------------------------------------------------------- + + +def test_node_reference_covers_phase_two_evidence(): + ref = NODE_REFERENCE + for words in ( + ["output_schema", "cite_fields: true", "vars."], # agent 交结构化字段 + ["[[v:Q1.r0.", "[[table:Q1 cols=", "rows=0-4"], # 报告引用单元格、整表 + ["cell(nodes.", "cell() "], # 口径卡直接取查询结果的一格 + ["evidence_role", "source", "compute"], # 沙箱代码的角色 + ["cells: true"], # 受管出具要声明 cells + ["不要用 transform 解析 agent / llm 的文字"], + ): + assert all(w in ref for w in words), [w for w in words if w not in ref] + # 旧的「transform 解析查询结果」「agent 的回答进不了口径卡」不再是推荐写法 + assert "agent 的回答是自由文本,进不了口径卡" not in ref + assert 'template="{{ nodes.取数节点id }}"' not in ref + + +def test_answer_branch_builds_agent_report_for_numbers_only(): + text = _user_message(GenerateIn(instruction="上周销售额多少", intent="answer"), patch=False) + assert "input → agent → report → output" in text and "[[v:Q1.r0." in text + assert "只问表结构、清单" in text and "不加 report" in text + assert "不要用 transform 解析 agent / llm 的文字" in text + assert "output_schema" in text and "cite_fields" in text + + +def _source(**tables): + from types import SimpleNamespace + + return SimpleNamespace(name="shop", schema_cache={"tables": { + name: {"qualified": name, "columns": [{"name": c} for c in cols]} for name, cols in tables.items()}}) + + +def _spec(*nodes): + from app.engine.schema import GraphSpec + + return GraphSpec.model_validate({"nodes": list(nodes), "edges": [ + {"source": a["id"], "target": b["id"]} for a, b in zip(nodes, nodes[1:])]}) + + +def _node(nid, ntype, **config): + return {"id": nid, "type": ntype, "data": {"label": nid, "config": config}} + + +def test_sql_columns_missing_from_the_schema_are_sent_back(): + from app.api.copilot import authored_issues + + shop = _source(orders=["id", "week", "amount", "refunded"]) + fetch = _node("fetch", "tool", tool="db_query__shop", + args={"sql": "SELECT SUM(amt) AS gmv, COUNT(*) AS n FROM orders WHERE wk = '{{ input.week }}'"}) + spec = _spec(_node("start", "input"), fetch, _node("out", "output", fields=[])) + [issue] = authored_issues(spec, [shop]) + assert issue["level"] == "error" and issue["code"] == "sql_unknown_column" + assert issue["node_id"] == "fetch" and issue["field"] == "args.sql" + assert "amt、wk" in issue["message"] and "amount" in issue["message"] and "db_schema__shop" in issue["message"] + + good = _node("fetch", "tool", tool="db_query__shop", + args={"sql": "SELECT SUM(amount) AS gmv, COUNT(*) AS n FROM orders WHERE week = '{{ input.week }}' " + "GROUP BY week ORDER BY gmv DESC"}) + assert authored_issues(_spec(_node("start", "input"), good, _node("out", "output", fields=[])), [shop]) == [] + # 结构没探查过、或者表不在结构里:判断不了,不打回 + assert authored_issues(spec, [_source()]) == [] + other = _node("fetch", "tool", tool="db_query__shop", args={"sql": "SELECT amt FROM refunds"}) + assert authored_issues(_spec(_node("start", "input"), other, _node("out", "output", fields=[])), [shop]) == [] + + +def test_sql_the_user_wrote_and_this_round_did_not_touch_is_left_alone(): + from app.api.copilot import authored_issues + + shop = _source(orders=["id", "amount"]) + fetch = _node("fetch", "tool", tool="db_query__shop", args={"sql": "SELECT amt FROM orders"}) + spec = _spec(_node("start", "input"), fetch, _node("out", "output", fields=[])) + assert authored_issues(spec, [shop], baseline=[fetch]) == [] + assert authored_issues(spec, [shop], baseline=[{**fetch, "data": {"config": {"tool": "db_query__shop"}}}]) + + +def test_parsing_agent_text_is_sent_back(): + from app.api.copilot import authored_issues + + spec = _spec( + _node("start", "input"), + _node("query_logs", "agent", prompt="查", tools=["db_query__shop"], assign_to="answer"), + _node("parse", "transform", mode="json", template="{{ nodes.query_logs.text }}", assign_to="q"), + _node("card", "metrics", metrics=[{"id": "n", "expression": "vars.q.n"}]), + _node("out", "output", fields=[]), + ) + [issue] = authored_issues(spec, []) + assert issue["code"] == "parse_model_text" and issue["level"] == "error" and issue["node_id"] == "parse" + assert "cite_fields" in issue["message"] and "不要用整形节点解析 agent / llm 的文字" in issue["message"] + + +# -------------------------------------------------------------------------- +# 端到端:搭完自查,SQL 里猜的列名被打回、改好才自动运行 +# -------------------------------------------------------------------------- + + +class _Scripted: + """第一轮吐 first;收到自查请求(human 里有「上线前自查」)时吐 fix。""" + + def __init__(self, first, fix): + self.first, self.fix = first, fix + self.calls: list[str] = [] + + async def astream(self, messages): + from langchain_core.messages import AIMessageChunk + + human = next(text for role, text in messages if role == "human") + self.calls.append(human) + for op in (self.fix if "上线前自查" in human else self.first): + yield AIMessageChunk(content=json.dumps(op, ensure_ascii=False) + "\n") + + +def _ops(sql): + return [ + {"op": "plan", "summary": "查销售额"}, + {"op": "add_node", "node": {"id": "start", "type": "input", "label": "输入", "config": {}}}, + {"op": "add_node", "node": {"id": "fetch", "type": "tool", "label": "取数", + "config": {"tool": "db_query__sales", "args": {"sql": sql}}}}, + {"op": "add_edge", "edge": {"source": "start", "target": "fetch"}}, + {"op": "add_node", "node": {"id": "out", "type": "output", "label": "成果", + "config": {"fields": [{"name": "r", "value": "{{ nodes.fetch }}"}]}}}, + {"op": "add_edge", "edge": {"source": "fetch", "target": "out"}}, + {"op": "done", "explanation": "查一下", "run": True}, + ] + + +@pytest.fixture +async def sales(tmp_path): + from sqlalchemy import select + + from app.db.base import SessionLocal + from app.db.models import DataSource + + cache = {"tables": {"orders": {"qualified": "orders", "columns": [ + {"name": "id", "type": "INTEGER"}, {"name": "week", "type": "TEXT"}, {"name": "amount", "type": "REAL"}]}}} + async with SessionLocal() as session: + row = (await session.execute(select(DataSource).where(DataSource.name == "sales"))).scalar_one_or_none() + if row is None: + row = DataSource(name="sales", kind="sqlite", database=str(tmp_path / "sales.db"), readonly=True) + session.add(row) + row.enabled, row.schema_cache = True, cache + await session.commit() + yield + + +async def test_guessed_columns_are_sent_back_and_fixed_before_running(sales, monkeypatch): + import app.api.copilot as copilot + from httpx import ASGITransport, AsyncClient + + from app.main import app + + model = _Scripted(_ops("SELECT SUM(sales_amount) AS gmv FROM orders"), + [{"op": "update_node", "id": "fetch", + "config": {"args": {"sql": "SELECT SUM(amount) AS gmv FROM orders"}}}, + {"op": "done", "explanation": "改好了"}]) + + async def _model(*_a, **_k): + return model, "mock-fast" + + monkeypatch.setattr(copilot, "get_chat_model", _model) + events: list[dict] = [] + async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client: + async with client.stream("POST", "/api/copilot/generate-stream", + json={"instruction": "上周销售额多少", "intent": "answer"}) as r: + async for line in r.aiter_lines(): + if line.startswith("data: "): + events.append(json.loads(line[6:])) + checks = [e for e in events if e["op"] == "check"] + assert [c["status"] for c in checks] == ["repairing", "passed"], checks + [issue] = checks[0]["issues"] + assert issue["code"] == "sql_unknown_column" and "sales_amount" in issue["message"] + assert "sales_amount" in model.calls[1] and "amount" in model.calls[1] + final = events[-1] + sql = next(n for n in final["graph"]["nodes"] if n["id"] == "fetch")["data"]["config"]["args"]["sql"] + assert sql == "SELECT SUM(amount) AS gmv FROM orders" and final["autorun"] is True + + +def test_model_text_inside_template_quotes_is_sent_back_with_the_json_filter_fix(): + """模板把 agent 的文字放在引号里拼 JSON:打回时给的改法是 | json,不是去换结构化输出。""" + from app.api.copilot import authored_issues + + spec = _spec( + _node("start", "input"), + _node("query_logs", "agent", prompt="查", tools=["db_query__shop"], assign_to="answer"), + _node("parse", "transform", mode="json", template='{"summary": "{{ nodes.query_logs.text }}"}', + assign_to="q"), + _node("out", "output", fields=[]), + ) + [issue] = authored_issues(spec, []) + assert issue["code"] == "parse_model_text" and issue["level"] == "error" + assert "{{ nodes.query_logs.text | json }}" in issue["message"] + assert "cite_fields" not in issue["message"] and "不要用整形节点解析" not in issue["message"] + + +#: 都是能跑的 SQL:关键字、运算符、排序规则名、窗口名、表达式后面不写 AS 的别名,都不是列 +VALID_SQL = [ + "SELECT id FROM orders ORDER BY status COLLATE NOCASE", + "SELECT id FROM orders WHERE status COLLATE NOCASE = 'x'", + "SELECT id FROM orders ORDER BY region COLLATE RTRIM", + "SELECT id FROM orders WHERE status = 'x' COLLATE utf8mb4_bin", + "SELECT created_at AT TIME ZONE 'UTC' FROM orders", + "SELECT id FROM orders WHERE status ISNULL", + "SELECT id FROM orders WHERE status NOTNULL", + "SELECT id FROM orders WHERE region GLOB 'E*'", + "SELECT id FROM orders WHERE region REGEXP '^E'", + "SELECT id FROM orders WHERE region RLIKE '^E'", + "SELECT id FROM orders WHERE region SIMILAR TO 'E%'", + "SELECT id FROM orders WHERE region SOUNDS LIKE 'x'", + "SELECT amount DIV 2 FROM orders", + "SELECT amount MOD 2 FROM orders", + "SELECT id FROM orders WHERE amount > 0 XOR status = 'x'", + "SELECT region, SUM(amount) FROM orders GROUP BY GROUPING SETS ((region), ())", + "SELECT FIRST_VALUE(amount) IGNORE NULLS OVER (ORDER BY id) FROM orders", + "SELECT FIRST_VALUE(amount) RESPECT NULLS OVER (ORDER BY id) FROM orders", + "SELECT RANK() OVER (ORDER BY amount RANGE BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW EXCLUDE TIES) FROM orders", + "SELECT RANK() OVER (ORDER BY amount GROUPS BETWEEN 1 PRECEDING AND CURRENT ROW EXCLUDE NO OTHERS) FROM orders", + "SELECT id, SUM(amount) OVER w FROM orders WINDOW w AS (PARTITION BY region)", + "SELECT SUM(amount) OVER w AS total FROM orders WINDOW w AS (PARTITION BY region)", + "SELECT id FROM orders WHERE amount > 0 MINUS SELECT id FROM orders", + "SELECT id FROM orders WHERE created_at >= NOW() - INTERVAL 7 DAYS", + "SELECT id FROM orders WHERE status = ANY (ARRAY['a'])", + "SELECT id FROM orders WHERE MATCH (status) AGAINST ('x' IN BOOLEAN MODE)", + "SELECT id FROM orders WHERE created_at OVERLAPS created_at", + "SELECT id FROM orders WHERE status = 'x' FOR UPDATE SKIP LOCKED", + "SELECT id FROM orders WHERE status = 'x' FOR SHARE NOWAIT", + "SELECT id FROM orders WHERE status = 'x' LOCK IN SHARE MODE", + "SELECT SQL_CALC_FOUND_ROWS id FROM orders", + "SELECT DISTINCTROW id FROM orders", + "SELECT id FROM orders WHERE region = N'x'", + "SELECT CONVERT(region USING utf8mb4) FROM orders", + "SELECT id FROM orders WHERE status = 'x' START WITH id = 1 CONNECT BY PRIOR id = customer_id", + "SELECT [Total Sales] FROM orders", + "SELECT id FROM orders WHERE region = @wanted", + "SELECT id FROM orders WHERE created_at > DATEADD(dd, -7, GETDATE())", + "SELECT DATEDIFF(wk, created_at, GETDATE()) FROM orders", + # 表达式后面不写 AS 的别名:前面是 END、NULL、TRUE、CURRENT_DATE 这种「一个值到此为止」的词 + "SELECT CASE WHEN status = 'x' THEN 1 ELSE 0 END flag FROM orders", + "SELECT CASE WHEN status = 'x' THEN 1 ELSE 0 END flag, id FROM orders", + "SELECT NULL placeholder, id FROM orders", + "SELECT TRUE flag, id FROM orders", + "SELECT CURRENT_DATE today, id FROM orders", +] + + +@pytest.mark.parametrize("sql", VALID_SQL) +def test_valid_sql_is_not_sent_back(sql): + from app.api.copilot import sql_unknown_columns + + shop = _source(orders=["id", "customer_id", "amount", "status", "created_at", "region"]) + assert sql_unknown_columns(sql, shop) is None, sql_unknown_columns(sql, shop) + + +@pytest.mark.parametrize("sql, unknown", [ + # 别名照样认得出来,打错的列照样打回 + ("SELECT CASE WHEN statsu = 'x' THEN 1 ELSE 0 END flag FROM orders", ["statsu"]), + ("SELECT id FROM orders ORDER BY regoin COLLATE NOCASE", ["regoin"]), + ("SELECT id, SUM(amt) OVER w FROM orders WINDOW w AS (PARTITION BY region)", ["amt"]), + ("SELECT id FROM orders WHERE statsu ISNULL", ["statsu"]), + # 日期部分的缩写只在 DATEADD 这类函数的第一个参数上放过 + ("SELECT DATEADD(dd, 1, created_at) FROM orders WHERE wk = 1", ["wk"]), +]) +def test_typos_next_to_those_constructs_are_still_caught(sql, unknown): + from app.api.copilot import sql_unknown_columns + + shop = _source(orders=["id", "customer_id", "amount", "status", "created_at", "region"]) + found = sql_unknown_columns(sql, shop) + assert found is not None and found[0] == unknown, found diff --git a/backend/tests/test_evidence_markers.py b/backend/tests/test_evidence_markers.py index a06eefca..782a3d59 100644 --- a/backend/tests/test_evidence_markers.py +++ b/backend/tests/test_evidence_markers.py @@ -254,7 +254,7 @@ def test_unresolved_references(catalog): reasons = {v["ref"]: v["message"] for v in bad} assert "gmvx" in reasons["m:gmvx"] assert "没有值" in reasons["m:gone"] - assert LATER_REASON in reasons["v:Q4.r0.amount"] + assert "目录里没有 Q4" in reasons["v:Q4.r0.amount"] # 单元格二期起支持,这份目录里没有 Q4 assert "百分数" in reasons["m:wow|pct"] for s in iter_segments(doc): if s.get("ref"): diff --git a/backend/tests/test_evidence_query_chain.py b/backend/tests/test_evidence_query_chain.py new file mode 100644 index 00000000..7cd3db19 --- /dev/null +++ b/backend/tests/test_evidence_query_chain.py @@ -0,0 +1,442 @@ +"""证据接口二期:片段链接上查询步骤,点开一个数一路追到快照里的那一格。 + +- 指标步骤、输入步骤之后接查询步骤:{step:"query", alias, tool, sql, columns, rows, row_offset, total_rows, + truncated, highlight:{rows, cols}, artifact, hash_ok} +- [[v:]] 片段(含整表里的格)直接给出查询步骤 +- rows 只返回被引用的行加前后各 2 行;完整快照仍走 /api/artifacts/{id} +- 数据源 options.mask_columns 里的列一律换成「已遮罩」,并列进 redacted.columns +- 只认封存范围内的事件(tool.end.query_artifact、node.finished.evidence 的 query 条目)追得到的快照 +- 证据图的 evidence 列表里有查询、检索条目 +""" +from __future__ import annotations + +import asyncio +import json +import sqlite3 + +import pytest +from httpx import ASGITransport, AsyncClient +from langchain_core.messages import AIMessage, ToolMessage +from sqlalchemy import select + +from app.core.config import settings +from app.data.engine import engines +from app.db.base import SessionLocal +from app.db.models import DataSource, Run, RunEvent +from app.engine.evidence import iter_segments +from app.engine.runner import run_manager +from app.main import app + +SQL = "SELECT id, week, amount, email FROM orders ORDER BY id" +ROWS = [(i, "2026-W37" if i > 3 else "2026-W36", float(i * 100) + 0.5, f"u{i}@example.com") for i in range(1, 11)] +TEXT = ("这一单金额 [[v:Q1.r5.amount]],口径卡记为 [[m:gmv]]。[[see:K1]]\n\n" + "[[table:Q1 cols=id,amount rows=4-6]]") + + +@pytest.fixture(autouse=True) +async def engine_up(): + await run_manager.setup() + yield + await run_manager.shutdown() + + +@pytest.fixture +async def client(): + async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as c: + yield c + + +async def set_source(path, options=None): + async with SessionLocal() as session: + row = (await session.execute(select(DataSource).where(DataSource.name == "shop"))).scalar_one_or_none() + if row is None: + row = DataSource(name="shop", kind="sqlite", database=str(path), readonly=True, enabled=True) + session.add(row) + row.database, row.kind, row.enabled, row.readonly = str(path), "sqlite", True, True + row.options = dict(options or {}) + await session.commit() + return row.id + + +@pytest.fixture(autouse=True) +async def shop(tmp_path): + path = tmp_path / "shop.db" + db = sqlite3.connect(path) + db.executescript("CREATE TABLE orders (id INTEGER PRIMARY KEY, week TEXT, amount REAL, email TEXT);") + db.executemany("INSERT INTO orders VALUES (?, ?, ?, ?)", ROWS) + db.commit() + db.close() + source_id = await set_source(path) + await engines.invalidate(source_id) + yield path + await engines.invalidate(source_id) + + +def node(nid, ntype, **config): + return {"id": nid, "type": ntype, "position": {"x": 0, "y": 0}, "data": {"label": nid, "config": config}} + + +def chain(*nodes): + return {"nodes": list(nodes), "edges": [{"source": a["id"], "target": b["id"]} for a, b in zip(nodes, nodes[1:])]} + + +def weekly(): + return chain( + node("start", "input"), + node("fetch", "tool", tool="db_query__shop", args={"sql": SQL}), + node("docs", "retrieve", query="退款口径", collection="docs"), + node("card", "metrics", caliber="周报口径", caliber_version="v2", metrics=[ + {"id": "gmv", "name": "销售额", "unit": "元", "decimals": 1, "format": "thousands", + "expression": "cell(nodes.fetch, 5, 'amount')"}]), + node("write", "report", instructions="写周报", on_violation="flag"), + node("out", "output", fields=[{"name": "周报", "value": "{{ nodes.write.text }}"}]), + ) + + +def script(monkeypatch, text=TEXT): + from app.providers import mock_model + + monkeypatch.setattr(mock_model.MockChatModel, "_decide", lambda self, messages: AIMessage(content=text)) + + +async def wait(run_id: str) -> Run: + for _ in range(400): + await asyncio.sleep(0.05) + async with SessionLocal() as session: + row = await session.get(Run, run_id) + if row.status in ("succeeded", "failed") and row.manifest_seq is not None: + return row + raise AssertionError(f"run {run_id} 没跑完:{row.status}") + + +def load_doc(doc_id: str) -> dict: + return json.loads((settings.data_dir / "artifacts" / doc_id[:2] / f"{doc_id}.json").read_text("utf-8")) + + +async def run_weekly(monkeypatch, graph=None, text=TEXT) -> tuple[Run, dict]: + from app.memory import kb + + async def fake_search(session, **kw): + return [{"title": "口径说明", "ordinal": 1, "content": "退款按发生周统计", "score": 0.9, "document_id": "d1"}] + + monkeypatch.setattr(kb, "search", fake_search) + script(monkeypatch, text) + row = await wait((await run_manager.start(graph=graph or weekly(), input_payload={})).id) + assert row.status == "succeeded", row.error + return row, load_doc(row.output["_evidence"]["doc_artifact"]) + + +def seg_of(doc: dict, text: str, n: int = 0) -> dict: + return [s for s in iter_segments(doc) if s["text"] == text and s.get("cite")][n] + + +async def query_artifact(run_id: str, node_id: str = "fetch") -> str: + async with SessionLocal() as session: + [end] = (await session.execute(select(RunEvent).where( + RunEvent.run_id == run_id, RunEvent.type == "tool.end", RunEvent.node_id == node_id))).scalars() + return end.data["query_artifact"] + + +async def segment(client, run_id: str, seg_id: str) -> dict: + resp = await client.get(f"/api/runs/{run_id}/evidence/segments/{seg_id}") + assert resp.status_code == 200, resp.text + return resp.json() + + +# -------------------------------------------------------------------------- +# 查询步骤的形状、窗口、高亮 +# -------------------------------------------------------------------------- + + +async def test_a_cell_segment_opens_the_query_step(client, monkeypatch): + row, doc = await run_weekly(monkeypatch) + artifact = await query_artifact(row.id) + body = await segment(client, row.id, seg_of(doc, "600.5")["id"]) + + [step] = body["chain"] + assert step["step"] == "query" and step["alias"] == "Q1" and step["tool"] == "db_query__shop" + assert step["sql"] == SQL and step["columns"] == ["id", "week", "amount", "email"] + assert step["artifact"] == artifact and step["hash_ok"] is True and step["sealed"] is True + assert step["node_id"] == "fetch" and step["source"] == "shop" + assert step["total_rows"] == 10 and step["truncated"] is False + assert step["column_types"] == {"id": "number", "week": "text", "amount": "number", "email": "text"} + # 第 5 行(从 0 数)加前后各 2 行 + assert step["row_offset"] == 3 and step["row_index"] == [3, 4, 5, 6, 7] + assert [r[0] for r in step["rows"]] == [4, 5, 6, 7, 8] + assert step["highlight"] == {"rows": [5], "cols": ["amount"], "cells": [[5, "amount"]]} + assert body["seal"]["covered"] is True + assert body["redacted"] == {"columns": []} + assert "查询快照" in body["note"] + + +async def test_the_window_is_clipped_at_both_ends(client, monkeypatch): + row, doc = await run_weekly(monkeypatch, text="首行 [[v:Q1.r0.amount]],末行 [[v:Q1.r9.amount]]。") + first = (await segment(client, row.id, seg_of(doc, "100.5")["id"]))["chain"][0] + assert first["row_offset"] == 0 and first["row_index"] == [0, 1, 2] + last = (await segment(client, row.id, seg_of(doc, "1,000.5")["id"]))["chain"][0] + assert last["row_index"] == [7, 8, 9] and last["highlight"]["rows"] == [9] + + +async def test_table_cells_open_the_same_query_step(client, monkeypatch): + row, doc = await run_weekly(monkeypatch) + cell = seg_of(doc, "600.5", 1) # 整表里第 5 行 amount 那一格 + assert cell["cite"]["kind"] == "cell" + [step] = (await segment(client, row.id, cell["id"]))["chain"] + assert step["alias"] == "Q1" and step["highlight"]["cells"] == [[5, "amount"]] + + +async def test_a_metric_chain_ends_in_the_query_step(client, monkeypatch): + row, doc = await run_weekly(monkeypatch) + artifact = await query_artifact(row.id) + metric, source, query = (await segment(client, row.id, seg_of(doc, "600.5元")["id"]))["chain"] + assert metric["step"] == "metric" and metric["alias"] == "m:gmv" + assert source["step"] == "input" and source["via"] == "tool_cell" and source["status"] == "verified" + assert source["artifact"] == artifact and source["locator"] == {"row": 5, "column": "amount"} + assert source["query"] == "Q1" and source["cell"] == "Q1.r5.amount" + assert query["step"] == "query" and query["artifact"] == artifact + assert query["highlight"]["cells"] == [[5, "amount"]] and query["row_index"] == [3, 4, 5, 6, 7] + + +# -------------------------------------------------------------------------- +# 遮罩 +# -------------------------------------------------------------------------- + + +async def test_masked_columns_are_redacted(client, monkeypatch, shop): + await set_source(shop, {"mask_columns": ["EMAIL"]}) + row, doc = await run_weekly(monkeypatch) + body = await segment(client, row.id, seg_of(doc, "600.5")["id"]) + [step] = body["chain"] + email = step["columns"].index("email") + assert {r[email] for r in step["rows"]} == {"已遮罩"} + assert step["rows"][2][2] == 600.5 and step["masked"] == ["email"] + assert body["redacted"]["columns"] == ["email"] + assert "不是安全边界" in body["redacted"]["note"] + + +async def test_mask_columns_written_as_text_are_understood(client, monkeypatch, shop): + """数据源表单的「高级连接参数」存的是文本:「email, week」和列表是一个意思。""" + await set_source(shop, {"mask_columns": "email,week"}) + row, doc = await run_weekly(monkeypatch) + body = await segment(client, row.id, seg_of(doc, "600.5")["id"]) + assert body["redacted"]["columns"] == ["week", "email"] + + +async def rename_source(old: str, new: str) -> None: + async with SessionLocal() as session: + row = (await session.execute(select(DataSource).where(DataSource.name == old))).scalar_one() + row.name = new + await session.commit() + + +async def test_masks_recorded_at_query_time_survive_a_renamed_source(client, monkeypatch, shop): + """数据源事后改名、删掉:面板按查询当时记下的遮罩处理,不能因为找不到数据源就把原值全亮出来。""" + await set_source(shop, {"mask_columns": ["email"]}) + row, doc = await run_weekly(monkeypatch) + artifact = await query_artifact(row.id) + assert load_doc(artifact)["mask_columns"] == ["email"] + await rename_source("shop", "shop_renamed") + try: + body = await segment(client, row.id, seg_of(doc, "600.5")["id"]) + finally: + await rename_source("shop_renamed", "shop") + [step] = body["chain"] + email = step["columns"].index("email") + assert {r[email] for r in step["rows"]} == {"已遮罩"} and step["masked"] == ["email"], step + assert body["redacted"]["columns"] == ["email"] + assert "「shop」" in step["mask_note"] and "查询当时" in step["mask_note"], step + + +async def test_masks_added_after_the_query_still_apply(client, monkeypatch, shop): + row, doc = await run_weekly(monkeypatch) + assert "mask_columns" not in load_doc(await query_artifact(row.id)) # 没设遮罩的快照和原来一字不差 + await set_source(shop, {"mask_columns": ["week"]}) + [step] = (await segment(client, row.id, seg_of(doc, "600.5")["id"]))["chain"] + assert step["masked"] == ["week"] and "mask_note" not in step + + +async def test_recorded_and_current_masks_are_combined(client, monkeypatch, shop): + """查询时遮 email、事后改成遮 week:两列都遮。事后把某列从遮罩里拿掉,旧证据也不因此亮出来。""" + await set_source(shop, {"mask_columns": ["email"]}) + row, doc = await run_weekly(monkeypatch) + await set_source(shop, {"mask_columns": ["week"]}) + [step] = (await segment(client, row.id, seg_of(doc, "600.5")["id"]))["chain"] + assert step["masked"] == ["week", "email"], step + + +async def test_a_vanished_source_without_masks_is_named_but_rows_stay(client, monkeypatch): + row, doc = await run_weekly(monkeypatch) + await rename_source("shop", "shop_gone") + try: + [step] = (await segment(client, row.id, seg_of(doc, "600.5")["id"]))["chain"] + finally: + await rename_source("shop_gone", "shop") + assert step["masked"] == [] and step["rows"][2][2] == 600.5 and "「shop」" in step["mask_note"], step + + +async def test_the_model_does_not_see_the_mask_record(monkeypatch, shop): + """遮罩只管证据面板:模型拿到的查询结果和原来一样,不多一个会让它以为数据被遮了的字段。""" + await set_source(shop, {"mask_columns": ["email"]}) + row, _ = await run_weekly(monkeypatch) + async with SessionLocal() as session: + [end] = (await session.execute(select(RunEvent).where( + RunEvent.run_id == row.id, RunEvent.type == "tool.end", RunEvent.node_id == "fetch"))).scalars() + returned = load_doc(end.data["artifact"]) + assert "mask_columns" not in json.dumps(returned, ensure_ascii=False), returned + + +# -------------------------------------------------------------------------- +# 只认封存范围内追得到的快照 +# -------------------------------------------------------------------------- + + +async def strip_query(run_id: str, artifact: str) -> None: + """从封存范围内的事件里抹掉这份快照的痕迹,再按改过的事件重算封存哈希: + 模拟「封存链完好,但事件里没有这件工件」——目录里写着它,也不能认。""" + from app.core.artifact_store import manifest_hash + + async with SessionLocal() as session: + run = await session.get(Run, run_id) + for event in (await session.execute(select(RunEvent).where(RunEvent.run_id == run_id))).scalars(): + data = dict(event.data or {}) + if event.type == "tool.end" and data.get("query_artifact") == artifact: + data.pop("query_artifact") + elif event.type == "node.finished" and data.get("evidence"): + data["evidence"] = [e for e in data["evidence"] if e.get("artifact") != artifact] + else: + continue + event.data = data + await session.commit() + rows = [tuple(r) for r in await session.execute( + select(RunEvent.seq, RunEvent.type, RunEvent.node_id, RunEvent.data) + .where(RunEvent.run_id == run_id, RunEvent.seq <= run.manifest_seq).order_by(RunEvent.seq))] + run.manifest_hash = manifest_hash(rows) + await session.commit() + + +async def test_a_snapshot_outside_the_seal_is_not_served(client, monkeypatch): + row, doc = await run_weekly(monkeypatch) + artifact = await query_artifact(row.id) + await strip_query(row.id, artifact) + # 封存之后追加一条指向它的事件,也不算 + async with SessionLocal() as session: + session.add(RunEvent(run_id=row.id, seq=row.manifest_seq + 5, type="tool.end", node_id="fetch", ts=0.0, + data={"tool": "db_query__shop", "query_artifact": artifact})) + await session.commit() + + body = await segment(client, row.id, seg_of(doc, "600.5")["id"]) + assert body["seal"]["ok"] is True + [step] = body["chain"] + assert step["artifact"] == artifact and step["sealed"] is False + assert step["rows"] == [] and step["columns"] == [] and step["sql"] is None + assert "封存" in step["note"] and body["seal"]["covered"] is False + + +async def test_a_tampered_snapshot_shows_no_rows(client, monkeypatch): + row, doc = await run_weekly(monkeypatch) + artifact = await query_artifact(row.id) + path = settings.data_dir / "artifacts" / artifact[:2] / f"{artifact}.json" + original = path.read_text("utf-8") + snap = json.loads(original) + snap["rows"][5][2] = 9999.5 + path.write_text(json.dumps(snap, ensure_ascii=False), encoding="utf-8") + try: + [step] = (await segment(client, row.id, seg_of(doc, "600.5")["id"]))["chain"] + finally: + # 快照按内容寻址、不带运行 id:后面的测试查出同样的结果(同一毫秒跑完)会落到同一个文件上 + path.write_text(original, encoding="utf-8") + assert step["hash_ok"] is False and step["rows"] == [] and "哈希" in step["note"] + + +# -------------------------------------------------------------------------- +# agent 字段:输入步骤带着核对状态,跳到的是全局编号的查询 +# -------------------------------------------------------------------------- + + +async def test_agent_field_inputs_carry_their_check_and_jump_to_the_query(client, monkeypatch): + from app.providers import mock_model + + schema = {"type": "object", "properties": {"gmv": {"type": "number"}, "orders": {"type": "integer"}}} + extracted = {"gmv": {"value": 601.5, "from": {"call": "Q1", "row": 0, "column": "gmv"}}, + "orders": {"value": 30, "from": {"call": "Q1", "row": 0, "column": "orders"}}} + agg = "SELECT SUM(amount) AS gmv, COUNT(*) AS orders FROM orders WHERE week = '2026-W36'" + + def decide(model, messages): + if model.response_format: + return AIMessage(content=json.dumps(extracted)) + if not model.tools: + return AIMessage(content=TEXT_ORDERS) + if not any(isinstance(m, ToolMessage) for m in messages): + return AIMessage(content="查。", tool_calls=[{"name": "db_query__shop", "args": {"sql": agg}, "id": "c0"}]) + return AIMessage(content="查完了。") + + TEXT_ORDERS = "订单 [[m:orders]]。" + monkeypatch.setattr(mock_model.MockChatModel, "_decide", decide) + graph = chain( + node("start", "input"), + node("bot", "agent", prompt="查", tools=["db_query__shop"], approval="never", output_schema=schema, + cite_fields=True, assign_to="kpi"), + node("card", "metrics", caliber="周报口径", metrics=[ + {"id": "orders", "name": "订单", "unit": "单", "format": "thousands", "expression": "vars.kpi.orders"}]), + node("write", "report", instructions="写", on_violation="flag"), + node("out", "output", fields=[{"name": "周报", "value": "{{ nodes.write.text }}"}]), + ) + row = await wait((await run_manager.start(graph=graph, input_payload={})).id) + assert row.status == "succeeded", row.error + doc = load_doc(row.output["_evidence"]["doc_artifact"]) + artifact = await query_artifact(row.id, "bot") + metric, source, query = (await segment(client, row.id, seg_of(doc, "3单")["id"]))["chain"] + assert source["via"] == "agent_field" and source["status"] == "mismatch" and source["model_value"] == 30 + assert source["ref"] == "Q1.r0.orders" and source["cell"] == "Q1.r0.orders" and source["query"] == "Q1" + assert query["artifact"] == artifact and query["highlight"]["cells"] == [[0, "orders"]] + assert query["rows"] == [[601.5, 3]] and query["row_index"] == [0] + + +# -------------------------------------------------------------------------- +# 证据图 +# -------------------------------------------------------------------------- + + +async def test_the_graph_lists_queries_and_retrievals(client, monkeypatch): + row, doc = await run_weekly(monkeypatch) + body = (await client.get(f"/api/runs/{row.id}/evidence")).json() + by_alias = {e["alias"]: e for e in body["evidence"]} + q1, k1 = by_alias["Q1"], by_alias["K1"] + assert q1["kind"] == "query" and q1["sealed"] is True and q1["node_id"] == "fetch" + assert q1["tool"] == "db_query__shop" and q1["columns"] == ["id", "week", "amount", "email"] + assert q1["rows"] == 10 and q1["source"] == "shop" + assert seg_of(doc, "600.5")["id"] in q1["cited_by"] + assert k1["kind"] == "retrieval" and k1["sealed"] is True and k1["source"] == "docs" and k1["rows"] == 1 + edges = body["edges"] + assert {"from": "Q1.r5.amount", "to": "Q1", "rel": "cell_of", "report": "write"} in edges + [card_input] = [e for e in edges if e["rel"] == "input" and e["from"] == "m:gmv"] + assert card_input["cell"] == "Q1.r5.amount" + + +# -------------------------------------------------------------------------- +# 数据源的 mask_columns 配置 +# -------------------------------------------------------------------------- + + +def test_mask_columns_is_not_a_driver_option(): + """驱动不认识 mask_columns:拼进连接串,连接就会因为未知参数被拒。""" + from types import SimpleNamespace + + from app.data.engine import build_url, masked_columns + + source = SimpleNamespace(kind="postgres", host="db", port=5432, database="shop", username="u", password=None, + options={"mask_columns": ["email"], "sslmode": "disable"}) + assert "mask_columns" not in build_url(source) and "sslmode=disable" in build_url(source) + assert masked_columns({"mask_columns": " email ,phone、email\nnote"}) == ["email", "phone", "note"] + assert masked_columns({"mask_columns": ["email", " ", "phone"]}) == ["email", "phone"] + assert masked_columns({}) == [] and masked_columns(None) == [] + + +async def test_datasource_form_offers_mask_columns_and_refuses_garbage(client): + kinds = {k["value"]: k for k in (await client.get("/api/datasources/kinds")).json()["kinds"]} + for kind in kinds.values(): + field = next(a for a in kind["advanced"] if a["key"] == "mask_columns") + assert "不是安全边界" in field["help"] + bad = await client.post("/api/datasources", json={"name": "masked", "kind": "sqlite", "database": "/tmp/x.db", + "options": {"mask_columns": {"email": True}}}) + assert bad.status_code == 422 and "遮罩" in bad.json()["detail"] diff --git a/backend/tests/test_evidence_validate.py b/backend/tests/test_evidence_validate.py new file mode 100644 index 00000000..6b926a38 --- /dev/null +++ b/backend/tests/test_evidence_validate.py @@ -0,0 +1,102 @@ +"""画图时就能说破的几件证据链配置问题(validate_graph)。 + +- agent 写了 output_schema 却没开 cite_fields:Schema 不生效,assign_to 拿到的仍是自由文字 +- 口径卡的输入来自沙箱代码、而它没标成取数(evidence_role 不是 source):算出来的数核对不了出处 +- 报告撰写节点没指定模型:和 llm / agent 一样提示会回退到默认 provider +- 钉住别处口径卡的 caliber_from:三项要写全、版本要是具体的数,本地不用再写指标 +""" +from __future__ import annotations + +from app.engine.schema import GraphSpec, validate_graph + + +def node(nid, ntype, label=None, **config): + return {"id": nid, "type": ntype, "position": {"x": 0, "y": 0}, + "data": {"label": label or nid, "config": config}} + + +def issues(*nodes, node_id=None): + graph = {"nodes": list(nodes), + "edges": [{"source": a["id"], "target": b["id"]} for a, b in zip(nodes, nodes[1:])]} + found = validate_graph(GraphSpec.model_validate(graph)).issues + return [i for i in found if node_id is None or i.node_id == node_id] + + +START = node("start", "input") +OUT = node("out", "output", fields=[{"name": "r", "value": "{{ last_message }}"}]) +SCHEMA = {"type": "object", "properties": {"gmv": {"type": "number"}}} + + +def test_output_schema_without_cite_fields_warns(): + bot = node("bot", "agent", prompt="查", tools=["db_query__shop"], output_schema=SCHEMA, model="m") + [issue] = [i for i in issues(START, bot, OUT, node_id="bot") if "cite_fields" in i.message] + assert issue.level == "warning" and issue.field == "output_schema" + assert issue.message.startswith("output_schema 要开 cite_fields 才生效,会多一次抽取调用") + + cited = node("bot", "agent", prompt="查", tools=["db_query__shop"], output_schema=SCHEMA, cite_fields=True, + model="m") + assert not [i for i in issues(START, cited, OUT, node_id="bot") if "cite_fields" in i.message] + plain = node("bot", "agent", prompt="查", tools=["db_query__shop"], model="m") + assert not [i for i in issues(START, plain, OUT, node_id="bot") if "cite_fields" in i.message] + + +def test_caliber_fed_by_a_compute_code_node_warns(): + def graph(**code): + fetch = node("fetch", "code", "取数脚本", code="print('{\"gmv\": 1}')", assign_to="agg", **code) + card = node("card", "metrics", caliber="周报口径", metrics=[ + {"id": "gmv", "expression": "vars.agg.gmv"}, {"id": "gmv2", "expression": "nodes.fetch.gmv * 2"}]) + return issues(START, fetch, card, OUT, node_id="card") + + [issue] = [i for i in graph() if "evidence_role" in i.message] + assert issue.level == "warning" and issue.field == "metrics[0].expression" + assert "「取数脚本」" in issue.message and "source" in issue.message + assert [i for i in graph(evidence_role="compute") if "evidence_role" in i.message] + assert not [i for i in graph(evidence_role="source") if "evidence_role" in i.message] + + +def test_evidence_role_must_be_source_or_compute(): + fetch = node("fetch", "code", code="print(1)", evidence_role="取数") + [issue] = [i for i in issues(START, fetch, OUT, node_id="fetch") if i.field == "evidence_role"] + assert issue.level == "error" and "source" in issue.message and "compute" in issue.message + + +def test_report_without_a_model_gets_the_same_warning_as_llm(): + card = node("card", "metrics", metrics=[{"id": "gmv", "expression": "1"}]) + write = node("write", "report", instructions="写周报") + [issue] = [i for i in issues(START, card, write, OUT, node_id="write") if i.field == "model"] + assert issue.level == "warning" and "没有指定模型" in issue.message + + +def test_caliber_from_replaces_local_metrics(): + pinned = {"workflow_id": "wf1", "workflow_version": 3, "node_id": "caliber"} + card = node("card", "metrics", caliber_from=pinned) + assert not [i for i in issues(START, card, OUT, node_id="card") if i.level == "error"] + + both = node("card", "metrics", caliber_from=pinned, metrics=[{"id": "gmv", "expression": "1"}]) + [issue] = [i for i in issues(START, both, OUT, node_id="card") if i.field == "metrics"] + assert issue.level == "warning" and "不会生效" in issue.message + + for bad, field in ((["wf1"], "caliber_from"), ({"workflow_id": "wf1", "node_id": "c"}, "caliber_from.workflow_version"), + ({"workflow_id": "wf1", "workflow_version": "最新", "node_id": "c"}, + "caliber_from.workflow_version"), + ({"workflow_id": "", "workflow_version": 1, "node_id": "c"}, "caliber_from")): + found = [i for i in issues(START, node("card", "metrics", caliber_from=bad), OUT, node_id="card") + if i.level == "error"] + assert [i.field for i in found] == [field], (bad, found) + + policy = node("card", "metrics", caliber_from=pinned, upgrade_policy="auto") + [issue] = [i for i in issues(START, policy, OUT, node_id="card") if i.field == "upgrade_policy"] + assert issue.level == "error" and "recompute" in issue.message + ok = node("card", "metrics", caliber_from=pinned, upgrade_policy="dual") + assert not [i for i in issues(START, ok, OUT, node_id="card") if i.level == "error"] + + +def test_template_eight_marks_its_fetch_code_as_a_source(): + """模板 ⑧ 的取数代码是「数据底座」:标成 source,口径卡读它不再被提示核对不了出处。""" + from app.seed import TEMPLATES + + weekly = next(t for t in TEMPLATES if t["name"].startswith("⑧")) + fetch = next(n for n in weekly["nodes"] if n["id"] == "fetch") + assert fetch["data"]["config"]["evidence_role"] == "source" + spec = GraphSpec.model_validate({"nodes": weekly["nodes"], "edges": weekly["edges"]}) + assert not [i for i in validate_graph(spec).issues if "evidence_role" in i.message] diff --git a/backend/tests/test_lint_contract_report_from.py b/backend/tests/test_lint_contract_report_from.py new file mode 100644 index 00000000..4f6cf71c --- /dev/null +++ b/backend/tests/test_lint_contract_report_from.py @@ -0,0 +1,103 @@ +"""发布门禁对引用模式契约(report_from)的判断。 + +以前 `_lint_contract` 不管有没有 report_from,一律要求 narrative:模板⑨这种只用 +report_from 核对报告文档的契约,数字回指由报告撰写节点的文档保证、根本用不上 +narrative,却因此发不成受管版本。有 report_from 就不再要 narrative;report_from +指错(不是报告撰写节点、不在出口上游)照样挡住发布。 +""" +from __future__ import annotations + +import copy + +import pytest +from httpx import ASGITransport, AsyncClient + +from app.engine.governance import lint_for_publish +from app.engine.schema import GraphSpec +from app.main import app +from app.seed import TEMPLATES + + +@pytest.fixture +async def client(): + async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as c: + yield c + + +def traceable() -> dict: + """模板⑨(可追溯周报)的图:契约只写 report_from,没有 narrative。""" + tpl = next(t for t in TEMPLATES if t["name"].startswith("⑨")) + return copy.deepcopy({"nodes": tpl["nodes"], "edges": tpl["edges"]}) + + +def contract_of(graph: dict) -> dict: + return next(n for n in graph["nodes"] if n["id"] == "done")["data"]["config"]["contract"] + + +def lint(graph: dict, level: str = "governed") -> list: + return lint_for_publish(GraphSpec.model_validate(graph), level=level).issues + + +async def publish(client, graph: dict, level: str = "governed") -> dict: + wf = (await client.post("/api/workflows", json={"name": "可追溯周报-门禁", "graph": graph})).json() + body = (await client.post(f"/api/workflows/{wf['id']}/publish", json={"level": level})).json() + await client.delete(f"/api/workflows/{wf['id']}") + return body + + +def test_the_template_contract_has_no_narrative_and_needs_none(): + graph = traceable() + assert "narrative" not in contract_of(graph) and contract_of(graph)["report_from"] == "write" + errors = [i for i in lint(graph) if i.level == "error"] + assert errors == [], [i.message for i in errors] + + +async def test_the_template_publishes_as_governed(client): + body = await publish(client, traceable()) + assert body["ok"] is True, body["issues"] + assert all(i["level"] == "warning" for i in body["issues"]) + + +@pytest.mark.parametrize("target, why", [("fetch", "不是「报告撰写」"), ("ghost", "不存在")]) +async def test_a_report_from_pointing_elsewhere_still_blocks(client, target, why): + graph = traceable() + contract_of(graph)["report_from"] = target + body = await publish(client, graph) + assert body["ok"] is False + bad = [i for i in body["issues"] if i["level"] == "error"] + assert [i["code"] for i in bad] == ["contract.report_from_invalid"], bad + assert why in bad[0]["message"] and bad[0]["node_id"] == "done" + + +async def test_a_report_that_is_not_upstream_still_blocks(client): + graph = traceable() + # 报告撰写节点挪到出口后面:出口核对时那份报告还没写出来 + graph["edges"] = [e for e in graph["edges"] if e["target"] != "done"] + [ + {"source": "caliber", "target": "done"}, {"source": "done", "target": "write"}] + body = await publish(client, graph) + assert body["ok"] is False + assert any(i["code"] == "contract.report_from_invalid" and "上游" in i["message"] + for i in body["issues"] if i["level"] == "error"), body["issues"] + + +@pytest.mark.parametrize("level, expected", [("governed", "error"), ("published", "warning")]) +def test_without_report_from_or_narrative_the_gate_still_asks(level, expected): + graph = traceable() + del contract_of(graph)["report_from"] + [issue] = [i for i in lint(graph, level) if i.code == "contract.report_from_missing"] + assert issue.level == expected and issue.node_id == "done" + assert "report_from" in issue.message and "narrative" in issue.message + + +def test_an_empty_report_from_counts_as_missing(): + graph = traceable() + contract_of(graph)["report_from"] = "" + assert any(i.code == "contract.report_from_missing" and i.level == "error" for i in lint(graph)) + + +def test_a_narrative_contract_is_unchanged(): + graph = traceable() + contract = contract_of(graph) + del contract["report_from"] + contract["narrative"] = "{{ nodes.write.text }}" + assert [i for i in lint(graph) if i.level == "error"] == [] diff --git a/backend/tests/test_publish_check_api.py b/backend/tests/test_publish_check_api.py new file mode 100644 index 00000000..c97355f9 --- /dev/null +++ b/backend/tests/test_publish_check_api.py @@ -0,0 +1,257 @@ +"""发布前检查(publish-check)和自动修复(autofix)两个接口。 + +- publish-check 的问题和真正发布时拦下的完全一致(同一套 validate + 门禁) +- 两个接口都只读:调用前后库里的工作流、版本、状态一个字都不变 +- autofix 返回的图(首批里能 auto 的那几类)真的能过发布检查,存下来也真能发布 +""" +from __future__ import annotations + +import copy + +import pytest +from httpx import ASGITransport, AsyncClient +from sqlalchemy import select + +from app.db.base import SessionLocal +from app.db.models import Workflow, WorkflowVersion +from app.main import app + + +@pytest.fixture +async def client(): + async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as c: + yield c + + +def node(nid, ntype, label=None, **config): + return {"id": nid, "type": ntype, "position": {"x": 40, "y": 80}, "data": {"label": label or nid, "config": config}} + + +def weekly(**contract_overrides) -> dict: + contract = {"report_from": "write", "metrics_from": ["card"], "required": ["gmv"], "strict": True} + contract.update(contract_overrides) + nodes = [ + node("start", "input", "周期"), + node("fetch", "tool", "取数", tool="db_query__shop", + args={"sql": "SELECT SUM(amount) AS gmv, COUNT(*) AS orders FROM orders"}), + node("card", "metrics", "周报口径卡", caliber="周报口径", metrics=[ + {"id": "gmv", "name": "销售额", "unit": "元", "expression": "cell(nodes.fetch, 0, 'gmv')"}, + {"id": "orders", "name": "订单数", "unit": "单", "expression": "cell(nodes.fetch, 0, 'orders')"}]), + node("write", "report", "报告撰写", instructions="写周报"), + node("done", "output", "出具", fields=[{"name": "周报", "value": "{{ nodes.write.text }}"}], + contract={k: v for k, v in contract.items() if v is not None}), + ] + return {"nodes": nodes, "edges": [{"source": a["id"], "target": b["id"]} for a, b in zip(nodes, nodes[1:])]} + + +def broken(lib_id: str | None = None) -> dict: + """首批里 auto 能修的几类问题各来一处。""" + graph = weekly(metrics_from=None, report_from="fetch", strict=False) + graph["nodes"].insert(2, node("ask", "agent", "查数员", tools=["db_query__shop"], approval="never", + prompt="用 db_query__shop 查")) + graph["edges"] += [{"source": "start", "target": "ask"}, {"source": "ask", "target": "card"}] + if lib_id: + graph["nodes"].insert(1, node("lib", "subgraph", "方法卡", workflow_id=lib_id)) + graph["edges"] += [{"source": "start", "target": "lib"}, {"source": "lib", "target": "fetch"}] + return graph + + +async def create(client, graph: dict, name: str = "周报-发布检查") -> str: + r = await client.post("/api/workflows", json={"name": name, "graph": graph}) + assert r.status_code == 201, r.text + return r.json()["id"] + + +async def library(client) -> str: + """一张已发布过 v1、之后又存了 v2 草稿的上游:当前的发布版本是 v1。""" + lib = await create(client, weekly(), name="方法卡库") + assert (await client.post(f"/api/workflows/{lib}/publish", json={"level": "published"})).json()["ok"] + changed = weekly() + changed["nodes"][3]["data"]["label"] = "改过的报告" + await client.patch(f"/api/workflows/{lib}", json={"graph": changed}) + return lib + + +async def state(*workflow_ids: str) -> list: + """库里这些工作流和它们全部版本的每一列。""" + async with SessionLocal() as session: + rows = [] + for wid in workflow_ids: + wf = await session.get(Workflow, wid) + rows.append({c.name: getattr(wf, c.name) for c in Workflow.__table__.columns}) + versions = (await session.execute(select(WorkflowVersion).where(WorkflowVersion.workflow_id == wid) + .order_by(WorkflowVersion.version))).scalars() + rows += [{c.name: getattr(v, c.name) for c in WorkflowVersion.__table__.columns} for v in versions] + count = len((await session.execute(select(WorkflowVersion.id))).all()) + return [rows, count] + + +def shape(issues: list[dict]) -> list[tuple]: + return [(i["level"], i.get("code"), i.get("node_id"), i.get("field"), i["message"]) for i in issues] + + +@pytest.mark.parametrize("level", ["governed", "published"]) +async def test_publish_check_matches_what_publishing_blocks(client, level): + lib = await library(client) + wf = await create(client, broken(lib)) + checked = (await client.post(f"/api/workflows/{wf}/publish-check", json={"level": level})).json() + published = (await client.post(f"/api/workflows/{wf}/publish", json={"level": level})).json() + assert checked["level"] == level and checked["ok"] is published["ok"] is False + assert shape(checked["issues"]) == shape(published["issues"]) + assert all("fix" in i for i in checked["issues"]) + + +async def test_publish_check_reports_codes_and_fixes(client): + lib = await library(client) + wf = await create(client, broken(lib)) + body = (await client.post(f"/api/workflows/{wf}/publish-check", json={"level": "governed"})).json() + fixes = {f["id"]: f for f in body["fixes"]} + assert {"governed.agent_approval_never:ask", "governed.subgraph_unpinned:lib", "contract.metrics_from_missing:done", + "contract.report_from_invalid:done", "contract.strict_off:done"} <= set(fixes) + # 钉的是当前的发布版本 v1,不是最新存的草稿 v2 + assert fixes["governed.subgraph_unpinned:lib"]["preview"]["after"] == 1 + assert all(not k.startswith("_") for f in body["fixes"] for k in f) + by_code = {i["code"]: i for i in body["issues"] if i.get("code")} + assert by_code["governed.agent_approval_never"]["fix"] == "governed.agent_approval_never:ask" + # 没有修复的问题 fix 是 null(老前端不认这两个字段也不影响) + assert all(i["fix"] is None for i in body["issues"] if not i.get("code")) + + +async def test_publish_check_takes_an_unsaved_graph(client): + wf = await create(client, weekly()) + assert (await client.post(f"/api/workflows/{wf}/publish-check", json={"level": "governed"})).json()["ok"] is True + body = (await client.post(f"/api/workflows/{wf}/publish-check", + json={"level": "governed", "graph": weekly(required=None)})).json() + assert body["ok"] is False and body["fixes"][0]["id"] == "contract.required_missing:done" + + +async def test_both_endpoints_leave_the_database_alone(client): + lib = await library(client) + wf = await create(client, broken(lib)) + before = await state(wf, lib) + await client.post(f"/api/workflows/{wf}/publish-check", json={"level": "governed"}) + await client.post(f"/api/workflows/{wf}/publish-check", json={"level": "published", "graph": weekly()}) + fixes = (await client.post(f"/api/workflows/{wf}/publish-check", json={"level": "governed"})).json()["fixes"] + out = (await client.post(f"/api/workflows/{wf}/autofix", json={ + "level": "governed", "apply": [f["id"] for f in fixes if f["kind"] == "auto"]})).json() + assert out["applied"] + await client.post(f"/api/workflows/{wf}/autofix", json={"level": "governed", "graph": weekly(required=None), + "apply": ["contract.required_missing:done"], + "choices": {"contract.required_missing:done": ["gmv"]}}) + assert await state(wf, lib) == before + + +async def test_autofixed_graph_passes_the_check_and_really_publishes(client): + lib = await library(client) + graph = broken(lib) + wf = await create(client, graph) + fixes = (await client.post(f"/api/workflows/{wf}/publish-check", json={"level": "governed"})).json()["fixes"] + autos = [f["id"] for f in fixes if f["kind"] == "auto"] + assert len(autos) == 5, autos + out = (await client.post(f"/api/workflows/{wf}/autofix", json={"level": "governed", "apply": autos})).json() + assert sorted(out["applied"]) == sorted(autos) and out["rejected"] == [] and out["ok"] is True + assert [i for i in out["remaining"] if i["level"] == "error"] == [] + assert {c["fix_id"] for c in out["changes"]} == set(autos) + assert all(op["op"] == "update_node" for op in out["ops"]) + # 预览只改了修复涉及的字段 + fixed = out["graph"] + assert [n["position"] for n in fixed["nodes"]] == [n["position"] for n in graph["nodes"]] + checked = (await client.post(f"/api/workflows/{wf}/publish-check", + json={"level": "governed", "graph": fixed})).json() + assert checked["ok"] is True, [i for i in checked["issues"] if i["level"] == "error"] + # 人确认后前端走现有的保存接口,再发布 + await client.patch(f"/api/workflows/{wf}", json={"graph": fixed, "note": "发布前自动修复"}) + published = (await client.post(f"/api/workflows/{wf}/publish", json={"level": "governed"})).json() + assert published["ok"] is True, published["issues"] + + +async def test_autofix_with_a_choice_and_without_it(client): + wf = await create(client, weekly(required=None)) + fid = "contract.required_missing:done" + out = (await client.post(f"/api/workflows/{wf}/autofix", json={"level": "governed", "apply": [fid]})).json() + assert out["applied"] == [] and out["rejected"][0]["fix_id"] == fid and out["ok"] is False + assert [i["fix"] for i in out["remaining"] if i.get("code") == "contract.required_missing"] == [fid] + out = (await client.post(f"/api/workflows/{wf}/autofix", json={ + "level": "governed", "apply": [fid], "choices": {fid: ["orders", "gmv"]}})).json() + assert out["ok"] is True + contract = next(n for n in out["graph"]["nodes"] if n["id"] == "done")["data"]["config"]["contract"] + assert contract["required"] == ["gmv", "orders"] + + +async def test_required_candidates_come_from_the_pinned_card(client): + lib = await create(client, weekly(), name="口径库") + local = weekly(required=None) + card = local["nodes"][2]["data"]["config"] + card.pop("metrics") + card["caliber_from"] = {"workflow_id": lib, "workflow_version": 1, "node_id": "card"} + wf = await create(client, local) + body = (await client.post(f"/api/workflows/{wf}/publish-check", json={"level": "governed"})).json() + [fix] = [f for f in body["fixes"] if f["id"] == "contract.required_missing:done"] + assert fix["kind"] == "choice" and [o["value"] for o in fix["options"]] == ["gmv", "orders"] + + +async def test_bad_requests(client): + wf = await create(client, weekly()) + r = await client.post(f"/api/workflows/{wf}/publish-check", json={"level": "gold"}) + assert r.status_code == 400 and "「已发布」" in r.json()["detail"] + r = await client.post(f"/api/workflows/{wf}/autofix", json={"level": "gold", "apply": []}) + assert r.status_code == 400 + r = await client.post("/api/workflows/nope/publish-check", json={"level": "governed"}) + assert r.status_code == 404 + r = await client.post("/api/workflows/nope/autofix", json={"level": "governed", "apply": []}) + assert r.status_code == 404 + body = (await client.post(f"/api/workflows/{wf}/publish-check", + json={"level": "governed", "graph": {"nodes": [{"id": "a", "type": "nope"}]}})).json() + assert body["ok"] is False and body["fixes"] == [] and "读不懂" in body["issues"][0]["message"] + body = (await client.post(f"/api/workflows/{wf}/autofix", json={ + "level": "governed", "graph": {"nodes": [{"id": "a", "type": "nope"}]}, "apply": ["x:y"]})).json() + assert body["ok"] is False and body["applied"] == [] and "读不懂" in body["remaining"][0]["message"] + + +async def test_the_draft_in_the_database_is_the_default(client): + wf = await create(client, weekly(strict=False)) + graph = copy.deepcopy(weekly(strict=False)) + out = (await client.post(f"/api/workflows/{wf}/autofix", + json={"level": "governed", "apply": ["contract.strict_off:done"]})).json() + assert out["applied"] == ["contract.strict_off:done"] + contract = next(n for n in out["graph"]["nodes"] if n["id"] == "done")["data"]["config"]["contract"] + assert contract["strict"] is True + assert (await client.get(f"/api/workflows/{wf}")).json()["graph"] == graph + + +@pytest.mark.parametrize("contract", ["abc", ["gmv"], 5]) +def test_a_contract_that_is_not_an_object_is_a_validation_error(contract): + """非空、却不是对象的契约:validate 报一条 error(两档发布、发起运行都挡住),不在 .get 上崩。""" + from app.engine.governance import publish_issues + from app.engine.schema import GraphSpec, validate_graph + + graph = weekly() + graph["nodes"][4]["data"]["config"]["contract"] = contract + spec = GraphSpec.model_validate(graph) + [issue] = [i for i in validate_graph(spec).issues if i.code == "contract.not_object"] + assert issue.level == "error" and issue.node_id == "done" and issue.field == "contract" + assert "JSON 对象" in issue.message + for level in ("published", "governed"): + assert any(i.code == "contract.not_object" and i.level == "error" for i in publish_issues(spec, level=level)) + + +@pytest.mark.parametrize("level", ["governed", "published"]) +async def test_a_string_contract_is_reported_not_a_crash(client, level): + graph = weekly() + graph["nodes"][4]["data"]["config"]["contract"] = "report_from=write" + wf = await create(client, graph) + before = await state(wf) + r = await client.post(f"/api/workflows/{wf}/publish-check", json={"level": level}) + assert r.status_code == 200, r.text + body = r.json() + assert body["ok"] is False + assert [(i["code"], i["node_id"]) for i in body["issues"] if i["level"] == "error"][0] == ( + "contract.not_object", "done") + # 修不了确定性修复:交给 Copilot + [fix] = [f for f in body["fixes"] if f["code"] == "contract.not_object"] + assert fix["kind"] == "assist" and fix["node_id"] == "done" + r = await client.post(f"/api/workflows/{wf}/autofix", json={"level": level, "apply": [fix["id"]]}) + assert r.status_code == 200 and r.json()["ok"] is False and r.json()["applied"] == [] + r = await client.post(f"/api/workflows/{wf}/publish", json={"level": level}) + assert r.status_code == 200 and r.json()["ok"] is False + assert await state(wf) == before diff --git a/backend/tests/test_query_column_types.py b/backend/tests/test_query_column_types.py new file mode 100644 index 00000000..6334bb00 --- /dev/null +++ b/backend/tests/test_query_column_types.py @@ -0,0 +1,49 @@ +"""查询快照记下每列的类型(column_types)。 + +驱动把 DECIMAL 读成 Decimal,快照里统一写成字符串保精度:小数位为 0 的 DECIMAL("45678")和 +文本列里的 "2026" 在快照里长得一模一样,下游只能按「像数字就当数字」猜。类型要在读结果的那一刻 +记下来——那时手上还有驱动给的原始值。拿不准的列(全是空值、类型混着)不记,下游照旧用启发式。 +""" +from __future__ import annotations + +import sqlite3 +from datetime import date, datetime +from decimal import Decimal +from types import SimpleNamespace + +import pytest + +from app.data.engine import column_types, engines, run_query + + +def test_types_are_read_from_the_driver_values(): + rows = [ + (Decimal("45678"), "2026", 3, 1.5, None, datetime(2026, 9, 1, 8), date(2026, 9, 1), True, "a"), + (Decimal("12.50"), "2027", 4, None, None, datetime(2026, 9, 2, 8), date(2026, 9, 2), False, 7), + ] + names = ["gmv", "year", "orders", "rate", "empty", "at", "day", "flag", "mixed"] + assert column_types(names, rows) == { + "gmv": "number", "year": "text", "orders": "number", "rate": "number", + "at": "datetime", "day": "date", "flag": "boolean"} + + +async def test_run_query_puts_column_types_into_the_payload(tmp_path): + path = tmp_path / "shop.db" + db = sqlite3.connect(path) + db.executescript("CREATE TABLE orders (id INTEGER, week TEXT, amount REAL, note TEXT);" + "INSERT INTO orders VALUES (1, '2026', 100.5, NULL), (2, '2027', 200.0, NULL);") + db.commit() + db.close() + source = SimpleNamespace(id="column-types", name="shop", kind="sqlite", database=str(path), readonly=True, + options={}, host=None, port=None, username=None, password=None) + try: + payload = (await run_query(source, "SELECT id, week, amount, note FROM orders")).to_payload() + finally: + await engines.invalidate(source.id) + assert payload["column_types"] == {"id": "number", "week": "text", "amount": "number"} + assert payload["rows"] == [[1, "2026", 100.5, None], [2, "2027", 200.0, None]] + + +@pytest.mark.parametrize("rows", [[], [(None,)]]) +def test_nothing_is_recorded_when_nothing_can_be_told(rows): + assert column_types(["x"], rows) == {} diff --git a/backend/tests/test_report_cells.py b/backend/tests/test_report_cells.py new file mode 100644 index 00000000..cfdf0afd --- /dev/null +++ b/backend/tests/test_report_cells.py @@ -0,0 +1,415 @@ +"""报告直接引用查询快照:单元格 [[v:Q1.r0.amount]] 和整表 [[table:Q1 cols=a,b rows=0-4]]。 + +一期的报告只能引用口径卡的指标,查询结果只能挂在 [[see:]] 里当依据。问数据页那种 +「查一下各区域销售额」的答案,数字全在查询结果里,没有口径卡可引——要么硬塞一张只做 +搬运的口径卡,要么模型照抄数字、出处又断了。二期让报告直接引用快照里的那一格: + +- 值一律从快照取(取回时复验哈希),按规则渲染;模型只写位置 +- 行越界、列不存在、快照取不回来、被改过,都判解析不了,原因写清楚 +- 整表由系统从快照生成,每一格都是带出处的片段 +- 沙箱代码节点的产出不能直接引用:那种数要进口径卡 +- 受管出具没声明 cells 时,复核可以把单元格引用判成解析不了 +- 流式渲染和整篇渲染逐字一致 +""" +from __future__ import annotations + +import copy +import random + +import pytest + +from app.core import artifact_store +from app.engine.evidence import ( + CELLS_REASON, + CODE_REASON, + StreamRenderer, + build_catalog, + catalog_prompt, + compose_doc, + iter_segments, + make_eid, + render_markers, + resolve_ref, + verify_doc, +) + +# -------------------------------------------------------------------------- +# 夹具:一份查询快照(形状同数据源工具落的 query_snapshot),外加一张口径卡 +# -------------------------------------------------------------------------- + +SNAP = { + "columns": ["region", "amount", "orders", "note"], + "rows": [ + ["华东", 18230.5, 120, "新客多"], + ["华南", 12004.0, 98, None], + ["华北", "9001.25", 77, "含|竖线"], # DECIMAL 列经 JSON 落盘后是字符串 + ["西南", 0, 0, "多行\n备注"], + ["西北", -12.5, 3, "x"], + ["东北", 1, 1, "y"], + ["中部", 2, 2, "z"], + ], + "row_count": 7, "truncated": False, "elapsed_ms": 3, + "sql": "SELECT region, SUM(amount) AS amount, COUNT(*) AS orders, MAX(note) AS note FROM orders GROUP BY region", + "source": "shop", +} +# 列名本身带数字:系统生成的表头不能被判成裸数字 +DIGITS = {"columns": ["销售额2025", "周"], "rows": [[1.5, "2026-W37"]], "row_count": 1, + "truncated": False, "elapsed_ms": 1, "sql": "SELECT 1", "source": "shop"} +WIDE = {"columns": [f"c{i}" for i in range(13)], "rows": [list(range(13))], "row_count": 1, + "truncated": False, "elapsed_ms": 1, "sql": "SELECT 1", "source": "shop"} +EMPTY = {"columns": ["amount"], "rows": [], "row_count": 0, "truncated": False, "elapsed_ms": 1, + "sql": "SELECT 1", "source": "shop"} + +TOOL_SNAP = "b" * 64 +GONE = "f" * 64 # 台账里有、工件库里没有的快照 +CARD_ART = "c" * 64 +CARD = {"kind": "metric_set", "caliber": "周报口径", "caliber_version": "v2", "artifact": CARD_ART, + "metrics": [{"id": "gmv", "name": "销售额", "unit": "元", "value": 45678.5, "decimals": 1, + "format": "thousands", "status": "ok", "expression": "…"}]} + + +def _query(artifact: str, snap: dict, node: str = "fetch", call: str = "call_1") -> dict: + return {"kind": "query", "node_id": node, "exec": 1, "artifact": artifact, "via": TOOL_SNAP, + "call_id": call, "tool": "db_query__shop", "source": "shop", "columns": snap["columns"], + "rows": len(snap["rows"]), "truncated": False} + + +def _store(snap: dict) -> str: + """按 put_json 的落盘方式写进(测试用的临时)工件库:取回走真实的 load,复验哈希。""" + text = artifact_store.canonical_json(snap) + artifact = artifact_store.content_hash(text) + path = artifact_store._path_of(artifact) + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(text, encoding="utf-8") + return artifact + + +@pytest.fixture(scope="module") +def stored() -> dict[str, str]: + return {name: _store(snap) for name, snap in (("snap", SNAP), ("digits", DIGITS), ("wide", WIDE), ("empty", EMPTY))} + + +@pytest.fixture +def catalog(stored): + ledger = [ + {"kind": "metric_set", "node_id": "caliber", "exec": 1, "artifact": CARD_ART, "caliber": "周报口径", + "version": "v2", "metrics": ["gmv"]}, + _query(stored["snap"], SNAP), # Q1 + {"kind": "retrieval", "node_id": "kb", "exec": 1, "artifact": "d" * 64, "source": "docs", "rows": 3}, + _query(GONE, SNAP, call="call_2"), # Q2:快照取不回来 + _query(stored["digits"], DIGITS, call="call_3"), # Q3 + _query(stored["wide"], WIDE, call="call_4"), # Q4 + _query(stored["empty"], EMPTY, call="call_5"), # Q5 + {"kind": "node_output", "node_id": "calc", "exec": 1, "artifact": "e" * 64, "code_sha": "0" * 64, + "role": "compute", "language": "python"}, + ] + return build_catalog(nodes={"caliber": CARD}, ledger=ledger, inputs={"week": "2026-W37"}) + + +def cell_eid(artifact, row, column): + return make_eid("cell", artifact, {"row": row, "column": column}) + + +def segs(doc, kind=None): + return [s for s in iter_segments(doc) if kind is None or s["kind"] == kind] + + +def bad(doc): + return {v["ref"]: v["message"] for v in doc["violations"] if v["code"] == "unresolved_ref"} + + +# -------------------------------------------------------------------------- +# 单元格 +# -------------------------------------------------------------------------- + + +def test_cell_values_come_from_the_snapshot(catalog, stored): + doc = compose_doc("[[v:Q1.r0.region]]销售额 [[v:Q1.r0.amount]],订单 [[v:Q1.r0.orders]]。[[see:Q1]]", + catalog) + assert doc["markdown"] == "华东销售额 18,230.5,订单 120。" + assert doc["violations"] == [] + region, amount, orders = [s for s in segs(doc) if s.get("ref")] + assert region["kind"] == "value" and amount["kind"] == "number" and orders["kind"] == "number" + cite = amount["cite"] + assert cite["status"] == "resolved" and cite["kind"] == "cell" and cite["role"] == "value" + assert cite["alias"] == "Q1" and cite["ref"] == "Q1.r0.amount" + assert cite["locator"] == {"row": 0, "column": "amount"} and cite["value"] == 18230.5 + assert cite["eid"] == cell_eid(stored["snap"], 0, "amount") + assert amount["state"] == "deterministic" and amount["ref"] == "v:Q1.r0.amount" + assert doc["stats"]["numbers_cited"] == 2 and doc["stats"]["values"] == 1 + assert doc["blocks"][0]["units"][0]["cites"] == ["Q1"] + + +@pytest.mark.parametrize("marker, shown, value", [ + ("[[v:Q1.r2.amount]]", "9,001.25", 9001.25), # DECIMAL 字符串按数渲染 + ("[[v:Q1.r1.note]]", "—", None), # NULL 就是「—」,不是 0 + ("[[v:Q1.r2.note]]", "含¦竖线", "含|竖线"), # 竖线换掉,免得把表格切出一格 + ("[[v:Q1.r3.note]]", "多行 备注", "多行\n备注"), # 文本压成一行 + ("[[v:Q1.r3.amount]]", "0", 0), + ("[[v:Q1.r4.amount]]", "-12.5", -12.5), + ("[[v:Q1.r0.amount|万]]", "1.82万", 18230.5), # 换算由渲染器完成 +]) +def test_cell_rendering_rules(catalog, marker, shown, value): + doc = compose_doc(f"值 {marker}", catalog) + assert doc["violations"] == [], doc["violations"] + [seg] = [s for s in segs(doc) if s.get("ref")] + assert seg["text"] == shown and seg["cite"]["value"] == value + + +def test_integer_decimal_text_renders_as_a_number(): + """小数位为 0 的 DECIMAL(整数列的 SUM())落盘后是没有小数点的 "45678":照样按数渲染、按数引用。""" + snap = {"columns": ["gmv", "refunds", "code", "tiny"], "rows": [["45678", "29", "00123", "1E-7"]], + "row_count": 1, "truncated": False, "elapsed_ms": 1, "sql": "SELECT 1", "source": "shop"} + catalog = build_catalog(nodes={}, ledger=[_query(_store(snap), snap)]) + doc = compose_doc("销售额 [[v:Q1.r0.gmv]]([[v:Q1.r0.gmv|万]]),退款 [[v:Q1.r0.refunds]] 单," + "编号 [[v:Q1.r0.code]],极小值 [[v:Q1.r0.tiny]]。", catalog) + assert doc["violations"] == [], doc["violations"] + assert doc["markdown"] == "销售额 45,678(4.57万),退款 29 单,编号 00123,极小值 0.0000001。" + gmv, wan, refunds, code, tiny = [s for s in segs(doc) if s.get("ref")] + assert gmv["kind"] == "number" and gmv["cite"]["value"] == 45678 and isinstance(gmv["cite"]["value"], int) + assert refunds["kind"] == "number" and refunds["cite"]["value"] == 29 + assert code["kind"] == "value" and code["cite"]["value"] == "00123" # 带前导零的是编号 + assert tiny["kind"] == "number" and tiny["cite"]["value"] == 1e-7 + assert "gmv=45,678" in catalog_prompt(catalog) + + +@pytest.mark.parametrize("marker, words", [ + ("[[v:Q1.r7.amount]]", "只有 7 行"), + ("[[v:Q1.r0.gmv]]", "没有列「gmv」"), + ("[[v:Q9.r0.amount]]", "目录里没有 Q9"), + ("[[v:K1.r0.amount]]", "不是查询结果"), + ("[[v:Q1.amount]]", "Q<编号>.r<行>.<列>"), + ("[[v:Q2.r0.amount]]", "取不回来"), + ("[[v:Q1.r0.region|万]]", "不是数"), + ("[[v:N:calc.total]]", CODE_REASON), + ("[[v:N:fetch.total]]", "后续版本"), +]) +def test_unresolvable_cells_say_why(catalog, marker, words): + doc = compose_doc(f"值 {marker}。", catalog) + [(ref, message)] = bad(doc).items() + assert ref == marker[2:-2] and words in message, message + [seg] = [s for s in segs(doc) if s.get("ref")] + assert seg["state"] == "none" and seg["issue"] == "unresolved_ref" and seg["text"].startswith("⟦?v:") + + +def test_tampered_snapshot_is_refused(stored, tmp_path): + """取回时复验哈希:工件文件被改过,单元格就解析不了,而不是把改过的数当真。""" + path = artifact_store._path_of(stored["snap"]) + original = path.read_text(encoding="utf-8") + try: + path.write_text(original.replace("18230.5", "99999.5"), encoding="utf-8") + catalog = build_catalog(nodes={}, ledger=[_query(stored["snap"], SNAP)]) + doc = compose_doc("值 [[v:Q1.r0.amount]]", catalog) + assert "对不上" in bad(doc)["v:Q1.r0.amount"] + assert "99,999.5" not in doc["markdown"] + finally: + path.write_text(original, encoding="utf-8") + + +def test_code_node_outputs_are_not_citable(catalog): + """沙箱算出来的数不在目录里给编号,也不能按节点字段引用:要进口径卡。""" + assert catalog["N:calc"]["kind"] == "node_output" and catalog["N:calc"]["code"] is True + assert not any(e.get("node_id") == "calc" for a, e in catalog.items() if a.startswith("Q")) + prompt = catalog_prompt(catalog) + assert "N:calc" not in prompt + + +# -------------------------------------------------------------------------- +# 整表 +# -------------------------------------------------------------------------- + + +def test_table_marker_expands_into_a_table_of_cited_cells(catalog, stored): + doc = compose_doc("各区域如下:\n\n[[table:Q1 cols=region,amount rows=0-2]]\n\n华东最高。", catalog) + assert doc["violations"] == [], doc["violations"] + assert doc["markdown"] == ("各区域如下:\n\n| region | amount |\n| --- | --- |\n| 华东 | 18,230.5 |\n" + "| 华南 | 12,004 |\n| 华北 | 9,001.25 |\n\n华东最高。") + assert [b["type"] for b in doc["blocks"]] == ["paragraph", "table", "paragraph"] + table = doc["blocks"][1] + header = [u for u in table["units"] if u["loc"]["row"] == -1] + cells = [u for u in table["units"] if u["loc"]["row"] >= 0] + assert len(header) == 2 and len(cells) == 6 + for unit in cells: + [seg] = [s for s in unit["segments"] if s["kind"] != "structural"] + row, col = unit["loc"]["row"], ["region", "amount"][unit["loc"]["col"]] + assert seg["ref"] == f"v:Q1.r{row}.{col}" and seg["state"] == "deterministic" + assert seg["cite"]["eid"] == cell_eid(stored["snap"], row, col) + assert unit["cites"] == ["Q1"] + # 模型的原文里只有那一个标记,展开是系统做的 + assert doc["source"].count("[[") == 1 + + +def test_table_defaults_to_the_first_five_rows_and_all_columns(catalog): + doc = compose_doc("[[table:Q1]]", catalog) + assert doc["violations"] == [] + table = doc["blocks"][0] + assert {u["loc"]["row"] for u in table["units"]} == {-1, 0, 1, 2, 3, 4} + assert {u["loc"]["col"] for u in table["units"]} == {0, 1, 2, 3} + + +@pytest.mark.parametrize("marker, words", [ + ("[[table:Q1 rows=0-25]]", "最多 20 行"), + ("[[table:Q1 rows=5-9]]", "只有 7 行"), + ("[[table:Q1 rows=3-1]]", "rows"), + ("[[table:Q1 cols=region,gmv]]", "没有列「gmv」"), + ("[[table:Q1 limit=3]]", "不认识的选项"), + ("[[table:Q4]]", "cols="), + ("[[table:Q5]]", "0 行"), + ("[[table:Q2]]", "取不回来"), + ("[[table:K1]]", "不是查询结果"), +]) +def test_table_problems_are_violations(catalog, marker, words): + doc = compose_doc(f"见下表:\n\n{marker}", catalog) + [(ref, message)] = bad(doc).items() + assert ref == marker[2:-2] and words in message, message + assert "|" not in doc["markdown"] + + +def test_table_written_inline_still_becomes_a_table(catalog): + doc = compose_doc("见表 [[table:Q1 cols=region rows=0-1]] 如上。", catalog) + assert doc["violations"] == [] + assert doc["markdown"] == "见表 \n| region |\n| --- |\n| 华东 |\n| 华南 |\n 如上。" + assert [b["type"] for b in doc["blocks"]] == ["paragraph", "table", "paragraph"] + + +def test_generated_header_digits_are_not_bare_numbers(catalog): + doc = compose_doc("[[table:Q3]]", catalog) + assert doc["violations"] == [], doc["violations"] + # 同样的字写在正文里照样要出处 + doc = compose_doc("销售额2025 是多少", catalog) + assert [v["code"] for v in doc["violations"]] == ["uncited_number"] + + +# -------------------------------------------------------------------------- +# 复核 +# -------------------------------------------------------------------------- + + +def test_verify_doc_rechecks_cells_independently(catalog): + doc = compose_doc("华东 [[v:Q1.r0.amount]]。\n\n[[table:Q1 cols=amount rows=0-1]]", catalog) + again = verify_doc(copy.deepcopy(doc), catalog) + assert again["violations"] == doc["violations"] == [] and again["stats"] == doc["stats"] + forged = copy.deepcopy(doc) + seg = next(s for s in iter_segments(forged) if s.get("ref") == "v:Q1.r0.amount") + seg["cite"]["eid"] = cell_eid("0" * 64, 0, "amount") + assert "eid_mismatch" in [v["code"] for v in verify_doc(forged, catalog)["violations"]] + + +def test_cells_can_be_refused_by_the_checker(catalog): + """受管出具没声明 cells:复核按参数把每一个单元格引用(含整表里的格)判成解析不了。""" + doc = compose_doc("华东 [[v:Q1.r0.amount]],指标 [[m:gmv]]。\n\n[[table:Q1 cols=amount rows=0-1]]", catalog) + assert doc["violations"] == [] + checked = verify_doc(doc, catalog, cells_allowed=False) + refused = [v for v in checked["violations"] if v["code"] == "unresolved_ref"] + assert [v["ref"] for v in refused] == ["v:Q1.r0.amount", "v:Q1.r0.amount", "v:Q1.r1.amount"] + assert all(CELLS_REASON in v["message"] for v in refused) + assert resolve_ref("m:gmv", catalog, cells_allowed=False)["status"] == "resolved" + # 写的时候就不许:整表标记整个解析不了,不展开 + early = compose_doc("[[table:Q1 cols=amount rows=0-1]] 和 [[v:Q1.r0.amount]]", catalog, cells_allowed=False) + assert set(bad(early)) == {"table:Q1 cols=amount rows=0-1", "v:Q1.r0.amount"} + assert all(CELLS_REASON in m for m in bad(early).values()) + + +# -------------------------------------------------------------------------- +# 流式 = 整篇 +# -------------------------------------------------------------------------- + +STREAMED = [ + "各区域如下:\n\n[[table:Q1 cols=region,amount rows=0-2]]\n\n华东 [[v:Q1.r0.amount]] 最高。", + "见表 [[table:Q1 cols=region rows=0-1]] 如上。", + "[[table:Q1]]", + "开头[[table:Q1 rows=0-0]]", + "[[table:Q1 rows=0-0]]结尾", + "[[[table:Q1 rows=0-0]]]]", + "两张\n[[table:Q1 cols=region rows=0-1]][[table:Q3]]\n完", + "坏的 [[table:Q1 rows=0-30]] 和 [[v:Q1.r9.amount]],好的 [[v:Q1.r2.amount|万]]。", + "**加粗 [[v:Q1.r0.amount]]** 与 [[m:gmv]]", +] + + +def _chunks(text: str, rng: random.Random) -> list[str]: + out, i = [], 0 + while i < len(text): + step = rng.randint(1, 7) + out.append(text[i:i + step]) + i += step + return out + + +@pytest.mark.parametrize("text", STREAMED) +def test_streaming_equals_full_rendering(catalog, text): + full = render_markers(text, catalog) + rng = random.Random(len(text)) + for _ in range(60): + renderer = StreamRenderer(catalog) + streamed = "".join(renderer.feed(c) for c in _chunks(text, rng)) + renderer.flush() + assert streamed == full + # 一个字一个字地喂也一样 + renderer = StreamRenderer(catalog) + assert "".join(renderer.feed(c) for c in text) + renderer.flush() == full + # 不含粗体的,流里看到的就是最终文档的正文 + if "**" not in text: + assert compose_doc(text, catalog)["markdown"] == full.strip() + + +def test_streaming_with_cells_refused_matches_full(catalog): + text = STREAMED[0] + full = render_markers(text, catalog, cells_allowed=False) + renderer = StreamRenderer(catalog, cells_allowed=False) + assert "".join(renderer.feed(c) for c in text) + renderer.flush() == full + assert "⟦?table:Q1" in full and "⟦?v:Q1.r0.amount⟧" in full + + +# -------------------------------------------------------------------------- +# 性质:带单元格和整表的任意原文,片段铺满正文,组装和复核一致 +# -------------------------------------------------------------------------- + +INTEGRITY = {"segment_mismatch", "structural_text", "state_mismatch", "render_mismatch", "eid_mismatch", "bad_schema"} + + +def _corpus(n: int) -> list[str]: + rng = random.Random(20260928) + alphabet = [*"我们。!?\n- *#>|`[]:0123 .,", "[[v:Q1.r0.amount]]", "[[v:Q1.r1.note]]", "[[v:Q1.r9.x]]", + "[[table:Q1 cols=region rows=0-1]]", "[[table:Q3]]", "[[table:Q1 rows=0-40]]", "[[m:gmv]]", + "**", "\n\n", "| a |", "[[see:Q1]]"] + return ["".join(rng.choice(alphabet) for _ in range(rng.randint(1, 30))) for _ in range(n)] + + +@pytest.mark.parametrize("raw", _corpus(250)) +def test_composition_invariants_with_cells(catalog, raw): + doc = compose_doc(raw, catalog) + assert "".join(s["text"] for s in iter_segments(doc)) == doc["markdown"] + again = verify_doc(copy.deepcopy(doc), catalog) + assert again["violations"] == doc["violations"] and again["stats"] == doc["stats"] + assert not INTEGRITY & {v["code"] for v in doc["violations"]}, doc["violations"] + + +# -------------------------------------------------------------------------- +# 给写作者看的目录 +# -------------------------------------------------------------------------- + + +def test_catalog_prompt_shows_query_rows_and_how_to_cite(catalog): + prompt = catalog_prompt(catalog) + assert "Q1" in prompt and "region, amount, orders, note" in prompt + assert "[[v:Q1.r0.amount]]" in prompt and "[[table:Q1" in prompt + assert "r0" in prompt and "华东" in prompt and "18,230.5" in prompt + assert "Q2" in prompt and "取不回来" in prompt + refused = catalog_prompt(catalog, cells_allowed=False) + assert "[[v:" not in refused and "[[see:Q1]]" in refused + + +def test_cell_rules_only_show_when_there_are_cells_to_cite(catalog): + """写作规则本身还是一期那份:升级前发起的运行在升级后跑到报告节点,提示一字不差;只有口径卡的目录 + 也不该教写作者去引用不存在的 Q1。单元格和整表的写法跟着目录里的查询出现。""" + from app.engine.evidence import MARKER_RULES + + assert "[[v:" not in MARKER_RULES and "[[table:" not in MARKER_RULES and "Q1" not in MARKER_RULES + for kinds in (("metric", "input"), ("metric", "retrieval")): + partial = {a: e for a, e in catalog.items() if e.get("kind") in kinds} + prompt = catalog_prompt(partial) + MARKER_RULES + assert "[[v:" not in prompt and "[[table:" not in prompt, kinds + full = catalog_prompt(catalog) + assert "[[v:Q1.r0.列名]]" in full and "[[table:Q1 cols=列a,列b rows=0-4]]" in full + refused = catalog_prompt(catalog, cells_allowed=False) + assert "[[v:" not in refused and "[[table:" not in refused diff --git a/backend/tests/test_review.py b/backend/tests/test_review.py index d7a5e466..93938c0a 100644 --- a/backend/tests/test_review.py +++ b/backend/tests/test_review.py @@ -401,3 +401,14 @@ async def test_review_endpoint_keeps_answers_with_evidence(client, monkeypatch): assert model.calls == 1 assert body["verdict"] == "annotated" and body["answer"] is None assert body["note"] == "报告被要求重写过一次。" + + +def test_agent_field_warnings_are_degraded_signals(): + """agent 的结构化字段和快照对不上(以快照为准)、或者核对不了(记成空值):答案能用,但有缺口要说明。 + 按自己的 kind 记下,界面才说得清是哪一种,而不是一个笼统的「警告」。""" + signals = rv.scan([ + warn("字段 orders:模型报 30,快照是 3,已按快照取值", "agent_field_mismatch"), + warn("字段 region 核对不了出处,记为空值", "agent_field_unverified"), + ], OK_OUTPUT) + assert [(s.kind, s.severity) for s in signals] == [ + ("agent_field_mismatch", rv.DEGRADED), ("agent_field_unverified", rv.DEGRADED)] diff --git a/backend/tests/test_run_tool_precheck.py b/backend/tests/test_run_tool_precheck.py new file mode 100644 index 00000000..3a932e5b --- /dev/null +++ b/backend/tests/test_run_tool_precheck.py @@ -0,0 +1,139 @@ +"""发起运行前检查绑定的工具:本机不存在的,发起就拒绝(422),不要等跑到那一步才失败。 + +用户手上真实踩到的:图里的 agent 绑着一个已经删掉的数据源工具,运行跑了几分钟、花了几轮 +模型调用才在半路报错。查的方式和运行时 build_tools 的解析一致:内置、mcp:、 +db_query__ / db_schema__、自定义。MCP 连不上时不拦,只在日志里提示。 +""" +from __future__ import annotations + +import logging + +import pytest +from httpx import ASGITransport, AsyncClient +from sqlalchemy import delete, select + +from app.db.base import SessionLocal +from app.db.models import CustomTool, DataSource, McpServer, Workflow, WorkflowVersion +from app.engine.runner import run_manager +from app.main import app + + +@pytest.fixture(autouse=True) +async def engine_up(): + await run_manager.setup() + yield + await run_manager.shutdown() + + +@pytest.fixture +async def client(): + async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as c: + yield c + + +@pytest.fixture(autouse=True) +async def catalog(tmp_path): + """本机有:数据源 shop(启用)、archive(停用);自定义工具 lookup;MCP 服务 files(连得上)、 + flaky(上次没连上)、off(停用)。别的测试也会建同名的行,这里按名字补齐状态,不删别人的。""" + rows = [ + (DataSource, "shop", {"kind": "sqlite", "database": str(tmp_path / "shop.db"), "readonly": True, + "enabled": True}), + (DataSource, "archive", {"kind": "sqlite", "database": str(tmp_path / "a.db"), "readonly": True, + "enabled": False}), + (CustomTool, "lookup", {"kind": "http", "description": "查编码", "parameters": {}, "enabled": True}), + (McpServer, "files", {"transport": "stdio", "command": "true", "enabled": True, "status": "ok", + "tools_cache": ["read_file"]}), + (McpServer, "flaky", {"transport": "stdio", "command": "true", "enabled": True, "status": "error"}), + (McpServer, "off", {"transport": "stdio", "command": "true", "enabled": False}), + ] + async with SessionLocal() as session: + for model, name, fields in rows: + row = (await session.execute(select(model).where(model.name == name))).scalar_one_or_none() + if row is None: + row = model(name=name) + session.add(row) + for key, value in fields.items(): + setattr(row, key, value) + for model, name in ((DataSource, "nope"), (CustomTool, "ghost_tool"), (McpServer, "gone")): + await session.execute(delete(model).where(model.name == name)) + await session.commit() + yield + + +def node(nid, ntype, label=None, **config): + return {"id": nid, "type": ntype, "position": {"x": 0, "y": 0}, "data": {"label": label or nid, "config": config}} + + +def graph(*middle): + nodes = [node("start", "input"), *middle, node("out", "output", fields=[{"name": "r", "value": "{{ last_message }}"}])] + return {"nodes": nodes, "edges": [{"source": a["id"], "target": b["id"]} for a, b in zip(nodes, nodes[1:])]} + + +async def start(client, g): + return await client.post("/api/runs", json={"graph": g}) + + +async def test_a_missing_datasource_tool_is_refused_and_named(client): + res = await start(client, graph(node("fetch", "tool", "取数", tool="db_query__nope", args={"sql": "SELECT 1"}))) + assert res.status_code == 422, res.text + body = res.json() + assert body["code"] == "run_tool_missing" + assert "「取数」" in body["detail"] and "db_query__nope" in body["detail"] + assert "去数据页接入,或在节点里重新选" in body["detail"] + + +async def test_every_missing_binding_is_listed(client): + res = await start(client, graph( + node("bot", "agent", "分析", prompt="查", approval="never", + tools=["web_search", "db_schema__archive", "db_query__shop", "ghost_tool", "mcp:off/x"]), + node("team", "supervisor", "协作", goal="g", agents=[ + {"name": "研究员", "tools": ["mcp:gone/read"]}, {"name": "写手", "tools": ["lookup"]}]), + )) + assert res.status_code == 422, res.text + detail = res.json()["detail"] + # 停用的数据源、库里没有的自定义工具、停用的 MCP 服务、没登记的 MCP 服务都算不存在 + for name in ("db_schema__archive", "ghost_tool", "mcp:off/x", "mcp:gone/read"): + assert name in detail, (name, detail) + assert "「分析」" in detail and "「协作」的成员「研究员」" in detail + # 在的不点名 + for name in ("web_search", "db_query__shop", "lookup"): + assert name not in detail, (name, detail) + + +async def test_existing_tools_pass(client): + res = await start(client, graph( + node("fetch", "tool", "取数", tool="db_query__shop", args={"sql": "SELECT 1"}), + node("bot", "agent", "分析", prompt="查", approval="never", + tools=["web_search", "db_schema__shop", "lookup", "mcp:files/read_file", "mcp:files/*"]), + )) + assert res.status_code == 201, res.text + + +async def test_an_unreachable_mcp_server_is_not_blocked(client, caplog): + """服务登记着、只是上次没连上:可能只是暂时的,发起时不拦,日志里提一句。""" + with caplog.at_level(logging.WARNING, logger="app.api.runs"): + res = await start(client, graph(node("bot", "agent", "分析", prompt="查", approval="never", + tools=["mcp:flaky/search", "mcp:files/not_listed"]))) + assert res.status_code == 201, res.text + assert "flaky" in caplog.text and "not_listed" in caplog.text + + +async def test_formal_runs_are_checked_too(client): + g = graph(node("fetch", "tool", "取数", tool="db_query__nope", args={"sql": "SELECT 1"})) + async with SessionLocal() as session: + wf = Workflow(name="周报", graph=g, status="published", published_version=1) + session.add(wf) + await session.flush() + session.add(WorkflowVersion(workflow_id=wf.id, version=1, graph=g)) + await session.commit() + wf_id = wf.id + res = await client.post("/api/runs", json={"workflow_id": wf_id, "run_class": "formal"}) + assert res.status_code == 422 and "db_query__nope" in res.json()["detail"] + # 保存过的工作流发起探索运行也查 + res = await client.post("/api/runs", json={"workflow_id": wf_id}) + assert res.status_code == 422 + + +async def test_graphs_without_tools_are_untouched(client): + res = await start(client, graph(node("fmt", "transform", mode="expression", expression="1"))) + assert res.status_code == 201, res.text diff --git a/backend/tests/test_segment_missing_input.py b/backend/tests/test_segment_missing_input.py new file mode 100644 index 00000000..0930e0ec --- /dev/null +++ b/backend/tests/test_segment_missing_input.py @@ -0,0 +1,143 @@ +"""缺输入的指标也给出链:报告里引用的指标存在、只是这次没有值(missing_input),片段显示「—」。 + +以前片段接口只给确定性片段出链,这种片段点开只剩一句「缺输入」:说不出是哪个输入空了、 +那一格在哪次查询里。现在照样返回指标步骤、它的输入步骤和查询步骤,面板能指着那一格说 +「没查到,记为空,没有兜底成 0」。引用本身写错(目录里没有这个指标)的,照旧没有链。 +""" +from __future__ import annotations + +import asyncio +import json +import sqlite3 + +import pytest +from httpx import ASGITransport, AsyncClient +from langchain_core.messages import AIMessage +from sqlalchemy import select + +from app.core.config import settings +from app.data.engine import engines +from app.db.base import SessionLocal +from app.db.models import DataSource, Run, RunEvent +from app.engine.evidence import iter_segments +from app.engine.runner import run_manager +from app.main import app + +SQL = "SELECT SUM(amount) AS gmv, COUNT(*) AS orders, NULL AS refunds FROM orders" +TEXT = "销售额 [[m:gmv]],退款率 [[m:refund_rate]],新客 [[m:nope]]。" + + +@pytest.fixture(autouse=True) +async def engine_up(): + await run_manager.setup() + yield + await run_manager.shutdown() + + +@pytest.fixture +async def client(): + async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as c: + yield c + + +@pytest.fixture(autouse=True) +async def shop(tmp_path): + path = tmp_path / "shop.db" + db = sqlite3.connect(path) + db.executescript("CREATE TABLE orders (id INTEGER PRIMARY KEY, amount REAL);" + "INSERT INTO orders VALUES (1, 100.5), (2, 200.0);") + db.commit() + db.close() + async with SessionLocal() as session: + row = (await session.execute(select(DataSource).where(DataSource.name == "shop"))).scalar_one_or_none() + if row is None: + row = DataSource(name="shop", kind="sqlite", database=str(path), readonly=True, enabled=True) + session.add(row) + row.database, row.kind, row.enabled, row.readonly, row.options = str(path), "sqlite", True, True, {} + await session.commit() + source_id = row.id + await engines.invalidate(source_id) + yield + await engines.invalidate(source_id) + + +def node(nid, ntype, **config): + return {"id": nid, "type": ntype, "position": {"x": 0, "y": 0}, "data": {"label": nid, "config": config}} + + +def weekly() -> dict: + nodes = [ + node("start", "input"), + node("fetch", "tool", tool="db_query__shop", args={"sql": SQL}), + # 缺输入记为空值,交给契约判档;不整张卡失败 + node("card", "metrics", caliber="周报口径", on_missing="null", metrics=[ + {"id": "gmv", "name": "销售额", "unit": "元", "expression": "cell(nodes.fetch, 0, 'gmv')"}, + {"id": "refund_rate", "name": "退款率", "format": "percent_of_ratio", "decimals": 4, + "expression": "cell(nodes.fetch, 0, 'refunds') / cell(nodes.fetch, 0, 'orders')"}]), + node("write", "report", instructions="写周报", on_violation="flag"), + node("out", "output", fields=[{"name": "周报", "value": "{{ nodes.write.text }}"}]), + ] + return {"nodes": nodes, "edges": [{"source": a["id"], "target": b["id"]} for a, b in zip(nodes, nodes[1:])]} + + +async def run_weekly(monkeypatch) -> tuple[Run, dict]: + from app.providers import mock_model + + monkeypatch.setattr(mock_model.MockChatModel, "_decide", lambda self, messages: AIMessage(content=TEXT)) + run = await run_manager.start(graph=weekly(), input_payload={}) + for _ in range(400): + await asyncio.sleep(0.05) + async with SessionLocal() as session: + row = await session.get(Run, run.id) + if row.status in ("succeeded", "failed") and row.manifest_seq is not None: + break + assert row.status == "succeeded", row.error + doc_id = row.output["_evidence"]["doc_artifact"] + doc = json.loads((settings.data_dir / "artifacts" / doc_id[:2] / f"{doc_id}.json").read_text("utf-8")) + return row, doc + + +def seg_by_ref(doc: dict, ref: str) -> dict: + return next(s for s in iter_segments(doc) if s.get("ref") == ref) + + +async def segment(client, run_id: str, seg_id: str) -> dict: + resp = await client.get(f"/api/runs/{run_id}/evidence/segments/{seg_id}") + assert resp.status_code == 200, resp.text + return resp.json() + + +async def test_a_metric_missing_its_input_still_opens_its_chain(client, monkeypatch): + row, doc = await run_weekly(monkeypatch) + seg = seg_by_ref(doc, "m:refund_rate") + assert seg["text"] == "—" and seg["state"] != "deterministic" and seg["cite"]["status"] == "unresolved" + body = await segment(client, row.id, seg["id"]) + metric, refunds, orders, query = body["chain"] + assert metric["step"] == "metric" and metric["alias"] == "m:refund_rate" and metric["metric"] == "refund_rate" + assert metric["status"] == "missing_input" and metric["value"] is None and metric["rendered"] == "—" + assert metric["eid_ok"] is True and metric["hash_ok"] is True and metric["sealed"] is True + assert refunds["step"] == "input" and refunds["via"] == "tool_cell" and refunds["value"] is None + assert refunds["locator"] == {"row": 0, "column": "refunds"} and refunds["cell"] == "Q1.r0.refunds" + assert orders["value"] == 2 + async with SessionLocal() as session: + [end] = (await session.execute(select(RunEvent).where( + RunEvent.run_id == row.id, RunEvent.type == "tool.end", RunEvent.node_id == "fetch"))).scalars() + assert query["step"] == "query" and query["artifact"] == end.data["query_artifact"] + assert query["highlight"]["cells"] == [[0, "refunds"], [0, "orders"]] + assert query["rows"] == [[300.5, 2, None]] + # 片段本身的说法不变:引用解析不了,缺输入 + assert "缺输入" in body["note"] and body["segment"]["cite"]["status"] == "unresolved" + + +async def test_a_wrong_reference_still_has_no_chain(client, monkeypatch): + row, doc = await run_weekly(monkeypatch) + body = await segment(client, row.id, seg_by_ref(doc, "m:nope")["id"]) + assert body["chain"] == [] and "目录里没有指标" in body["note"] + + +async def test_resolved_metrics_are_unchanged(client, monkeypatch): + row, doc = await run_weekly(monkeypatch) + body = await segment(client, row.id, seg_by_ref(doc, "m:gmv")["id"]) + metric, source, query = body["chain"] + assert metric["status"] == "ok" and metric["render_ok"] is True and source["value"] == 300.5 + assert query["highlight"]["cells"] == [[0, "gmv"]] diff --git a/backend/tests/test_text_columns.py b/backend/tests/test_text_columns.py new file mode 100644 index 00000000..a5eb2440 --- /dev/null +++ b/backend/tests/test_text_columns.py @@ -0,0 +1,190 @@ +"""文本列按文本渲染:查询快照带着 column_types(查询时从驱动的原始值记下的)。 + +以前快照里没有列类型,VARCHAR 的 "2026" 和小数位为 0 的 DECIMAL "45678" 长得一模一样,只能 +「像数字就当数字」:报告里 [[v:Q1.r0.week]] 渲染成「2,026」。现在 text 列一律按文本: +render_cell、[[v:]] 的渲染和取值、写作目录、cite_fields 没声明类型的字段。拿不到 column_types +的老快照保持原来的启发式。 + +口径卡表达式里的 cell() 照旧按值猜:那是在算数,数字写成文本的列(CAST 成文本、VARCHAR 存金额) +多半就是要拿来算的;要文本写 str(cell(...))。 +""" +from __future__ import annotations + +import asyncio +import sqlite3 + +import pytest +from langchain_core.messages import AIMessage +from sqlalchemy import select + +from app.core import artifact_store +from app.data.engine import engines +from app.db.base import SessionLocal +from app.db.models import DataSource, Run +from app.engine.evidence import ( + RenderError, + build_catalog, + catalog_prompt, + compose_doc, + iter_segments, + render_cell, + verify_doc, +) +from app.engine.expressions import cell_value, column_kind, eval_expression +from app.engine.runner import run_manager + +BASE = {"columns": ["week", "gmv", "code"], "rows": [["2026", "45678", "10001"], ["2027", "12.50", "10002"]], + "row_count": 2, "truncated": False, "elapsed_ms": 1, "sql": "SELECT week, gmv, code FROM orders", + "source": "shop"} +TYPED = {**BASE, "column_types": {"week": "text", "gmv": "number", "code": "text"}} + + +def _store(snap: dict) -> str: + text = artifact_store.canonical_json(snap) + artifact = artifact_store.content_hash(text) + path = artifact_store._path_of(artifact) + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(text, encoding="utf-8") + return artifact + + +def catalog_of(snap: dict) -> dict: + artifact = _store(snap) + return build_catalog(nodes={}, ledger=[{ + "kind": "query", "node_id": "fetch", "exec": 1, "artifact": artifact, "via": "b" * 64, "tool": "db_query__shop", + "source": "shop", "columns": snap["columns"], "rows": len(snap["rows"]), "truncated": False}], inputs={}) + + +def test_cell_value_keeps_text_columns_as_text(): + assert cell_value("2026", "text") == "2026" + assert cell_value("45678", "number") == 45678 + assert cell_value("2026") == 2026 # 老快照:照旧按值猜 + assert cell_value("2026", None) == 2026 + assert column_kind(TYPED, "week") == "text" and column_kind(TYPED, "gmv") == "number" + assert column_kind(BASE, "week") is None and column_kind(TYPED, "nope") is None + assert column_kind({"column_types": "bad"}, "week") is None + + +def test_render_cell_reads_the_column_type(): + assert render_cell("2026", kind="text") == "2026" + assert render_cell("10001", kind="text") == "10001" + assert render_cell("45678", kind="number") == "45,678" + assert render_cell("2026") == "2,026" + with pytest.raises(RenderError): + render_cell("2026", "万", kind="text") + + +def test_cell_refs_render_text_columns_as_text(): + doc = compose_doc("[[v:Q1.r0.week]] 周销售额 [[v:Q1.r0.gmv]],编号 [[v:Q1.r1.code]]。", catalog_of(TYPED)) + assert doc["markdown"] == "2026 周销售额 45,678,编号 10002。" + week, gmv, code = [s for s in iter_segments(doc) if s.get("ref")] + assert week["kind"] == "value" and week["cite"]["value"] == "2026" + assert gmv["kind"] == "number" and gmv["cite"]["value"] == 45678 + assert code["kind"] == "value" and code["cite"]["value"] == "10002" + assert doc["violations"] == [] + assert verify_doc(doc, catalog_of(TYPED))["violations"] == [] + + +def test_old_snapshots_keep_the_heuristic(): + doc = compose_doc("[[v:Q1.r0.week]] 周", catalog_of(BASE)) + assert doc["markdown"] == "2,026 周" + + +def test_a_text_column_cannot_be_converted(): + doc = compose_doc("[[v:Q1.r0.week|万]]", catalog_of(TYPED)) + [bad] = [v for v in doc["violations"] if v["code"] == "unresolved_ref"] + assert "不是数" in bad["message"] + + +def test_tables_render_text_columns_as_text(): + doc = compose_doc("[[table:Q1 cols=week,gmv]]", catalog_of(TYPED)) + assert "| 2026 | 45,678 |" in doc["markdown"] and "| 2027 | 12.5 |" in doc["markdown"] + + +def test_the_writing_catalog_shows_what_the_report_will_show(): + prompt = catalog_prompt(catalog_of(TYPED)) + assert "week=2026,gmv=45,678,code=10001" in prompt + # 示例挑第一个数:文本列不算 + assert "例:[[v:Q1.r0.gmv]]" in prompt + assert "例:[[v:Q1.r0.week]]" in catalog_prompt(catalog_of(BASE)) + + +def test_cite_fields_without_a_declared_type_follow_the_column(): + from app.engine.nodes.llm import cited_fields, verify_cited_fields + + queries = [{"alias": "Q1", "artifact": "a" * 64, "via": "b" * 64}] + parsed = {"week": {"value": "2026", "from": {"call": "Q1", "row": 0, "column": "week"}}, + "gmv": {"value": 45678, "from": {"call": "Q1", "row": 0, "column": "gmv"}}, + "weeks": {"value": ["2026", "2027"], "from": {"call": "Q1", "rows": "0-1", "column": "week"}}} + schema = {"type": "object", "properties": {"week": {}, "gmv": {}, "weeks": {"type": "array", "items": {}}}} + data, ev = verify_cited_fields({"parsed": parsed}, cited_fields(schema), queries, loader=lambda a: TYPED) + assert data == {"week": "2026", "gmv": 45678, "weeks": ["2026", "2027"]} + assert {e["status"] for e in ev.values()} == {"verified"} + # 声明了类型的照声明:写 schema 的人说了它是数 + declared = {"type": "object", "properties": {"week": {"type": "integer"}, "gmv": {}, "weeks": { + "type": "array", "items": {"type": "number"}}}} + data, _ = verify_cited_fields({"parsed": parsed}, cited_fields(declared), queries, loader=lambda a: TYPED) + assert data == {"week": 2026, "gmv": 45678, "weeks": [2026, 2027]} + # 老快照照旧按值猜 + data, _ = verify_cited_fields({"parsed": parsed}, cited_fields(schema), queries, loader=lambda a: BASE) + assert data["week"] == 2026 + + +def test_card_expressions_keep_computing_on_number_like_text(): + assert eval_expression("cell(vars.q, 0, 'week') + 1", {"vars": {"q": TYPED}}) == 2027 + assert eval_expression("str(cell(vars.q, 0, 'week'))", {"vars": {"q": TYPED}}) == "2026" + + +# -------------------------------------------------------------------------- +# 端到端:列类型从驱动一路带到报告 +# -------------------------------------------------------------------------- + + +@pytest.fixture +async def shop(tmp_path): + await run_manager.setup() + path = tmp_path / "shop.db" + db = sqlite3.connect(path) + db.executescript("CREATE TABLE orders (id INTEGER PRIMARY KEY, week TEXT, amount REAL);") + db.executemany("INSERT INTO orders VALUES (?, ?, ?)", [(1, "2026", 100.5), (2, "2026", 200.0)]) + db.commit() + db.close() + async with SessionLocal() as session: + row = (await session.execute(select(DataSource).where(DataSource.name == "shop"))).scalar_one_or_none() + if row is None: + row = DataSource(name="shop", kind="sqlite", database=str(path), readonly=True, enabled=True) + session.add(row) + else: + row.database, row.kind, row.enabled, row.readonly, row.options = str(path), "sqlite", True, True, {} + await session.commit() + source_id = row.id + await engines.invalidate(source_id) + yield + await engines.invalidate(source_id) + await run_manager.shutdown() + + +async def test_a_report_quotes_a_text_year_as_written(shop, monkeypatch): + from app.providers import mock_model + + monkeypatch.setattr(mock_model.MockChatModel, "_decide", + lambda model, messages: AIMessage(content="[[v:Q1.r0.week]] 年销售额 [[v:Q1.r0.gmv]]。")) + + def node(nid, ntype, **config): + return {"id": nid, "type": ntype, "position": {"x": 0, "y": 0}, "data": {"label": nid, "config": config}} + + nodes = [node("start", "input"), + node("fetch", "tool", tool="db_query__shop", + args={"sql": "SELECT week, SUM(amount) AS gmv FROM orders GROUP BY week"}), + node("write", "report", instructions="写一句话", on_violation="flag"), + node("out", "output", fields=[{"name": "答案", "value": "{{ nodes.write.text }}"}])] + graph = {"nodes": nodes, "edges": [{"source": a["id"], "target": b["id"]} for a, b in zip(nodes, nodes[1:])]} + run = await run_manager.start(graph=graph, input_payload={}) + for _ in range(400): + await asyncio.sleep(0.05) + async with SessionLocal() as session: + row = await session.get(Run, run.id) + if row.status in ("succeeded", "failed"): + break + assert row.status == "succeeded", row.error + assert row.output["答案"] == "2026 年销售额 300.5。" diff --git a/backend/tests/test_tool_evidence.py b/backend/tests/test_tool_evidence.py new file mode 100644 index 00000000..c122f891 --- /dev/null +++ b/backend/tests/test_tool_evidence.py @@ -0,0 +1,488 @@ +"""取数链路进证据台账:tool 节点、agent、知识检索都记账,口径卡能精确到单元格。 + +一期的台账里只有口径卡的指标集:报告点开一个数,最远追到「vars.q.rows[0][0]」这条 +路径,追不到那次查询的哪一格。二期: + +- tool 节点落 tool 条目,查库再落 query 条目(工件是查询快照,外面包着工具快照) +- agent 每次查库都记账,给模型看的工具结果前面标上本节点内的编号「【证据 Q1】」 +- 知识检索落 retrieval 条目 +- 报告目录按全局台账顺序编出 Q1… / K1… +- 口径卡用 cell(nodes.fetch, 0, "amount") 取数时,来历精确到那一格(via tool_cell) +- 数据源工具返回「查询失败:…」「SQL 被拒绝:…」时,tool 节点判失败,报错写原话 +""" +from __future__ import annotations + +import asyncio +import json +import sqlite3 + +import pytest +from langchain_core.messages import AIMessage, ToolMessage +from sqlalchemy import select + +from app.core import artifact_store +from app.data.engine import engines +from app.db.base import SessionLocal +from app.db.models import DataSource, Run, RunEvent +from app.engine.evidence import build_catalog, make_eid +from app.engine.expressions import ( + CellError, + ExpressionError, + eval_expression, + leaf_refs, + parse_expression, + substitute, + table_cell, +) +from app.engine.runner import run_manager + +TOOL = "db_query__shop" +SQL = "SELECT SUM(amount) AS gmv, SUM(prev) AS gmv_prev, COUNT(*) AS orders FROM orders WHERE week = '2026-W37'" + + +@pytest.fixture +async def engine_up(): + await run_manager.setup() + yield + await run_manager.shutdown() + + +@pytest.fixture +async def shop(tmp_path): + """示例 shop 库:本周 3 单,金额 100.5 + 200 + 300。""" + path = tmp_path / "shop.db" + db = sqlite3.connect(path) + db.executescript("CREATE TABLE orders (id INTEGER PRIMARY KEY, week TEXT, amount REAL, prev REAL, refunded INTEGER);") + db.executemany("INSERT INTO orders VALUES (?, ?, ?, ?, ?)", [ + (1, "2026-W37", 100.5, 90.0, 0), (2, "2026-W37", 200.0, 100.0, 1), (3, "2026-W37", 300.0, 110.0, 0), + (4, "2026-W36", 50.0, 40.0, 0)]) + db.commit() + db.close() + async with SessionLocal() as session: + row = (await session.execute(select(DataSource).where(DataSource.name == "shop"))).scalar_one_or_none() + if row is None: + row = DataSource(name="shop", kind="sqlite", database=str(path), readonly=True, enabled=True) + session.add(row) + else: + row.database, row.kind, row.enabled, row.readonly = str(path), "sqlite", True, True + await session.commit() + source_id = row.id + # 连接池按数据源 id 缓存:换了库文件不作废,查的还是上一个测试的库 + await engines.invalidate(source_id) + yield + await engines.invalidate(source_id) + + +def node(nid, ntype, **config): + return {"id": nid, "type": ntype, "position": {"x": 0, "y": 0}, "data": {"label": nid, "config": config}} + + +def chain(*nodes): + return {"nodes": list(nodes), "edges": [{"source": a["id"], "target": b["id"]} for a, b in zip(nodes, nodes[1:])]} + + +async def finish(graph, statuses=("succeeded", "failed")) -> Run: + run = await run_manager.start(graph=graph, input_payload={}) + for _ in range(400): + await asyncio.sleep(0.05) + async with SessionLocal() as session: + row = await session.get(Run, run.id) + if row.status in statuses and (row.status == "interrupted" or row.manifest_seq is not None): + return row + raise AssertionError(f"run {run.id} 没跑完:{row.status}") + + +async def events(run_id: str, etype: str | None = None, node_id: str | None = None) -> list[RunEvent]: + async with SessionLocal() as session: + q = select(RunEvent).where(RunEvent.run_id == run_id) + if etype: + q = q.where(RunEvent.type == etype) + if node_id: + q = q.where(RunEvent.node_id == node_id) + return list((await session.execute(q.order_by(RunEvent.seq))).scalars()) + + +async def ledger(run_id: str) -> list[dict]: + tup = await run_manager.checkpointer.aget_tuple({"configurable": {"thread_id": run_id}}) + return list(tup.checkpoint["channel_values"].get("evidence") or []) + + +# -------------------------------------------------------------------------- +# cell():纯函数 +# -------------------------------------------------------------------------- + +PAYLOAD = {"columns": ["gmv", "orders"], "rows": [[600.5, 3], ["45678.50", 7]], "artifact": "a" * 64} + + +def test_cell_reads_json_text_and_objects(): + text = json.dumps(PAYLOAD) + assert table_cell(text, 0, "gmv") == 600.5 + assert table_cell(PAYLOAD, 0, 1) == 3 # 列号也认 + assert table_cell(PAYLOAD, 1, "gmv") == 45678.5 # DECIMAL 字符串能参与运算 + assert table_cell({"rows": [{"gmv": 1}]}, 0, "gmv") == 1 + assert eval_expression("cell(nodes.q, 0, 'gmv') * 2", {"nodes": {"q": text}}) == 1201.0 + + +@pytest.mark.parametrize("args, words", [ + ((PAYLOAD, 2, "gmv"), "只有 2 行"), + ((PAYLOAD, 0, "amount"), "没有列「amount」"), + ((PAYLOAD, 0, 5), "只有 2 列"), + ((PAYLOAD, -1, "gmv"), "从 0 数"), + ((PAYLOAD, True, "gmv"), "整数"), + (("查询失败:no such table: orders", 0, "gmv"), "查询失败:no such table: orders"), + (({"columns": ["gmv"], "rows": []}, 0, "gmv"), "0 行"), + ((None, 0, "gmv"), "查询结果"), +]) +def test_cell_errors_are_readable(args, words): + with pytest.raises(CellError) as info: + table_cell(*args) + assert words in str(info.value) and isinstance(info.value, ExpressionError) + assert str(info.value).startswith("cell()") + + +# 驱动把 DECIMAL 一律落成 str(Decimal):小数位为 0 的——MySQL 对整数列 SUM()(包括 +# SUM(CASE WHEN … THEN 1 ELSE 0 END) 这种计数写法)、Postgres 的 SUM(bigint)——是没有小数点的 +# "45678";很小的数写成 "1E-7" +INT_DECIMAL = {"columns": ["gmv", "refunds", "code", "tiny"], "rows": [["45678", "29", "00123", "1E-7"]], + "artifact": "a" * 64} + + +def test_cell_treats_integer_decimal_text_as_numbers(): + gmv = table_cell(INT_DECIMAL, 0, "gmv") + assert gmv == 45678 and isinstance(gmv, int) + assert table_cell(INT_DECIMAL, 0, "refunds") == 29 + assert table_cell(INT_DECIMAL, 0, "tiny") == 1e-7 + assert table_cell(INT_DECIMAL, 0, "code") == "00123" # 带前导零的是编号,照文本 + ctx = {"nodes": {"q": json.dumps(INT_DECIMAL)}} + assert eval_expression("cell(nodes.q, 0, 'gmv') / 2", ctx) == 22839.0 + assert eval_expression("cell(nodes.q, 0, 'refunds') + 1", ctx) == 30 + + +def test_leaf_refs_treat_a_cell_call_as_one_input(): + tree = parse_expression("round(cell(nodes.fetch, 0, 'gmv') / cell(nodes.fetch, vars.i, 'orders'), 2)")[0] + assert leaf_refs(tree) == ["cell(nodes.fetch, 0, 'gmv')", "cell(nodes.fetch, vars.i, 'orders')", "vars.i"] + text = substitute(tree, {"cell(nodes.fetch, 0, 'gmv')": 600.5, "cell(nodes.fetch, vars.i, 'orders')": 3, + "vars.i": 0}) + assert text == "round(600.5 / 3, 2)" + + +# -------------------------------------------------------------------------- +# tool 节点 +# -------------------------------------------------------------------------- + + +def fetch_graph(*after, **tool_cfg): + return chain( + node("start", "input"), + node("fetch", "tool", tool=TOOL, args={"sql": SQL}, **tool_cfg), + *after, + node("out", "output", fields=[{"name": "r", "value": "{{ nodes.fetch }}"}]), + ) + + +async def test_tool_node_writes_tool_and_query_entries(engine_up, shop): + row = await finish(fetch_graph()) + assert row.status == "succeeded", row.error + [end] = await events(row.id, "tool.end", "fetch") + payload = json.loads(row.output["r"]) + assert end.data["query_artifact"] == payload["artifact"] + [finished] = await events(row.id, "node.finished", "fetch") + tool_entry, query_entry = finished.data["evidence"] + assert tool_entry == {"kind": "tool", "node_id": "fetch", "exec": 1, "artifact": end.data["artifact"], "tool": TOOL} + assert query_entry == {"kind": "query", "node_id": "fetch", "exec": 1, "artifact": payload["artifact"], + "via": end.data["artifact"], "tool": TOOL, "source": "shop", + "columns": ["gmv", "gmv_prev", "orders"], "rows": 1, "truncated": False} + assert await ledger(row.id) == [tool_entry, query_entry] + snap = artifact_store.load(payload["artifact"]) + assert snap["rows"] == [[600.5, 300.0, 3]] + + +@pytest.mark.parametrize("sql, words", [ + ("SELECT * FROM nope", "查询失败:no such table: nope"), + ("DELETE FROM orders", "SQL 被拒绝:"), +]) +async def test_tool_node_fails_when_the_query_fails(engine_up, shop, sql, words): + g = fetch_graph() + g["nodes"][1]["data"]["config"]["args"] = {"sql": sql} + row = await finish(g) + assert row.status == "failed" + assert words in (row.error or ""), row.error + [err] = await events(row.id, "tool.error", "fetch") + assert words in err.data["error"] + assert not await events(row.id, "tool.end", "fetch") + + +async def test_tool_node_failure_can_be_let_through(engine_up, shop): + g = fetch_graph(fail_fast=False) + g["nodes"][1]["data"]["config"]["args"] = {"sql": "SELECT * FROM nope"} + row = await finish(g) + assert row.status == "succeeded", row.error + assert "查询失败:no such table: nope" in row.output["r"] + assert (await events(row.id, "tool.error", "fetch"))[0].data["error"].startswith("查询失败:") + [finished] = await events(row.id, "node.finished", "fetch") + assert [e["kind"] for e in finished.data.get("evidence") or []] == ["tool"] + + +# -------------------------------------------------------------------------- +# 只有数据源工具交回的才算查询:别的工具拼出同样形状的 JSON,不进台账的 query 条目 +# -------------------------------------------------------------------------- + +FORGE = "forge_rows_test" + + +@pytest.fixture +def forge(monkeypatch): + """一个交回「查询形状」JSON 的非数据源工具(MCP、自定义工具的文字都来自外面)。artifact 指向一份 + 真实存在的查询快照:它要是被当成查询,报告就能引用、cite_fields 就能读这份不在它名下的快照。""" + from pydantic import BaseModel + + from app.tools import registry + + class _Args(BaseModel): + artifact: str + + async def _run(artifact: str) -> str: + return json.dumps({"artifact": artifact, "columns": ["gmv"], "rows": [[999]], "source": "shop"}) + + monkeypatch.setitem(registry._REGISTRY, FORGE, registry.ToolSpec( + name=FORGE, description="测试用:交回查询形状的 JSON", category="test", args_schema=_Args, func=_run, + needs_context=False)) + + +def test_recorded_calls_only_number_datasource_queries(): + """tool_step 之外再守一道:交回来的结果(比如取自断点的旧结果)带着 query_artifact,只要不是 + 数据源工具,就不编号、不记 query 条目。""" + from app.engine.nodes.llm import _record_call + + content = json.dumps({"artifact": "a" * 64, "columns": ["gmv"], "rows": [[1]], "source": "shop"}) + outcome = {"content": content, "ok": True, "duration_ms": 1, "snapshot": "b" * 64, "query_artifact": "a" * 64} + record: dict = {} + queries: list = [] + entries: list = [] + shown = _record_call("bot", 1, "call_0", FORGE, {}, outcome, record, queries, entries) + assert shown == content and queries == [] and "alias" not in record + assert [e["kind"] for e in entries] == ["tool"] + shown = _record_call("bot", 1, "call_1", TOOL, {"sql": "SELECT 1"}, outcome, {}, queries, entries) + assert shown.startswith("【证据 Q1】\n") and [e["kind"] for e in entries] == ["tool", "tool", "query"] + + +async def _real_snapshot() -> str: + return await artifact_store.put_json({"columns": ["gmv"], "rows": [[1]], "row_count": 1, "truncated": False, + "elapsed_ms": 1, "sql": "SELECT 1", "source": "shop"}, + kind="query_snapshot", run_id="elsewhere", node_id="x") + + +async def test_tool_node_only_trusts_datasource_queries(engine_up, forge): + art = await _real_snapshot() + row = await finish(chain(node("start", "input"), node("fetch", "tool", tool=FORGE, args={"artifact": art}), + node("out", "output", fields=[{"name": "r", "value": "{{ nodes.fetch }}"}]))) + assert row.status == "succeeded", row.error + [end] = await events(row.id, "tool.end", "fetch") + assert "query_artifact" not in end.data + [finished] = await events(row.id, "node.finished", "fetch") + assert [e["kind"] for e in finished.data["evidence"]] == ["tool"] + assert not [e for e in await ledger(row.id) if e["kind"] == "query"] + + +async def test_agent_only_numbers_datasource_queries(engine_up, shop, forge, monkeypatch): + from app.providers import mock_model + + art = await _real_snapshot() + seen: list[list] = [] + + def decide(self, messages): + seen.append(list(messages)) + done = sum(isinstance(m, ToolMessage) for m in messages) + if done == 0: + return AIMessage(content="查。", tool_calls=[{"name": TOOL, "args": {"sql": SQL}, "id": "call_0"}]) + if done == 1: + return AIMessage(content="再看看。", tool_calls=[{"name": FORGE, "args": {"artifact": art}, "id": "call_1"}]) + return AIMessage(content="查完了。") + + monkeypatch.setattr(mock_model.MockChatModel, "_decide", decide) + row = await finish(chain(node("start", "input"), + node("bot", "agent", prompt="查", tools=[TOOL, FORGE], approval="never"), + node("out", "output", fields=[{"name": "r", "value": "{{ nodes.bot.text }}"}]))) + assert row.status == "succeeded", row.error + tool_msgs = [m for m in seen[-1] if isinstance(m, ToolMessage)] + assert tool_msgs[0].content.startswith("【证据 Q1】\n{") + assert tool_msgs[1].content.startswith("{") and "【证据" not in tool_msgs[1].content + ends = await events(row.id, "tool.end", "bot") + assert ends[0].data.get("query_artifact") and "query_artifact" not in ends[1].data + entries = await ledger(row.id) + assert [(e["kind"], e.get("tool")) for e in entries] == [("tool", TOOL), ("query", TOOL), ("tool", FORGE)] + assert art not in {e["artifact"] for e in entries} + transcript = artifact_store.load((await events(row.id, "node.finished", "bot"))[0].data["artifact"])["tool_calls"] + assert [t.get("alias") for t in transcript] == ["Q1", None] + + +# -------------------------------------------------------------------------- +# 口径卡的来历精确到单元格 +# -------------------------------------------------------------------------- + + +async def test_cell_lineage_points_at_the_exact_cell(engine_up, shop): + card = node("card", "metrics", caliber="周报口径", metrics=[ + {"id": "gmv", "name": "销售额", "unit": "元", "decimals": 2, "format": "thousands", + "expression": "cell(nodes.fetch, 0, 'gmv')"}, + {"id": "wow", "name": "环比", "decimals": 1, "format": "plain", + "expression": "round((cell(nodes.fetch, 0, 'gmv') - cell(vars.q, 0, 1)) / cell(vars.q, 0, 1) * 100, 1)"}, + ]) + parse = node("parse", "transform", mode="json", template="{{ nodes.fetch }}", assign_to="q") + row = await finish(fetch_graph(parse, card)) + assert row.status == "succeeded", row.error + query = next(e for e in await ledger(row.id) if e["kind"] == "query") + finished = (await events(row.id, "node.finished", "card"))[0] + metrics = {m["id"]: m for m in artifact_store.load(finished.data["artifact"])["metrics"]} + + gmv = metrics["gmv"] + assert gmv["value"] == 600.5 and gmv["substituted"] == "600.5" and gmv["recompute_ok"] is True + [cell] = gmv["inputs"] + assert cell == {"path": "cell(nodes.fetch, 0, 'gmv')", "value": 600.5, "node_id": "fetch", "via": "tool_cell", + "status": "verified", "locator": {"row": 0, "column": "gmv"}, "artifact": query["artifact"], + "eid": make_eid("cell", query["artifact"], {"row": 0, "column": "gmv"})} + + # 经 transform 解析过的对象照样认得出是哪次查询;列号换成列名,eid 和按列名写的一致 + prev = next(i for i in metrics["wow"]["inputs"] if i["path"] == "cell(vars.q, 0, 1)") + assert prev["via"] == "tool_cell" and prev["node_id"] == "fetch" and prev["status"] == "verified" + assert prev["locator"] == {"row": 0, "column": "gmv_prev"} + assert prev["eid"] == make_eid("cell", query["artifact"], {"row": 0, "column": "gmv_prev"}) + + +async def test_card_computes_on_integer_decimal_text(engine_up, shop): + """sqlite 没有 DECIMAL:CAST(… AS TEXT) 落进快照的,和 MySQL 整数列 SUM() 的 str(Decimal) 一模一样。""" + sql = ("SELECT CAST(SUM(refunded) AS TEXT) AS refunds, CAST(COUNT(*) AS TEXT) AS orders " + "FROM orders WHERE week = '2026-W37'") + card = node("card", "metrics", caliber="周报口径", metrics=[ + {"id": "rate", "name": "退款率", "decimals": 1, "format": "plain", + "expression": "cell(nodes.fetch, 0, 'refunds') / cell(nodes.fetch, 0, 'orders') * 100"}]) + g = fetch_graph(card) + g["nodes"][1]["data"]["config"]["args"] = {"sql": sql} + row = await finish(g) + assert row.status == "succeeded", row.error + assert json.loads(row.output["r"])["rows"] == [["1", "3"]] + finished = (await events(row.id, "node.finished", "card"))[0] + [rate] = artifact_store.load(finished.data["artifact"])["metrics"] + assert rate["value"] == pytest.approx(33.3) and rate["recompute_ok"] is True + assert {i["status"] for i in rate["inputs"]} == {"verified"} + assert [i["value"] for i in rate["inputs"]] == [1, 3] + + +async def test_cell_out_of_range_fails_the_card_readably(engine_up, shop): + card = node("card", "metrics", metrics=[{"id": "x", "expression": "cell(nodes.fetch, 3, 'gmv')"}]) + row = await finish(fetch_graph(card)) + assert row.status == "failed" + assert "cell() 要第 3 行,但这份查询结果只有 1 行" in (row.error or ""), row.error + + +# -------------------------------------------------------------------------- +# agent 与知识检索也记账;目录按全局顺序编号 +# -------------------------------------------------------------------------- + + +def script_agent(monkeypatch, sqls): + from app.providers import mock_model + + seen: list[list] = [] + + def decide(self, messages): + if self.response_format: + return AIMessage(content="{}") + seen.append(list(messages)) + done = sum(isinstance(m, ToolMessage) for m in messages) + if done < len(sqls): + return AIMessage(content="查。", tool_calls=[{"name": TOOL, "args": {"sql": sqls[done]}, "id": f"call_{done}"}]) + return AIMessage(content="查完了。") + + monkeypatch.setattr(mock_model.MockChatModel, "_decide", decide) + return seen + + +async def test_agent_and_retrieval_write_the_ledger_and_the_catalog_numbers_them(engine_up, shop, monkeypatch): + from app.memory import kb + + async def fake_search(session, **kw): + return [{"title": "口径说明", "ordinal": 1, "content": "退款按发生周统计", "score": 0.9, "document_id": "d1"}] + + monkeypatch.setattr(kb, "search", fake_search) + seen = script_agent(monkeypatch, [SQL, "SELECT * FROM nope", "SELECT COUNT(*) AS refunds FROM orders WHERE refunded = 1"]) + g = chain( + node("start", "input"), + node("docs", "retrieve", query="退款口径", collection="docs"), + node("bot", "agent", prompt="查本周销售额和退款", tools=[TOOL], approval="never"), + node("out", "output", fields=[{"name": "r", "value": "{{ nodes.bot.text }}"}]), + ) + row = await finish(g) + assert row.status == "succeeded", row.error + + # 给模型看的:查成功的结果前面有本节点内的编号,失败的原话照旧喂回去、不编号 + tool_msgs = [m for m in seen[-1] if isinstance(m, ToolMessage)] + assert tool_msgs[0].content.startswith("【证据 Q1】\n{") + assert tool_msgs[1].content.startswith("查询失败:no such table: nope") + assert tool_msgs[2].content.startswith("【证据 Q2】\n{") + + entries = await ledger(row.id) + assert [(e["kind"], e["node_id"]) for e in entries] == [ + ("retrieval", "docs"), ("tool", "bot"), ("query", "bot"), ("tool", "bot"), ("tool", "bot"), ("query", "bot")] + retrieval = entries[0] + [retrieve_end] = await events(row.id, "retrieve.end", "docs") + assert retrieval == {"kind": "retrieval", "node_id": "docs", "exec": 1, "artifact": retrieve_end.data["artifact"], + "source": "docs", "rows": 1} + ends = await events(row.id, "tool.end", "bot") + queries = [e for e in entries if e["kind"] == "query"] + assert [q["artifact"] for q in queries] == [e.data["query_artifact"] for e in ends if e.data.get("query_artifact")] + assert [q["via"] for q in queries] == [ends[0].data["artifact"], ends[2].data["artifact"]] + assert queries[0]["call_id"] == "call_0" and queries[1]["call_id"] == "call_2" + assert queries[1]["columns"] == ["refunds"] and queries[1]["rows"] == 1 + + # 工具调用记录带上编号和工件 + bot = (await events(row.id, "node.finished", "bot"))[0].data + assert [e["kind"] for e in bot["evidence"]] == ["tool", "query", "tool", "tool", "query"] + transcript = artifact_store.load(bot["artifact"])["tool_calls"] + assert [t.get("alias") for t in transcript] == ["Q1", None, "Q2"] + assert transcript[0]["artifact"] == queries[0]["artifact"] and transcript[0]["via"] == queries[0]["via"] + assert transcript[0]["call_id"] == "call_0" + + catalog = build_catalog(nodes={}, ledger=entries) + assert catalog["Q1"]["artifact"] == queries[0]["artifact"] and catalog["Q2"]["artifact"] == queries[1]["artifact"] + assert catalog["K1"]["artifact"] == retrieval["artifact"] and "1 条" in catalog["K1"]["label"] + + +# -------------------------------------------------------------------------- +# 问数据那条路:input → agent → report → output,报告直接引用 agent 查过的格 +# -------------------------------------------------------------------------- + + +async def test_report_cites_cells_the_agent_queried(engine_up, shop, monkeypatch): + from app.engine.evidence import iter_segments + from app.providers import mock_model + + answer = "本周销售额 [[v:Q1.r0.gmv]],共 [[v:Q1.r0.orders]] 单。[[see:Q1]]\n\n[[table:Q1 cols=gmv,orders]]" + + def decide(self, messages): + if self.tools: + if not any(isinstance(m, ToolMessage) for m in messages): + return AIMessage(content="查。", tool_calls=[{"name": TOOL, "args": {"sql": SQL}, "id": "call_0"}]) + return AIMessage(content="查完了。") + return AIMessage(content=answer) + + monkeypatch.setattr(mock_model.MockChatModel, "_decide", decide) + g = chain( + node("start", "input"), + node("bot", "agent", prompt="本周卖了多少", tools=[TOOL], approval="never"), + node("write", "report", instructions="回答用户的问题"), + node("out", "output", fields=[{"name": "答案", "value": "{{ nodes.write.text }}"}]), + ) + row = await finish(g) + assert row.status == "succeeded", row.error + assert row.output["答案"] == ("本周销售额 600.5,共 3 单。\n\n| gmv | orders |\n| --- | --- |\n| 600.5 | 3 |") + assert row.output["_evidence"]["fields"] == ["答案"] + + [checked] = await events(row.id, "report.checked", "write") + assert checked.data["ok"] is True and checked.data["violations"] == [] + doc = artifact_store.load(checked.data["doc_artifact"]) + query = next(e for e in await ledger(row.id) if e["kind"] == "query") + cited = [s for s in iter_segments(doc) if s.get("ref") == "v:Q1.r0.gmv"] + assert len(cited) == 2 # 正文一处、表里一处,指向同一格 + assert {s["cite"]["eid"] for s in cited} == {make_eid("cell", query["artifact"], {"row": 0, "column": "gmv"})} + assert doc["catalog"]["Q1"]["artifact"] == query["artifact"] and doc["catalog"]["Q1"]["node_id"] == "bot" diff --git a/backend/tests/test_transform_json_error.py b/backend/tests/test_transform_json_error.py new file mode 100644 index 00000000..7c6999a3 --- /dev/null +++ b/backend/tests/test_transform_json_error.py @@ -0,0 +1,271 @@ +"""整形节点按 JSON 解析模型写的文字:报错要说到点子上。 + +真实踩过的一张图:agent(自由文字)→ transform `{{ nodes.query_logs.text }}` → 口径卡。 +agent 在 JSON 字符串里夹了没转义的英文双引号,transform 报「模板渲染出来的不是合法 +JSON……字符串值要用 | json 过滤器输出」。模板本身没错,这句提示把人引到了错的方向。 + +- 出错的位置落在上游模型写的那段文字里:点名上游,说常见原因,指向 output_schema + + cite_fields;出错位置前后的原文只进技术细节 +- 模板自己写错了:照旧是原来那句提示 +- 画图时就说破:transform 解析 agent / llm 的文字、产出流向口径卡或按 JSON 解析,给 warning +""" +from __future__ import annotations + +import asyncio + +import pytest +from langchain_core.messages import AIMessage +from sqlalchemy import select + +from app.db.base import SessionLocal +from app.db.models import Run, RunEvent +from app.engine.runner import run_manager +from app.engine.schema import GraphSpec, validate_graph + +#: 「他说"好的"」:字符串里的英文引号没转义,json.loads 在「好」那里要逗号 +BAD = '{"summary": "他说"好的"然后走了", "count": 3}' +TEMPLATE_HINT = "检查模板里的引号、逗号,字符串值要用 | json 过滤器输出" + + +@pytest.fixture(autouse=True) +async def engine_up(): + await run_manager.setup() + yield + await run_manager.shutdown() + + +def node(nid, ntype, label=None, **config): + return {"id": nid, "type": ntype, "position": {"x": 0, "y": 0}, + "data": {"label": label or nid, "config": config}} + + +def chain(*nodes): + return {"nodes": list(nodes), "edges": [{"source": a["id"], "target": b["id"]} for a, b in zip(nodes, nodes[1:])]} + + +def script(monkeypatch, text): + from app.providers import mock_model + + monkeypatch.setattr(mock_model.MockChatModel, "_decide", lambda self, messages: AIMessage(content=text)) + + +def weekly(template, *, parse_mode="json"): + return chain( + node("start", "input"), + node("query_logs", "llm", "查询日志", prompt="把结果写成 JSON", assign_to="answer"), + node("parse", "transform", "解析取数结果为变量", mode=parse_mode, template=template, assign_to="q"), + node("out", "output", fields=[{"name": "r", "value": "{{ vars.q | json }}"}]), + ) + + +async def failure(graph, payload=None) -> RunEvent: + run = await run_manager.start(graph=graph, input_payload=payload or {}) + for _ in range(400): + await asyncio.sleep(0.05) + async with SessionLocal() as session: + row = await session.get(Run, run.id) + if row.status in ("succeeded", "failed") and row.manifest_seq is not None: + break + assert row.status == "failed", row.status + async with SessionLocal() as session: + [failed] = (await session.execute(select(RunEvent).where( + RunEvent.run_id == run.id, RunEvent.type == "node.failed"))).scalars() + assert failed.node_id == "parse" + return failed + + +# -------------------------------------------------------------------------- +# 运行时的报错 +# -------------------------------------------------------------------------- + + +@pytest.mark.parametrize("template", [ + "{{ nodes.query_logs.text }}", + " {{ vars.answer }}\n", + "{\"wrapped\": {{ nodes.query_logs.text }}}", +]) +async def test_unescaped_quotes_in_model_text_blame_the_upstream(monkeypatch, template): + script(monkeypatch, BAD) + failed = await failure(weekly(template)) + error, detail = failed.data["error"], failed.data["detail"] + + assert error.startswith("上游「查询日志」输出的不是合法 JSON"), error + assert "第 17 列附近" in error, error # 在上游那段文字里的位置,不是在整段模板里的 + assert "没转义的英文引号" in error and "output_schema + cite_fields" in error + assert TEMPLATE_HINT not in error and "| json" not in error + # 原文只进技术细节:首行不带模型写的内容 + assert "他说" not in error + assert detail.startswith("JSONDecodeError: Expecting ',' delimiter") + assert '"summary": "他说"' in detail and '好的"然后走了' in detail, detail + + +async def test_context_in_detail_is_thirty_characters_each_side(monkeypatch): + head, tail = "x" * 50, "y" * 50 + script(monkeypatch, '{"a": "' + head + '"' + tail + '"}') + failed = await failure(weekly("{{ nodes.query_logs.text }}")) + near = failed.data["detail"].split("\n", 1)[1] + assert "x" * 29 + '"' in near and "x" * 31 not in near, near + assert "y" * 30 in near and "y" * 31 not in near, near + + +async def test_a_broken_template_keeps_the_template_hint(monkeypatch): + """模板自己多了个逗号:该查的就是模板,照旧是原来那句提示。""" + script(monkeypatch, "好的") + failed = await failure(weekly('{"q": {{ nodes.query_logs.text | json }}, }')) + error = failed.data["error"] + assert error.startswith("模板渲染出来的不是合法 JSON(第 1 行第 "), error + assert error.endswith(TEMPLATE_HINT) and "上游" not in error + assert failed.data["detail"].startswith("JSONDecodeError: ") + + +async def test_quoted_model_text_passed_through_the_json_filter_is_not_blamed(monkeypatch): + """上游的文字过了 | json 就是一个合法的 JSON 字符串:错一定在模板里。""" + script(monkeypatch, BAD) + failed = await failure(weekly('{"summary": {{ nodes.query_logs.text | json }} "n": 1}')) + assert failed.data["error"].startswith("模板渲染出来的不是合法 JSON") + + +# -------------------------------------------------------------------------- +# 模板自己写错了,就不能甩给上游:错的位置落在模型写的那一段里,不等于错在模型 +# -------------------------------------------------------------------------- + +JSON_FIX = "{{ nodes.query_logs.text | json }}" + + +def template_error(failed: RunEvent) -> str: + error = failed.data["error"] + assert error.startswith("模板渲染出来的不是合法 JSON(第 1 行第 "), error + assert "上游" not in error and "cite_fields" not in error and "output_schema" not in error, error + assert failed.data["detail"].startswith("JSONDecodeError: ") + return error + + +@pytest.mark.parametrize("text", ["GMV is 5", "本周销售额 5 万"]) +async def test_prose_dropped_into_a_value_slot_asks_for_the_json_filter(monkeypatch, text): + """模型本来就只写一句话,模板把它原样放在值的位置:该加的是 | json,模型没做错什么。""" + script(monkeypatch, text) + error = template_error(await failure(weekly('{"summary": {{ nodes.query_logs.text }}}'))) + assert "第 13 列附近" in error, error + assert "模板里 {{ nodes.query_logs.text }} 是模型写的文字" in error and JSON_FIX in error, error + assert "外面不要再加引号" in error + + +@pytest.mark.parametrize("template, fix, text", [ + # 前面还有一个带转义引号的字符串:判断「在不在引号里」不能数错 + ('{"note": "a\\"b", "summary": "{{ nodes.query_logs.text }}"}', JSON_FIX, '他说"好的"'), + ('{"summary": "{{ vars.answer }}", "n": 1}', "{{ vars.answer | json }}", '他说"好的"'), + # 模型交回的是 JSON,但模板把它当字符串放进了引号:还是模板的事 + ('{"summary": "{{ nodes.query_logs.text }}"}', JSON_FIX, '{"gmv": 1}'), +]) +async def test_model_text_inside_template_quotes_asks_for_the_json_filter(monkeypatch, template, fix, text): + """模板里写的是 "{{ … }}":模型的文字只要带英文引号就把字符串撑破。上游没错,是模板少了 | json。""" + script(monkeypatch, text) + error = template_error(await failure(weekly(template))) + assert fix in error and "外面不要再加引号" in error, error + + +@pytest.mark.parametrize("template", [ + '{"x": {{ nodes.query_logs.text }}"b": 2}', # 紧跟着的逗号漏了 + '{"x": {{ nodes.query_logs.text }} "b": 2}', # 隔着空格漏的 + '[1 {{ nodes.query_logs.text }}]', # 前面漏了逗号 +]) +async def test_a_missing_comma_next_to_valid_model_json_keeps_the_template_hint(monkeypatch, template): + """上游交回的是一个完整、合法的 JSON 对象,解析器停在它前后:缺的是模板里的逗号。""" + script(monkeypatch, '{"a": 1}') + error = template_error(await failure(weekly(template))) + assert error.endswith(TEMPLATE_HINT) and JSON_FIX not in error, error + + +async def test_a_json_filtered_value_wrapped_in_quotes_says_to_drop_the_quotes(monkeypatch): + script(monkeypatch, "好的") + error = template_error(await failure(weekly('{"summary": "{{ nodes.query_logs.text | json }}"}'))) + assert "{{ nodes.query_logs.text | json }} 出来的已经是合法的 JSON(文字自带引号)" in error, error + assert error.endswith("外面不要再加引号"), error + + +async def test_an_empty_value_in_a_value_slot_names_the_span(monkeypatch): + script(monkeypatch, "好的") + error = template_error(await failure(weekly('{"summary": {{ input.missing }} , "n": 1}'))) + assert "{{ input.missing }} 取出来是空的" in error, error + + +async def test_plain_text_that_is_not_from_a_model_also_gets_the_json_filter_hint(monkeypatch): + script(monkeypatch, "好的") + error = template_error(await failure(weekly('{"q": {{ input.question }}}'), {"question": "上周"})) + assert "模板里 {{ input.question }} 是一段文字" in error and "{{ input.question | json }}" in error, error + + +@pytest.mark.parametrize("text, reason", [ + ('{"week": "2026-W37", "rows": [1, 2', "没有收尾"), + ('```json\n{"gmv": 1}\n```', "代码块"), + ("好的,本周销售额是 5 万", "一段文字"), +]) +async def test_model_text_parsed_as_a_whole_says_what_is_wrong_with_it(monkeypatch, text, reason): + """整个模板就是上游那段文字:模板不可能写错。原因按实情说,不一律说引号。""" + script(monkeypatch, text) + error = (await failure(weekly("{{ nodes.query_logs.text }}"))).data["error"] + assert error.startswith("上游「查询日志」输出的不是合法 JSON"), error + assert reason in error and "没转义的英文引号" not in error and "output_schema" in error, error + + +# -------------------------------------------------------------------------- +# 画图时就说破 +# -------------------------------------------------------------------------- + + +def warnings_of(graph, node_id="parse"): + return [i for i in validate_graph(GraphSpec.model_validate(graph)).issues + if i.node_id == node_id and "cite_fields" in i.message] + + +CARD = node("card", "metrics", "口径卡", caliber="周报口径", metrics=[{"id": "gmv", "expression": "vars.q.gmv"}]) + + +def agent_graph(template, *, parse_mode="json", after=(CARD,), **agent): + return chain( + node("start", "input"), + node("query_logs", "agent", "查询日志", prompt="查", tools=["db_query__shop"], assign_to="answer", + **agent), + node("parse", "transform", "解析", mode=parse_mode, template=template, expression=template, + assign_to="q"), + *after, + node("out", "output", fields=[{"name": "r", "value": "{{ vars.q | json }}"}]), + ) + + +def test_validate_warns_when_a_transform_parses_agent_text_into_a_caliber(): + [issue] = warnings_of(agent_graph("{{ nodes.query_logs.text }}")) + assert issue.level == "warning" and issue.field == "template" + assert "「查询日志」" in issue.message and "output_schema" in issue.message + # 按 JSON 解析的,不经过口径卡也要说 + assert warnings_of(agent_graph("{{ vars.answer }}", after=())) + # 表达式模式读文字、流向口径卡 + [expr] = warnings_of(agent_graph("nodes.query_logs.text", parse_mode="expression")) + assert expr.field == "expression" + + +def test_validate_stays_quiet_when_nothing_parses_model_text(): + # 过了 | json:是一个合法的 JSON 字符串 + assert not warnings_of(agent_graph('{"note": {{ nodes.query_logs.text | json }}}')) + # 开了 cite_fields 的 agent:vars 里拿到的是核对过的结构化数据 + schema = {"type": "object", "properties": {"gmv": {"type": "number"}}} + assert not warnings_of(agent_graph("{{ vars.answer }}", output_schema=schema, cite_fields=True)) + # 模板模式拼一段话、也不进口径卡:没人按 JSON 读它 + assert not warnings_of(agent_graph("结论:{{ nodes.query_logs.text }}", parse_mode="template", after=())) + # 查询工具交回的是系统拼的 JSON,解析它是正路 + tool = chain(node("start", "input"), node("fetch", "tool", tool="db_query__shop", args={"sql": "SELECT 1"}), + node("parse", "transform", mode="json", template="{{ nodes.fetch }}", assign_to="q"), CARD, + node("out", "output", fields=[{"name": "r", "value": "{{ vars.q | json }}"}])) + assert not warnings_of(tool) + + +def test_validate_points_quoted_model_text_at_the_json_filter(): + """模板把模型的文字放在引号里:画图时也别把人引去 cite_fields,该改的是模板。""" + graph = agent_graph('{"summary": "{{ nodes.query_logs.text }}", "n": 1}', after=()) + [issue] = [i for i in validate_graph(GraphSpec.model_validate(graph)).issues + if i.node_id == "parse" and "| json" in i.message] + assert issue.level == "warning" and issue.field == "template" + assert "{{ nodes.query_logs.text | json }}" in issue.message and "外面不要再加引号" in issue.message + assert "cite_fields" not in issue.message and "「查询日志」" in issue.message + # 值的位置、整段解析:仍然指向结构化输出 + assert warnings_of(agent_graph('{"summary": {{ nodes.query_logs.text }}}', after=())) diff --git a/backend/tests/test_version_level.py b/backend/tests/test_version_level.py new file mode 100644 index 00000000..0b6ed7b4 --- /dev/null +++ b/backend/tests/test_version_level.py @@ -0,0 +1,158 @@ +"""发布级别记在版本上(WorkflowVersion.level)。 + +以前「这次正式运行按不按受管出具」看的是工作流当前的 status。可 status 说的是当前画布: +受管版本发布之后在画布上改一笔,status 就退回 draft,再从那个受管版本发起的正式运行 +就被当成已发布级别出具(单元格不必在契约里声明 cells 就放行)。级别要跟着版本走: +发布时记在那一版上,正式运行看它钉的版本;没有记过级别的老版本照旧看 status。 +""" +from __future__ import annotations + +import asyncio + +import pytest +from httpx import ASGITransport, AsyncClient +from langchain_core.messages import AIMessage +from sqlalchemy import create_engine, select, text + +from app.db.base import SessionLocal +from app.db.models import Run, RunEvent, Workflow, WorkflowVersion +from app.engine.runner import run_manager +from app.main import app + + +@pytest.fixture(autouse=True) +async def engine_up(): + await run_manager.setup() + yield + await run_manager.shutdown() + + +@pytest.fixture +async def client(): + async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as c: + yield c + + +@pytest.fixture(autouse=True) +def writer(monkeypatch): + from app.providers import mock_model + + monkeypatch.setattr(mock_model.MockChatModel, "_decide", + lambda model, messages: AIMessage(content="本周销售额 [[m:gmv]]。")) + + +def node(nid, ntype, **config): + return {"id": nid, "type": ntype, "position": {"x": 0, "y": 0}, "data": {"label": nid, "config": config}} + + +def weekly() -> dict: + nodes = [ + node("start", "input"), + node("fetch", "transform", mode="expression", expression="{'gmv': 300.5}", assign_to="kpi"), + node("card", "metrics", caliber="周报口径", metrics=[ + {"id": "gmv", "name": "销售额", "unit": "元", "expression": "vars.kpi.gmv"}]), + node("write", "report", instructions="写周报"), + node("out", "output", fields=[{"name": "周报", "value": "{{ nodes.write.text }}"}], + contract={"report_from": "write", "metrics_from": ["card"], "required": ["gmv"], "strict": True}), + ] + return {"nodes": nodes, "edges": [{"source": a["id"], "target": b["id"]} for a, b in zip(nodes, nodes[1:])]} + + +async def version_row(wf: str, version: int) -> WorkflowVersion: + async with SessionLocal() as session: + return (await session.execute(select(WorkflowVersion).where( + WorkflowVersion.workflow_id == wf, WorkflowVersion.version == version))).scalar_one() + + +async def notes(run_id: str) -> list[dict]: + async with SessionLocal() as session: + rows = (await session.execute(select(RunEvent).where( + RunEvent.run_id == run_id, RunEvent.type == "log").order_by(RunEvent.seq))).scalars() + return [e.data for e in rows if e.data.get("code") == "run_governance"] + + +async def settle(run_id: str) -> Run: + for _ in range(400): + await asyncio.sleep(0.05) + async with SessionLocal() as session: + row = await session.get(Run, run_id) + if row.status in ("succeeded", "failed") and row.manifest_seq is not None: + return row + raise AssertionError(f"run {run_id} 没跑完:{row.status}") + + +@pytest.mark.parametrize("level", ["published", "governed"]) +async def test_publishing_records_the_level_on_the_version(client, level): + wf = (await client.post("/api/workflows", json={"name": f"周报-{level}", "graph": weekly()})).json()["id"] + body = (await client.post(f"/api/workflows/{wf}/publish", json={"level": level})).json() + assert body["ok"] is True, body["issues"] + assert (await version_row(wf, 1)).level == level + versions = (await client.get(f"/api/workflows/{wf}/versions")).json() + assert versions[0]["level"] == level + detail = (await client.get(f"/api/workflows/{wf}/versions/1")).json() + assert detail["level"] == level and detail["published"] is True + + +async def test_a_blocked_publish_records_nothing(client): + graph = weekly() + graph["nodes"][-1]["data"]["config"]["contract"].pop("required") + wf = (await client.post("/api/workflows", json={"name": "周报-被拦", "graph": graph})).json()["id"] + assert (await client.post(f"/api/workflows/{wf}/publish", json={"level": "governed"})).json()["ok"] is False + assert (await version_row(wf, 1)).level is None + + +async def test_republishing_the_same_version_updates_its_level(client): + wf = (await client.post("/api/workflows", json={"name": "周报-升档", "graph": weekly()})).json()["id"] + await client.post(f"/api/workflows/{wf}/publish", json={"level": "published"}) + await client.post(f"/api/workflows/{wf}/publish", json={"level": "governed"}) + assert (await version_row(wf, 1)).level == "governed" + + +@pytest.mark.parametrize("level, governed", [("governed", True), ("published", False)]) +async def test_editing_the_canvas_after_publishing_does_not_change_how_the_version_issues(client, level, governed): + from app.engine.governance import governed_formal + + wf = (await client.post("/api/workflows", json={"name": f"周报-改画布-{level}", "graph": weekly()})).json()["id"] + assert (await client.post(f"/api/workflows/{wf}/publish", json={"level": level})).json()["ok"] is True + edited = weekly() + edited["nodes"][3]["data"]["label"] = "改过的报告" + after = (await client.patch(f"/api/workflows/{wf}", json={"graph": edited})).json() + assert after["status"] == "draft" and after["published_version"] == 1 + r = await client.post("/api/runs", json={"workflow_id": wf, "run_class": "formal"}) + assert r.status_code == 201, r.text + run_id = r.json()["id"] + row = await settle(run_id) + [note] = await notes(run_id) + assert note["governed"] is governed and "v1" in note["message"], note + assert await governed_formal(run_id) is governed + assert row.status == "succeeded", row.error + + +async def test_a_version_published_before_the_column_existed_follows_the_status(client): + """老版本没有记级别:照旧看工作流现在的 status(test_contract_cells 那套旧规则)。""" + async with SessionLocal() as session: + w = Workflow(name="周报-老版本", graph=weekly(), status="governed", published_version=1) + session.add(w) + await session.flush() + session.add(WorkflowVersion(workflow_id=w.id, version=1, graph=weekly())) + await session.commit() + wf = w.id + r = await client.post("/api/runs", json={"workflow_id": wf, "run_class": "formal"}) + run_id = r.json()["id"] + await settle(run_id) + [note] = await notes(run_id) + assert note["governed"] is True + + +def test_the_column_is_added_to_old_databases(): + from app.db.base import _migrate + + engine = create_engine("sqlite://") + with engine.begin() as conn: + conn.execute(text("CREATE TABLE workflow_versions (id VARCHAR(32) PRIMARY KEY, workflow_id VARCHAR(32), " + "version INTEGER, graph JSON, note TEXT, graph_hash VARCHAR(64))")) + conn.execute(text("INSERT INTO workflow_versions (id, workflow_id, version) VALUES ('a', 'w', 1)")) + _migrate(conn) + cols = {row[1] for row in conn.execute(text("PRAGMA table_info(workflow_versions)"))} + assert "level" in cols + assert conn.execute(text("SELECT level FROM workflow_versions")).scalar() is None diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index 1bec52aa..2ab01609 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -1,7 +1,8 @@ import type { - Approval, Conversation, ConversationDetail, ConversationTurn, CustomTool, DataSource, EvidenceGraph, - EvidenceSegmentDetail, GraphSpec, KbDocument, MemoryItem, Provider, ReviewResult, Run, RunEvent, RunStatus, - Skill, ToolChange, ToolInfo, ToolTrust, ValidationIssue, VarIssue, Variable, Workflow, WorkflowVersion, + Approval, AutofixResult, Conversation, ConversationDetail, ConversationTurn, CustomTool, DataSource, EvidenceGraph, + EvidenceSegmentDetail, GraphSpec, KbDocument, MemoryItem, Provider, PublishCheck, PublishLevel, ReviewResult, Run, + RunEvent, RunStatus, Skill, ToolChange, ToolInfo, ToolTrust, ValidationIssue, VarIssue, Variable, Workflow, + WorkflowVersion, } from '../types' import { localActor } from '../lib/actor' import { VALIDATION_TITLE, describeValidation } from '../lib/validation' @@ -420,6 +421,18 @@ export const api = { `/workflows/${id}/publish`, { level, version }), validate: (graph: GraphSpec) => post<{ ok: boolean; issues: ValidationIssue[] }>('/workflows/validate', { graph }), + /** + * 发布前检查:validate 和发布门禁的并集,和真正发布同一套口径,另给每条问题的修复。 + * 只读。不带 graph 就查库里的草稿。老后端没有这个接口(404),调用方按「不支持」处理 + */ + publishCheck: (id: string, body: { level: PublishLevel; graph?: GraphSpec }, opts?: RequestOptions) => + request(`/workflows/${id}/publish-check`, + { method: 'POST', body: JSON.stringify(body), ...opts }), + /** 按选中的修复出一份预览(整张图 + 逐项改动)。只读:不存草稿、不发布。assist 时会调模型,可能要几十秒 */ + autofix: (id: string, body: { + level: PublishLevel; graph?: GraphSpec; apply: string[]; choices?: Record; assist?: boolean + }, opts?: RequestOptions) => + request(`/workflows/${id}/autofix`, { method: 'POST', body: JSON.stringify(body), ...opts }), /** 这张图里有哪些变量、谁产出、谁引用。纯静态分析,不需要跑过 */ variables: (graph: GraphSpec) => post<{ variables: Variable[]; issues: VarIssue[] }>('/workflows/variables', { graph }), diff --git a/frontend/src/canvas/Inspector.tsx b/frontend/src/canvas/Inspector.tsx index 1d92c7b9..eddc778d 100644 --- a/frontend/src/canvas/Inspector.tsx +++ b/frontend/src/canvas/Inspector.tsx @@ -3,17 +3,18 @@ import { createPortal } from 'react-dom' import { Link } from 'react-router-dom' import { AlertTriangle, ChevronLeft, Copy, Lock, Maximize2, Plus, Trash2, X, XCircle } from 'lucide-react' import clsx from 'clsx' -import { NODE_DEFS, syntaxOf, type FieldDef, type FieldSyntax } from './nodeDefs' +import { NODE_DEFS, syntaxOf, type FieldDef, type FieldSyntax, type NodeDef } from './nodeDefs' import { fieldOfIssue, unboundToolOf, withToolBound, type FieldRef } from './issues' import { hintOf } from './shortcuts' import { isActivePhase } from '../run/trace' import { EDIT_LOCK_TEXT, useEditLock, useStudio } from '../store/studio' import { datasourceTools, modelOptions, useCatalog, useDatasources } from '../store/catalog' import { api } from '../api/client' -import { IconButton, JsonInput, Modal, isComposing } from '../components/ui' +import { IconButton, JsonInput, Modal, isComposing, useRadioGroup } from '../components/ui' import { formatShortcut } from '../lib/keys' +import { SUBGRAPH_UPGRADE_HELP, upgradeNewerText } from '../lib/terms' import { TemplateText } from './TemplateText' -import type { ValidationIssue, WorkflowVersion } from '../types' +import type { NodeType, ValidationIssue, WorkflowVersion } from '../types' /** 检查器里一条落到字段上的问题 */ type FieldIssue = ValidationIssue & { at: FieldRef | null } @@ -149,6 +150,26 @@ function NodeRefsInput({ nodeId, type, value, onChange }: { ) } +/** + * 子工作流钉住版本后的升版处置:和口径卡的是同一个下拉——直接借口径卡的字段定义,选项、 + * 文案一字不差,只换说明里「和谁同一套」那半句。上游在钉住的那一版之后又发了版时,正式运行 + * 前必须声明,否则挡住(后端 governance.unresolved_caliber_upgrades 两者同一套规则) + */ +const SUBGRAPH_POLICY: FieldDef | null = (() => { + const base = NODE_DEFS.metrics.fields.find((f) => f.key === 'upgrade_policy') + return base ? { + ...base, help: SUBGRAPH_UPGRADE_HELP, + when: (c: Record) => !!c.workflow_id && c.workflow_version != null && c.workflow_version !== '', + } : null +})() + +/** 这个节点要画哪些字段:子工作流在「工作流」后面插上升版处置,其余照 nodeDefs */ +function fieldsOf(type: NodeType, def: NodeDef): FieldDef[] { + if (type !== 'subgraph' || !SUBGRAPH_POLICY || def.fields.some((f) => f.key === 'upgrade_policy')) return def.fields + const at = def.fields.findIndex((f) => f.key === 'workflow_id') + return [...def.fields.slice(0, at + 1), SUBGRAPH_POLICY, ...def.fields.slice(at + 1)] +} + /** 属性面板。所有字段都由 nodeDefs 的声明驱动渲染,加节点类型不用改这里。 */ export function Inspector() { const selectedId = useStudio((s) => s.selectedId) @@ -197,7 +218,7 @@ export function Inspector() { const setConfig = (key: string, value: any) => updateNode(node.id, { config: { ...config, [key]: value } }) - const visible = def.fields.filter((f) => !f.when || f.when(config)) + const visible = fieldsOf(node.data.nodeType, def).filter((f) => !f.when || f.when(config)) const keys = new Set(visible.map((f) => f.key)) const basic = visible.filter((f) => !f.advanced) const advanced = visible.filter((f) => f.advanced) @@ -382,7 +403,7 @@ function Field({ field, nodeId, value, config, issues, onChange }: { const id = `f-${nodeId}-${field.key}` const syntax = syntaxOf(field) const composite = field.type === 'cases' || field.type === 'agents' || field.type === 'ioFields' - || field.type === 'metricsList' + || field.type === 'metricsList' || field.type === 'caliberFrom' // 复合字段自己把问题落到第几项;落不到具体某一项的,和普通字段一样挂在下面 const own = composite ? issues.filter((i) => i.at?.index == null) : issues const bad = own.some((i) => i.level === 'error') @@ -390,8 +411,10 @@ function Field({ field, nodeId, value, config, issues, onChange }: { // 长文本在 360px 宽的栏里没法写:提示词、代码可以展开到大编辑器里(同一份值,边写边存) const expandable = (field.type === 'prompt' || field.type === 'code' || field.type === 'textarea') && syntax !== 'plain' const [wide, setWide] = useState(false) + // 看得见、暂时用不了:说清差什么(比如先写 Schema 才能开 cite_fields) + const off = field.disabled?.(config) ?? null return ( -
+
{field.type !== 'switch' && (
@@ -413,10 +436,12 @@ function Field({ field, nodeId, value, config, issues, onChange }: { document.body, )} + invalid={bad} describedBy={[errorId, off ? `${id}-off` : ''].filter(Boolean).join(' ') || undefined} + disabled={!!off} issues={issues} onChange={onChange} /> {field.help && field.type !== 'switch' && (
{field.help}
)} + {off &&
{off}
} {!!own.length && (
{own.map((i, k) => onChange(c[field.key]))} />)} @@ -426,9 +451,11 @@ function Field({ field, nodeId, value, config, issues, onChange }: { ) } -function FieldInput({ field, id, nodeId, syntax, value, config, invalid, describedBy, issues, onChange }: { +function FieldInput({ field, id, nodeId, syntax, value, config, invalid, describedBy, disabled, issues, onChange }: { field: FieldDef; id: string; nodeId: string; syntax: FieldSyntax; value: any config: Record; invalid: boolean; describedBy?: string + /** 字段的 disabled 条件成立(原因写在字段下面,由 describedBy 接上) */ + disabled?: boolean issues: FieldIssue[]; onChange: (v: any) => void }) { // 目录只按这个字段用得上的那一张订阅。以前每个字段都订阅整个 catalog:连接心跳、 @@ -500,9 +527,9 @@ function FieldInput({ field, id, nodeId, syntax, value, config, invalid, describ case 'switch': return ( -
) } +/** + * 口径卡从哪来:在这里定义,或者钉住另一个工作流某一版里的口径卡(caliber_from)。 + * + * 钉住时选工作流、版本、那一版图里的口径卡节点,写成 {workflow_id, workflow_version, node_id}; + * 本地的名称、版本、指标定义这时不生效(后端 validate 也这么说),切过去就清掉——撤销能找回。 + * 版本必须钉:不钉的话口径会跟着上游漂移。上游在钉住的那一版之后又发了版,旁边就提醒 + * 要在下面声明升版处置,否则挡住正式运行(和子工作流同一套) + */ +function CaliberFromPicker({ id, value, invalid, describedBy }: { + id: string; value: any; invalid: boolean; describedBy?: string +}) { + const workflows = useCatalog((s) => s.workflows) + const self = useStudio((s) => s.workflow?.id) + const nodeId = useStudio((s) => s.selectedId) + const updateNode = useStudio((s) => s.updateNode) + const ref = value && typeof value === 'object' && !Array.isArray(value) ? value as Record : null + const wfId: string = ref?.workflow_id ? String(ref.workflow_id) : '' + const version: number | null = ref?.workflow_version != null && String(ref.workflow_version) !== '' + ? Number(ref.workflow_version) : null + const [versions, setVersions] = useState(null) + const [graph, setGraph] = useState<{ key: string; nodes: any[] } | 'error' | null>(null) + useEffect(() => { + setVersions(null) + if (!wfId) return + let alive = true + api.workflows.versions(wfId).then((v) => { if (alive) setVersions(v) }).catch(() => { if (alive) setVersions([]) }) + return () => { alive = false } + }, [wfId]) + useEffect(() => { + setGraph(null) + if (!wfId || version == null || !Number.isFinite(version)) return + let alive = true + api.workflows.version(wfId, version).then( + (v) => { if (alive) setGraph({ key: `${wfId}@${version}`, nodes: Array.isArray(v?.graph?.nodes) ? v.graph!.nodes : [] }) }, + () => { if (alive) setGraph('error') }, + ) + return () => { alive = false } + }, [wfId, version]) + + /** 改这个节点的配置:钉住的来源和本地定义是互斥的两套 */ + const write = (patch: Record, drop: string[] = []) => { + const node = nodeId ? useStudio.getState().nodes.find((n) => n.id === nodeId) : undefined + if (!node) return + const config = { ...node.data.config, ...patch } + for (const k of drop) delete config[k] + updateNode(node.id, { config }) + } + const mode: 'local' | 'pinned' = ref ? 'pinned' : 'local' + const setMode = (m: 'local' | 'pinned') => { + if (m === mode) return + if (m === 'pinned') write({ caliber_from: {} }, ['caliber', 'caliber_version', 'metrics']) + else write({ caliber_version: 'v1', metrics: [{ id: 'total', name: '', unit: '', expression: '' }] }, + ['caliber_from', 'upgrade_policy']) + } + const radio = useRadioGroup(CALIBER_MODES, mode, setMode) + const cards = graph && graph !== 'error' ? graph.nodes.filter((n) => (n?.type ?? n?.data?.nodeType) === 'metrics') : [] + const cardOf = (n: any) => ({ id: String(n.id), label: n.data?.label || n.title || '', config: n.data?.config ?? n.config ?? {} }) + const chosen = cards.map(cardOf).find((c) => c.id === ref?.node_id) + const latest = versions?.length ? Math.max(...versions.map((v) => v.version)) : null + // 那一版里只有一张口径卡:直接钉上,不让人多点一次 + useEffect(() => { + if (!ref || ref.node_id || cards.length !== 1) return + const only = cardOf(cards[0]) + if (!only.config.caliber_from) write({ caliber_from: { ...ref, node_id: only.id } }) + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [graph]) + + return ( +
+
+ {CALIBER_MODES.map((m) => ( + + ))} +
+ {ref && ( + <> + + {!!wfId && ( +
+ + +
+ )} + {!!wfId && version != null && ( +
+ + +
+ )} + {chosen && ( +
+
+ 口径「{chosen.config.caliber || '—'}」{chosen.config.caliber_version ?? ''} + {' · '}{Array.isArray(chosen.config.metrics) ? chosen.config.metrics.length : 0} 个指标 +
+ {Array.isArray(chosen.config.metrics) && chosen.config.metrics.length > 0 && ( +
+ {chosen.config.metrics.slice(0, 8).map((m: any) => m?.id).filter(Boolean).join('、')} + {chosen.config.metrics.length > 8 ? ' …' : ''} +
+ )} +
+ )} + {latest != null && version != null && latest > version && ( +
+ {upgradeNewerText(latest, version)} +
+ )} + + )} +
+ ) +} + +const CALIBER_MODES = ['local', 'pinned'] as const + // ------------------------------------------------------------------------- // 复合编辑器 // ------------------------------------------------------------------------- diff --git a/frontend/src/canvas/NodeCard.tsx b/frontend/src/canvas/NodeCard.tsx index 7a6f4d53..f039291c 100644 --- a/frontend/src/canvas/NodeCard.tsx +++ b/frontend/src/canvas/NodeCard.tsx @@ -65,8 +65,16 @@ function summarize(type: NodeType, config: Record): string { return first(config.title) || '人工审批 · 未填标题' case 'validate': return 'JSON Schema 校验' + (config.repair_with_llm ? ' · 失败自动返工' : '') - case 'metrics': + case 'metrics': { + // 钉住别的工作流里的口径卡:本地那几项不生效,写「0 个指标」会让人以为卡是空的 + const from = config.caliber_from + if (from && typeof from === 'object') { + return from.workflow_id && from.workflow_version && from.node_id + ? `钉住上游口径卡 v${from.workflow_version} · 节点 ${from.node_id}${config.upgrade_policy ? ` · 升版按 ${config.upgrade_policy}` : ''}` + : '钉住上游口径卡 · 还没选完' + } return `${config.caliber || '口径'}@${config.caliber_version || 'v1'} · ${(config.metrics ?? []).length} 个指标` + } case 'report': { const from = Array.isArray(config.metrics_from) ? config.metrics_from.length : 0 return `${first(config.instructions) || '未填写作要求'} · ${from ? `${from} 张口径卡` : '上游全部口径卡'}` diff --git a/frontend/src/canvas/ProblemsPanel.tsx b/frontend/src/canvas/ProblemsPanel.tsx index c612012f..d59267d2 100644 --- a/frontend/src/canvas/ProblemsPanel.tsx +++ b/frontend/src/canvas/ProblemsPanel.tsx @@ -1,12 +1,16 @@ -import { useEffect, useRef } from 'react' -import { AlertTriangle, CheckCircle2, CornerDownRight, Plus, RotateCw, Trash2, Unlink, Workflow, XCircle } from 'lucide-react' +import { useEffect, useRef, useState } from 'react' +import { + AlertTriangle, CheckCircle2, CornerDownRight, ListChecks, Plus, RotateCw, ShieldCheck, Trash2, Unlink, Workflow, XCircle, +} from 'lucide-react' import clsx from 'clsx' import { NODE_DEFS } from './nodeDefs' -import { unboundToolOf, withToolBound, type FieldRef, type Problem } from './issues' +import { problemsOf, unboundToolOf, withToolBound, type FieldRef, type Problem } from './issues' +import { PublishPreflightPane } from './PublishDialog' import { formatShortcut } from '../lib/keys' +import { PUBLISH_FIX_TEXT } from '../lib/terms' import { useCatalog } from '../store/catalog' import { useEditLock, useStudio } from '../store/studio' -import { Spinner } from '../components/ui' +import { Spinner, useRadioGroup } from '../components/ui' /** * 问题面板:图级、节点、边上的 error 和 warning,一条一行,点一下定位。 @@ -21,6 +25,48 @@ export function ProblemsPane({ problems, activeId, onLocate, onDeleteEdge }: { activeId: string | null onLocate: (p: Problem) => void onDeleteEdge: (edgeId: string) => void +}) { + // 校验:画布上随改随查的那一份。发布前检查:按选定的发布等级另查一遍门禁,带修法—— + // 在画布上就能提前发现、提前修,不用等到点发布那一刻 + const [mode, setMode] = useState<'lint' | 'publish'>('lint') + const radio = useRadioGroup(MODES, mode, setMode) + const hasWorkflow = useStudio((s) => !!s.workflow) + const errors = problems.filter((p) => p.level === 'error').length + return ( + <> + {hasWorkflow && ( +
+ {MODES.map((m) => ( + + ))} +
+ )} + {mode === 'publish' && hasWorkflow + ? { + const { nodes } = useStudio.getState() + const [p] = problemsOf([issue], nodes) + if (p) onLocate(p) + }} /> + : } + + ) +} + +const MODES = ['lint', 'publish'] as const + +function LintPane({ problems, activeId, onLocate, onDeleteEdge }: { + problems: Problem[] + activeId: string | null + onLocate: (p: Problem) => void + onDeleteEdge: (edgeId: string) => void }) { const nodes = useStudio((s) => s.nodes) const analysis = useStudio((s) => s.analysis) diff --git a/frontend/src/canvas/PublishDialog.tsx b/frontend/src/canvas/PublishDialog.tsx index 68cf0132..b50a17ef 100644 --- a/frontend/src/canvas/PublishDialog.tsx +++ b/frontend/src/canvas/PublishDialog.tsx @@ -1,17 +1,26 @@ -import { useEffect, useState } from 'react' +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { Link } from 'react-router-dom' -import { AlertTriangle, Check, CornerDownRight, PenLine, ShieldCheck, XCircle } from 'lucide-react' +import { + AlertTriangle, ArrowRight, Check, CheckCircle2, CornerDownRight, PenLine, RotateCw, ShieldCheck, Sparkles, Wand2, + XCircle, +} from 'lucide-react' import clsx from 'clsx' import { api } from '../api/client' -import { diffGraphs, toFlow, useStudio, type GraphDiff } from '../store/studio' -import { Modal, Spinner, toast } from '../components/ui' -import { WORKFLOW_STATUS_HINT } from '../lib/terms' +import { + EDIT_LOCK_TEXT, diffGraphs, editLockOf, toFlow, toGraph, useEditLock, useStudio, type GraphDiff, +} from '../store/studio' +import { useCatalog } from '../store/catalog' +import { Modal, Spinner, toast, useRadioGroup } from '../components/ui' +import { humanizeError } from '../lib/errors' +import { PUBLISH_FIX_TEXT as T, WORKFLOW_STATUS_HINT, WORKFLOW_STATUS_LABEL } from '../lib/terms' import { setLocalActor, useLocalActor } from '../lib/actor' -import type { Workflow } from '../types' +import { + autoFixIds, choiceReady, contentSig, fixFieldLabel, fixFor, fixValueLines, fixValueText, isMissingEndpoint, mergeIssues, + normalizeAutofix, normalizeCheck, type NodeNameOf, +} from './issues' +import type { AutofixResult, PublishCheck, PublishFix, PublishLevel, ValidationIssue, Workflow } from '../types' -type Level = 'published' | 'governed' - -const LEVEL_DETAIL: Record = { +const LEVEL_DETAIL: Record = { published: '基础校验通过即可。正式运行从这一版发起,之后画布上继续改不影响它', governed: '另过治理门禁:不许有「多 Agent 协作」这类全动态规划节点;子工作流要钉住版本;' + '至少一个「成果」节点声明出具契约;Agent 的危险工具至少要人工审批', @@ -23,7 +32,11 @@ const LEVEL_DETAIL: Record = { * 等级的初值跟着工作流现在的等级走:以前写死「已发布」,对一张受管工作流直接点 * 「发布」,后端照 level 改写状态,它就被悄悄降了级。选中态用底色 + 勾,不只靠一圈 * 1px 描边。发布是要留痕的动作,所以写明以谁的名义发布;没署名就就地填。 - * 门禁拦下的问题一条一行、前置节点名,点一下关掉弹窗、定位到那个节点。 + * + * 打开就先做一遍发布前检查(和真正发布同一套口径),问题先列出来,能修的就地给修法: + * 以前红字只能一条条抄进助手里描述一遍。修复一律先出预览,人点「应用并重新检查」才存草稿、 + * 重查;发布永远要人自己点。不修也照旧能点发布,门禁照旧拦。老后端没有检查接口时, + * 退回原来的样子:点发布才知道被拦下了什么。 */ export function PublishDialog({ workflow, onClose, onDone, onLocate }: { workflow: Workflow @@ -32,10 +45,11 @@ export function PublishDialog({ workflow, onClose, onDone, onLocate }: { onLocate: (nodeId: string) => void }) { const load = useStudio((s) => s.load) - const nodes = useStudio((s) => s.nodes) - const [level, setLevel] = useState(workflow.status === 'governed' ? 'governed' : 'published') + const dirty = useStudio((s) => s.dirty) + const [level, setLevel] = useState(workflow.status === 'governed' ? 'governed' : 'published') const [busy, setBusy] = useState(false) - const [issues, setIssues] = useState(null) + /** 真的点了发布、被门禁拦下时它回的问题。和检查同一套口径,拦下之后以它为准 */ + const [gate, setGate] = useState(null) // 署名只存在这台浏览器里(和请求头 X-Actor 是同一份,见 lib/actor)。存不进去(隐私模式) // 时这一次弹窗里先记着,照样写得进发布提示 const stored = useLocalActor() @@ -45,6 +59,10 @@ export function PublishDialog({ workflow, onClose, onDone, onLocate }: { const [delta, setDelta] = useState(null) const published = workflow.published_version const downgrade = workflow.status === 'governed' && level === 'published' + const pf = usePreflight(workflow.id, level, { onRechecked: () => setGate(null) }) + + // 换了等级:上一档被拦下的那张单子不再适用 + useEffect(() => { setGate(null) }, [level]) // 和上一个已发布版比一比:发布之前知道这次到底改了什么 useEffect(() => { @@ -66,10 +84,9 @@ export function PublishDialog({ workflow, onClose, onDone, onLocate }: { const publish = async () => { setBusy(true) - setIssues(null) + setGate(null) try { const res = await api.workflows.publish(workflow.id, level) - setIssues(res.issues ?? []) if (res.ok) { toast.ok(`已发布 v${res.version}${level === 'governed' ? ' · 受管' : ''}${actor ? ` · ${actor}` : ''}`) const fresh = await api.workflows.get(workflow.id) @@ -80,6 +97,7 @@ export function PublishDialog({ workflow, onClose, onDone, onLocate }: { else load(fresh) onDone() } else { + setGate(normalizeCheck({ issues: res.issues }, level).issues) toast.warn('发布被门禁拦下:下面列了要改的地方,点一条定位到节点') } } catch (e) { @@ -89,20 +107,22 @@ export function PublishDialog({ workflow, onClose, onDone, onLocate }: { } } - const labelOf = (id?: string | null) => (id ? nodes.find((n) => n.id === id)?.data.label || id : '') - const errors = (issues ?? []).filter((i) => i.level === 'error') + // 预览摆着、正在存、存失败了:这时发布的是还没修的那一版,先让人把修复这件事办完。 + // 存失败(或被锁没存上)时预览可能已经收起(换过等级),说「先应用或放弃预览」就不对了 + const held = unsavedFix(pf) ? T.unsaved : pf.pending ? T.pendingPreview : dirty ? T.unsaved : '' return ( - }>
- {(['published', 'governed'] as Level[]).map((l) => { + {(['published', 'governed'] as PublishLevel[]).map((l) => { const on = level === l return (
- {issues && !!issues.length && ( -
-
- {errors.length ? `门禁拦下了 ${errors.length} 处,改完再发布` : '已发布,另有这些提示'} -
-
    - {issues.map((issue, i) => { - const err = issue.level === 'error' - const Icon = err ? XCircle : AlertTriangle - const where = labelOf(issue.node_id) - return ( -
  • - -
  • - ) - })} -
-
- )} +
+ { if (issue.node_id) { onLocate(issue.node_id); onClose() } }} /> +
) } @@ -230,3 +224,610 @@ function describe(d: GraphDiff): string { ].filter(Boolean) return parts.join('、') } + +// ------------------------------------------------------------------------- +// 发布前检查:发布弹窗和问题面板共用 +// ------------------------------------------------------------------------- + +type CheckState = { + status: 'idle' | 'loading' | 'ready' | 'unsupported' | 'error' + /** 上一次查到的:重查期间照样列着,不闪成一片空白 */ + data?: PublishCheck + /** data 是按哪一版画布内容查的(contentSig) */ + sig?: string + error?: unknown +} + +type PreviewState = + | { status: 'loading'; assist: boolean } + | { status: 'ready'; assist: boolean; result: AutofixResult; sig: string } + | { status: 'error'; assist: boolean; error: unknown } + | { status: 'unsupported'; assist: boolean } + +type ApplyState = + | { status: 'saving' | 'checking' | 'stale' } + /** unsaved:修复已经落到画布上,要存时画布锁着、没存(重试保存时撞上的) */ + | { status: 'locked'; why: string; unsaved?: boolean } + | { status: 'failed'; error: unknown } + +type PreviewRequest = { apply: string[]; choices?: Record; assist?: boolean } + +export type PreflightState = ReturnType + +const canvasGraph = () => { + const s = useStudio.getState() + return toGraph(s.nodes, s.edges) +} +const isAbort = (e: unknown) => (e as { name?: string } | null)?.name === 'AbortError' +const reasonOf = (e: unknown) => { + const h = humanizeError(e) + return h.reason ? `${h.title}:${h.reason}` : h.title +} +const same = (a: unknown, b: unknown) => JSON.stringify(a) === JSON.stringify(b) +/** 修复已经落到画布上、还没存上:存失败了,或者重试保存时画布锁着 */ +const unsavedApply = (a: ApplyState) => a.status === 'failed' || (a.status === 'locked' && !!a.unsaved) +const unsavedFix = (pf: PreflightState) => !!pf.apply && unsavedApply(pf.apply) + +/** + * 节点 id → 名字和类型。跟着 issues 换新(改名、改配置之后校验都会重跑),不订阅 nodes: + * 问题面板开着时拖一下节点,这一整块不必跟着每帧重渲染 + */ +function useNodeLookup(): (id: string) => { label: string; type: string } | undefined { + const tick = useStudio((s) => s.issues) + const map = useMemo(() => new Map(useStudio.getState().nodes.map((n) => [n.id, { label: n.data.label, type: n.data.nodeType }])), + // eslint-disable-next-line react-hooks/exhaustive-deps + [tick]) + return useCallback((id: string) => map.get(id), [map]) +} + +/** + * 发布前检查的状态:查、出修复预览、应用(落画布 → 走保存接口存草稿 → 重查)。 + * 查的一律是画布上此刻的图:发布弹窗只在没有未保存改动时打开得了,这时画布就是草稿; + * 问题面板里画布可能改了没存,查的也正是眼前这张。接口只读,存草稿走的是现有的保存。 + */ +export function usePreflight(workflowId: string | undefined, level: PublishLevel, + { onRechecked }: { onRechecked?: () => void } = {}) { + const [check, setCheck] = useState({ status: 'idle' }) + const [preview, setPreview] = useState(null) + const [apply, setApply] = useState(null) + const [choices, setChoices] = useState>({}) + const seq = useRef(0) + const checkCtl = useRef(null) + const previewCtl = useRef(null) + const lastRequest = useRef(null) + /** 落到画布上、还没存上的那一次:重试保存时用同一句版本说明 */ + const pendingSave = useRef<{ labels: string[]; count: number } | null>(null) + const rechecked = useRef(onRechecked) + rechecked.current = onRechecked + + const runCheck = useCallback(async (fresh = false): Promise => { + if (!workflowId) return null + const n = ++seq.current + checkCtl.current?.abort() + const ctl = new AbortController() + checkCtl.current = ctl + const graph = canvasGraph() + setCheck((c) => (fresh ? { status: 'loading' } : { ...c, status: 'loading', error: undefined })) + try { + const raw = await api.workflows.publishCheck(workflowId, { level, graph }, { signal: ctl.signal }) + if (n !== seq.current) return null + const data = normalizeCheck(raw, level) + setCheck({ status: 'ready', data, sig: contentSig(graph) }) + return data + } catch (e) { + if (n !== seq.current || isAbort(e)) return null + setCheck(isMissingEndpoint(e) ? { status: 'unsupported' } : { status: 'error', error: e }) + return null + } + }, [workflowId, level]) + + // 打开、换等级:从头查一遍,上一档的预览和选择都不再适用。只有「修复已经落到画布上、 + // 还没存上」这件事跨档留着:换了等级它照样没存,「重试保存」得一直看得见 + useEffect(() => { + previewCtl.current?.abort() + setPreview(null) + setApply((a) => (pendingSave.current && a && unsavedApply(a) ? a : null)) + setChoices({}) + void runCheck(true) + return () => { + checkCtl.current?.abort() + previewCtl.current?.abort() + } + }, [runCheck]) + + const requestPreview = useCallback(async (req: PreviewRequest) => { + if (!workflowId) return + previewCtl.current?.abort() + const ctl = new AbortController() + previewCtl.current = ctl + lastRequest.current = req + const graph = canvasGraph() + const assist = !!req.assist + setApply(null) + setPreview({ status: 'loading', assist }) + try { + const raw = await api.workflows.autofix(workflowId, { + level, graph, apply: req.apply, + ...(req.choices && Object.keys(req.choices).length ? { choices: req.choices } : {}), + ...(assist ? { assist: true } : {}), + }, { signal: ctl.signal }) + if (ctl.signal.aborted) return + setPreview({ status: 'ready', assist, result: normalizeAutofix(raw), sig: contentSig(graph) }) + } catch (e) { + if (ctl.signal.aborted || isAbort(e)) return + setPreview(isMissingEndpoint(e) ? { status: 'unsupported', assist } : { status: 'error', assist, error: e }) + } + }, [workflowId, level]) + + const discard = useCallback(() => { + previewCtl.current?.abort() + setPreview(null) + setApply(null) + }, []) + + /** + * 存草稿,和页面自己的保存同一套规矩:画布锁着(助手在改、正式运行在跑)不存——助手改到一半 + * 的图存下去就是半成品;恢复旧版本留下的「回滚到 vN」不能被这一句盖掉,两句接着写 + */ + const save = useCallback(async () => { + const what = pendingSave.current + if (!what) return + const studio = useStudio.getState() + const lock = editLockOf(studio) + if (lock) { + setApply({ status: 'locked', why: EDIT_LOCK_TEXT[lock], unsaved: true }) + return + } + const pendingNote = studio.pendingNote.trim() + const note = T.saveNote(what.labels) + setApply({ status: 'saving' }) + try { + await studio.save(pendingNote ? `${pendingNote};${note}` : note) + } catch (e) { + setApply({ status: 'failed', error: e }) + return + } + pendingSave.current = null + void useCatalog.getState().refresh() + setApply({ status: 'checking' }) + await runCheck() + rechecked.current?.() + setPreview(null) + setApply(null) + toast.ok(T.applied(what.count, useStudio.getState().workflow?.version)) + }, [runCheck]) + + const applyPreview = useCallback(async () => { + if (preview?.status !== 'ready' || !preview.result.graph) return + // 预览是按发请求那一刻的画布算的:之后又改过的话,套上去会把那几下盖掉 + if (contentSig(canvasGraph()) !== preview.sig) { + setApply({ status: 'stale' }) + return + } + const { result } = preview + const count = result.changes.length || result.applied.length + const labels = result.changes.map((c) => c.label || fixLabelOf(check.data, c.fix_id)).filter(Boolean) as string[] + if (!useStudio.getState().applyFixes(result.graph!, T.undoLabel(count))) { + // 预览摆着的时候画布锁上了(正式运行开始、助手开始改):落不下去,说清为什么 + const lock = editLockOf(useStudio.getState()) + setApply({ status: 'locked', why: lock ? EDIT_LOCK_TEXT[lock] : EDIT_LOCK_TEXT.formal }) + return + } + pendingSave.current = { labels: labels.length ? labels : [preview.assist ? T.assist : T.fixOne], count } + await save() + }, [preview, check.data, save]) + + const retryPreview = useCallback(() => { + if (lastRequest.current) void requestPreview(lastRequest.current) + }, [requestPreview]) + + const choose = useCallback((fix: PublishFix, value: unknown) => { + setChoices((c) => { + if (!fix.multiple) return { ...c, [fix.id]: value } + const cur = Array.isArray(c[fix.id]) ? c[fix.id] as unknown[] : [] + const next = cur.some((v) => same(v, value)) ? cur.filter((v) => !same(v, value)) : [...cur, value] + return { ...c, [fix.id]: next } + }) + }, []) + + const working = preview?.status === 'loading' || apply?.status === 'saving' || apply?.status === 'checking' + return { + level, check, preview, apply, choices, working, + /** 预览摆着、正在存、存失败(或被锁没存上):发布按钮等这件事办完 */ + pending: preview?.status === 'loading' || preview?.status === 'ready' + || apply?.status === 'saving' || apply?.status === 'checking' || (!!apply && unsavedApply(apply)), + recheck: () => runCheck(), + requestPreview, discard, applyPreview, retryPreview, retrySave: save, choose, + } +} + +function fixLabelOf(data: PublishCheck | undefined, id: string): string { + return data?.fixes.find((f) => f.id === id)?.label ?? '' +} + +/** + * 发布前检查的整块:一句结论(+ 一键修复)、修复预览、问题清单(每条旁边给修法)。 + * gate:真点了发布、被门禁拦下时回的那张单子,有它就列它。 + */ +export function Preflight({ pf, gate, onLocate, stale }: { + pf: PreflightState + gate?: ValidationIssue[] | null + onLocate: (issue: ValidationIssue) => void + /** 问题面板:查完之后画布又改过 */ + stale?: boolean +}) { + const nodeOf = useNodeLookup() + const lock = useEditLock() + const { check } = pf + const fixes = check.data?.fixes ?? [] + const issues = gate ?? check.data?.issues ?? null + // validate 和门禁对同一处各报一条:合成一行,计数也按一处算 + const rows = useMemo(() => (issues ? mergeIssues(issues) : null), [issues]) + const errors = (rows ?? []).filter((r) => r.issue.level === 'error').length + const warns = (rows ?? []).length - errors + const canFix = !lock && check.status !== 'unsupported' + const autoIds = issues ? autoFixIds(issues, fixes) : [] + const labelOf = (id?: string | null) => (id ? nodeOf(id)?.label || id : '') + const nameOf: NodeNameOf = (id) => nodeOf(id)?.label || undefined + const anyFix = !!issues?.some((i) => fixFor(i, fixes)) + // 同一个修复只画一次控件:挂在第一条指向它的问题上(几条问题共用一个修复时,比如全图默认 + // 一改、跟随它的几个节点一起好了),别处再画一组就是两个共用状态的「预览」 + const drawn = new Set() + const fixOf = (issue: ValidationIssue): PublishFix | undefined => { + const fix = canFix ? fixFor(issue, fixes) : undefined + if (!fix || drawn.has(fix.id)) return undefined + drawn.add(fix.id) + return fix + } + + return ( +
+
+ {check.status === 'loading' && !check.data && !gate && ( + {T.checking} + )} + {check.status === 'unsupported' && !gate && ( + {T.unsupported} + )} + {check.status === 'error' && !gate && ( + <> + {T.failed}:{reasonOf(check.error)} + + + )} + {issues && ( + + {errors ? + : } + + {gate ? T.gateBlocked(errors) : errors ? T.blocked(errors) : T.passed} + {warns > 0 && {errors || gate ? ',' : ';'}{T.warnings(warns)}} + + + )} + {check.status === 'loading' && (check.data || gate) && } + + {canFix && autoIds.length > 0 && ( + + )} +
+ {stale && check.status === 'ready' && ( +
+ {T.stale} +
+ )} + {lock && anyFix && ( +
{T.locked(EDIT_LOCK_TEXT[lock])}
+ )} + + {pf.preview && (id ? nodeOf(id)?.type : undefined)} />} + {/* 修复落到画布上却没存上,预览又已经收起了(换过等级):「重试保存」照样得在 */} + {pf.preview?.status !== 'ready' && unsavedFix(pf) && } + + {rows && !!rows.length && ( +
    + {rows.map(({ issue, others }, i) => ( + onLocate(issue)} /> + ))} +
+ )} +
+ ) +} + +function PreflightRow({ issue, others, fix, where, pf, onLocate }: { + issue: ValidationIssue; others: string[]; fix?: PublishFix; where: string; pf: PreflightState; onLocate: () => void +}) { + const err = issue.level === 'error' + const Icon = err ? XCircle : AlertTriangle + return ( +
  • + + {fix && } +
  • + ) +} + +/** 一条问题的修法:auto 一个「修复」,choice 一组选项(不替人选),assist 交给 Copilot */ +function FixControl({ fix, pf }: { fix: PublishFix; pf: PreflightState }) { + const value = pf.choices[fix.id] + if (fix.kind === 'choice') { + const ready = choiceReady(fix, value) + const picked = (v: unknown) => (fix.multiple + ? Array.isArray(value) && value.some((x) => same(x, v)) + : value !== undefined && same(value, v)) + return ( +
    + {fix.label}{fix.multiple ? ` · ${T.multiple}` : ''} +
    + {(fix.options ?? []).map((o, k) => ( + + ))} +
    +
    + + {!ready && {T.chooseFirst}} +
    +
    + ) + } + const assist = fix.kind === 'assist' + return ( +
    + + {fix.label} +
    + ) +} + +/** 修复预览:逐项「节点 · 字段:原值 → 新值」,没采用的写原因,Copilot 的问题原样摆出来 */ +function FixPreview({ pf, labelOf, nameOf, typeOf }: { + pf: PreflightState; labelOf: (id?: string | null) => string; nameOf: NodeNameOf + typeOf: (id?: string | null) => string | undefined +}) { + const dirty = useStudio((s) => s.dirty) + const p = pf.preview! + const box = 'rounded-md border px-2.5 py-2' + // 点的可能是清单最底下那一条的「修复」:预览在清单上面出来,得让人看得见 + const ref = useRef(null) + useEffect(() => { ref.current?.scrollIntoView({ block: 'nearest' }) }, [p.status]) + if (p.status === 'loading') { + return ( +
    + + {p.assist ? T.assisting : T.previewing} + +
    + ) + } + if (p.status === 'unsupported' || p.status === 'error') { + return ( +
    + + + {p.status === 'unsupported' ? T.autofixMissing : `${T.autofixFailed}:${reasonOf(p.error)}`} + + {p.status === 'error' && ( + + )} + +
    + ) + } + const { result } = p + const applying = pf.apply?.status === 'saving' || pf.apply?.status === 'checking' + const unsaved = unsavedFix(pf) + const left = result.remaining.filter((i) => i.level === 'error').length + const usable = !!result.graph && (result.changes.length > 0 || result.applied.length > 0) + return ( +
    +
    {result.changes.length ? T.previewTitle(result.changes.length) : T.noChange}
    + {!!result.changes.length && ( +
      + {result.changes.map((c, i) => { + const where = c.node_title || labelOf(c.node_id) || T.whole + const field = fixFieldLabel(c.field, typeOf(c.node_id)) + return ( +
    • + + 「{where}」{field && ` · ${field}`}: + + + + + {c.label && {c.label}} +
    • + ) + })} +
    + )} + {!!result.rejected.length && ( +
    +
    {T.rejected}
    +
      + {result.rejected.map((r, i) => ( +
    • + + + {r.fix_id === 'assist' ? T.assistSaid : fixLabelOf(pf.check.data, r.fix_id) || r.fix_id}:{r.reason} + +
    • + ))} +
    +
    + )} + {result.assist && (result.assist.summary || !!result.assist.questions?.length) && ( +
    + {result.assist.summary && ( +
    + + {T.assistSaid}:{result.assist.summary} +
    + )} + {!!result.assist.questions?.length && ( +
    +
    {T.questions}
    +
      + {result.assist.questions.map((q, i) =>
    • {q}
    • )} +
    +
    + )} +
    + )} + {usable && ( +
    + {result.ok ? T.afterOk : T.afterLeft(left)} +
    + )} + + {pf.apply?.status === 'stale' &&
    {T.stalePreview}
    } + {pf.apply?.status === 'locked' && !pf.apply.unsaved && ( +
    {T.locked(pf.apply.why)}
    + )} + {unsaved && } + {!unsaved && ( +
    + + {applying ? (pf.apply?.status === 'saving' ? T.saving : T.rechecking) + : usable ? `${T.applyNote}${dirty ? `。${T.dirtyNote}` : ''}` : ''} + + + {usable && ( + + )} +
    + )} +
    + ) +} + +/** + * 预览里的一个值。整份契约(生成契约骨架)一键一行地写,别的压成一行;节点引用写节点名 + */ +function FixValue({ value, field, nameOf, className, ...data }: { + value: unknown; field?: string | null; nameOf: NodeNameOf; className?: string + 'data-fix-before'?: string; 'data-fix-after'?: string +}) { + const lines = fixValueLines(value, field, nameOf) + if (!lines) return {fixValueText(value, field, nameOf)} + return ( + + {lines.map((line, i) => {line})} + + ) +} + +/** + * 修复已经落到画布上、还没存上:存失败了,或者重试保存时画布锁着(助手在改、正式运行在跑)。 + * 说清楚为什么,给「重试保存」。预览里有一份;预览收起了(换过等级)就在清单上面单独一份 + */ +function SaveTrouble({ pf }: { pf: PreflightState }) { + const a = pf.apply + if (!a || !unsavedApply(a)) return null + const failed = a.status === 'failed' + return ( +
    + + {failed ? `${T.saveFailed}:${reasonOf(a.error)}` : a.status === 'locked' ? `${T.saveFailed}(${a.why})` : ''}。 + {T.saveFailedHint} + + +
    + ) +} + +const LEVELS = ['published', 'governed'] as const + +/** + * 问题面板里的「发布前检查」:选一档,查一遍,同一套修法。画布上就能提前发现、提前修, + * 不用等到点发布那一刻。点一条照样定位到节点 + */ +export function PublishPreflightPane({ onLocate }: { onLocate: (issue: ValidationIssue) => void }) { + const workflow = useStudio((s) => s.workflow) + const [level, setLevel] = useState(workflow?.status === 'governed' ? 'governed' : 'published') + // 修复正在出预览、存草稿、重查时不换档(和发布弹窗同一条):方向键也换不了 + const busy = useRef(false) + const pick = useCallback((l: PublishLevel) => { if (!busy.current) setLevel(l) }, []) + const radio = useRadioGroup(LEVELS, level, pick) + const pf = usePreflight(workflow?.id, level) + busy.current = pf.working + // 查完之后画布又改了:结论可能已经过时。跟着 issues 重算(每次改图后校验都会换新), + // 不订阅 nodes——那样拖一帧就要把整张图序列化一遍 + const tick = useStudio((s) => s.issues) + const sig = pf.check.sig + const stale = useMemo(() => sig != null && contentSig(canvasGraph()) !== sig, [tick, sig]) + if (!workflow) return null + return ( +
    +
    +
    + {LEVELS.map((l) => ( + + ))} +
    + + +
    + +
    + ) +} diff --git a/frontend/src/canvas/issues.ts b/frontend/src/canvas/issues.ts index b46b7cc2..5fe2e053 100644 --- a/frontend/src/canvas/issues.ts +++ b/frontend/src/canvas/issues.ts @@ -10,8 +10,11 @@ * 例外是「提示词要求用 X,但没绑定」:后端的 field 指着点名的那句提示词,可修法在工具 * 那一栏,定位落到工具上(见 toolBindingOf)。 */ +import { ApiError } from '../api/client' +import { APPROVAL_POLICY_LABEL } from '../lib/terms' +import { NODE_DEFS } from './nodeDefs' import type { FlowNode } from '../store/studio' -import type { ValidationIssue } from '../types' +import type { AutofixResult, GraphSpec, PublishCheck, PublishFix, PublishLevel, ValidationIssue } from '../types' export interface FieldRef { /** 顶层配置键:prompt、cases、fields… 'label' 表示节点名称 */ @@ -187,3 +190,227 @@ export function problemsOf( } return list.sort((a, b) => order(a) - order(b) || (a.level === b.level ? 0 : a.level === 'error' ? -1 : 1)) } + +// ------------------------------------------------------------------------- +// 发布前检查与自动修复(POST /workflows/{id}/publish-check、/autofix) +// +// 接口是这一期才有的,老后端没有;新后端也可能少给字段。这里把回来的东西收成确定的形状, +// 认不出的丢掉,不猜:少了 fixes 就当没有修复,少了 changes 就当没有改动。 +// ------------------------------------------------------------------------- + +const asArray = (v: unknown): T[] => (Array.isArray(v) ? v as T[] : []) +const asText = (v: unknown): string => (typeof v === 'string' ? v : v == null ? '' : String(v)) + +function normalizeIssue(raw: any): ValidationIssue | null { + if (!raw || typeof raw !== 'object' || !raw.message) return null + return { + level: raw.level === 'error' ? 'error' : 'warning', + message: asText(raw.message), + node_id: raw.node_id ?? null, + edge_id: raw.edge_id ?? null, + field: raw.field ?? null, + code: typeof raw.code === 'string' ? raw.code : null, + fix: typeof raw.fix === 'string' && raw.fix ? raw.fix : null, + } +} + +function normalizeFix(raw: any): PublishFix | null { + if (!raw || typeof raw !== 'object' || typeof raw.id !== 'string' || !raw.id) return null + const kind = raw.kind === 'choice' || raw.kind === 'assist' ? raw.kind : raw.kind === 'auto' ? 'auto' : null + if (!kind) return null + const options = asArray(raw.options) + .filter((o) => o && typeof o === 'object' && 'value' in o) + .map((o) => ({ value: o.value, label: asText(o.label ?? o.value), hint: o.hint ? asText(o.hint) : null })) + // 选项类没有候选就没法选:不给控件,比给一个空下拉强 + if (kind === 'choice' && !options.length) return null + return { + id: raw.id, + code: asText(raw.code), + kind, + node_id: raw.node_id ?? null, + label: asText(raw.label) || raw.id, + preview: raw.preview && typeof raw.preview === 'object' ? raw.preview : null, + options, + multiple: !!raw.multiple, + default: raw.default, + } +} + +export function normalizeCheck(raw: any, level: PublishLevel): PublishCheck { + const issues = asArray(raw?.issues).map(normalizeIssue).filter((i): i is ValidationIssue => !!i) + const fixes = asArray(raw?.fixes).map(normalizeFix).filter((f): f is PublishFix => !!f) + return { + level: raw?.level === 'governed' || raw?.level === 'published' ? raw.level : level, + ok: typeof raw?.ok === 'boolean' ? raw.ok : !issues.some((i) => i.level === 'error'), + issues, + fixes: [...new Map(fixes.map((f) => [f.id, f])).values()], + } +} + +export function normalizeAutofix(raw: any): AutofixResult { + const graph = raw?.graph && typeof raw.graph === 'object' && Array.isArray(raw.graph.nodes) ? raw.graph as GraphSpec : null + const remaining = asArray(raw?.remaining).map(normalizeIssue).filter((i): i is ValidationIssue => !!i) + const assist = raw?.assist && typeof raw.assist === 'object' + ? { ok: !!raw.assist.ok, summary: asText(raw.assist.summary), questions: asArray(raw.assist.questions).map(asText).filter(Boolean) } + : null + return { + graph, + changes: asArray(raw?.changes).filter((c) => c && typeof c === 'object').map((c) => ({ + fix_id: asText(c.fix_id), node_id: c.node_id ?? null, node_title: c.node_title ?? null, + field: c.field ?? null, before: c.before, after: c.after, label: c.label ? asText(c.label) : null, + })), + applied: asArray(raw?.applied).map(asText).filter(Boolean), + rejected: asArray(raw?.rejected).filter((r) => r && typeof r === 'object') + .map((r) => ({ fix_id: asText(r.fix_id), reason: asText(r.reason) || '没说原因' })), + remaining, + assist, + ok: typeof raw?.ok === 'boolean' ? raw.ok : !remaining.some((i) => i.level === 'error'), + } +} + +/** + * 老后端没有这个接口:FastAPI 对不存在的路由回 404 {"detail":"Not Found"}(方法不对是 405)。 + * 别的 404(工作流不在了)是真的出错,要照实说 + */ +export function isMissingEndpoint(e: unknown): boolean { + if (!(e instanceof ApiError) || e.kind !== 'http') return false + return e.status === 405 || (e.status === 404 && (e.detail == null || e.detail === 'Not Found')) +} + +/** + * 这条问题的修复:后端点了 fix id 就按 id 找;没点的(/publish 被拦时的回包只有 code)按 code + 节点认。 + * 认不出时和后端 autofix.annotate 同一个退路:图级问题(没有节点,比如「没有带契约的出口」, + * 修复却落在唯一的出口上)认同一编号的第一条修复;图级修复(改的是全图默认,不落在任何节点上) + * 认同一编号的每一条问题。再认不出就没有 + */ +export function fixFor(issue: ValidationIssue, fixes: PublishFix[]): PublishFix | undefined { + if (issue.fix) return fixes.find((f) => f.id === issue.fix) + if (!issue.code) return undefined + const same = fixes.filter((f) => f.code === issue.code) + const exact = same.find((f) => (f.node_id ?? null) === (issue.node_id ?? null)) + if (exact) return exact + return issue.node_id == null ? same[0] : same.find((f) => f.node_id == null) +} + +/** + * 同一处问题只列一行:validate 和门禁常对同一处各报一条(同 code、同节点,说法不同), + * 列两行就是两组一样的修复控件、「会被拦下 2 处」。合成的那一行取最重的级别、第一条 error 的说法, + * 其余说法收进 others(悬停可见)。没有 code 的认不出是不是同一处,照原样各列一行 + */ +export function mergeIssues(issues: ValidationIssue[]): { issue: ValidationIssue; others: string[] }[] { + const out: { issue: ValidationIssue; others: string[] }[] = [] + const at = new Map() + for (const issue of issues) { + const key = issue.code ? `${issue.code}\u0000${issue.node_id ?? ''}` : null + const i = key != null ? at.get(key) : undefined + if (i == null) { + if (key != null) at.set(key, out.length) + out.push({ issue, others: [] }) + continue + } + const row = out[i] + const cur = row.issue + const worse = issue.level === 'error' && cur.level !== 'error' + const [keep, drop] = worse ? [issue, cur] : [cur, issue] + row.issue = { ...keep, fix: cur.fix ?? issue.fix ?? null, field: keep.field ?? drop.field ?? null } + if (drop.message !== keep.message && !row.others.includes(drop.message)) row.others.push(drop.message) + row.others = row.others.filter((m) => m !== keep.message) + } + return out +} + +/** 「一键修复」会应用的那几项:挂在眼前这些问题上的 auto 修复,去重 */ +export function autoFixIds(issues: ValidationIssue[], fixes: PublishFix[]): string[] { + const ids = issues.map((i) => fixFor(i, fixes)).filter((f) => f?.kind === 'auto').map((f) => f!.id) + return [...new Set(ids)] +} + +/** 选项类修复选好了没有:多选至少一项,单选要有值 */ +export function choiceReady(fix: PublishFix, value: unknown): boolean { + if (fix.multiple) return Array.isArray(value) && value.length > 0 + return value !== undefined +} + +/** + * 画布内容的签名:节点的类型、名字、配置和连线,不含位置。预览是按发请求那一刻的图算的, + * 应用时画布内容变了就对不上;只是挪了挪节点不算变 + */ +export function contentSig(graph: GraphSpec): string { + return JSON.stringify({ + nodes: (graph.nodes ?? []).map((n) => [n.id, n.type, n.data?.label ?? '', n.data?.config ?? {}]), + edges: (graph.edges ?? []).map((e) => [e.source, e.target, e.sourceHandle ?? null]), + }) +} + +/** 出具契约里的几个键在界面上的叫法。拆成几行写契约骨架时也按这个顺序 */ +const CONTRACT_KEYS: Record = { + metrics_from: '指标来自', report_from: '报告来自', required: '必需指标', expected: '期望指标', + strict: '严格模式', narrative: '叙述', cells: '单元格引用', allow_numbers: '允许不带出处的数', +} + +/** 不在节点定义的字段表里、由检查器自己画的几项(子工作流钉的版本、升版处置),以及图级的全图默认 */ +const EXTRA_FIELDS: Record = { + workflow_version: '钉住版本', upgrade_policy: '上游发了新版本时', defaults: '全图默认', +} +/** 点号后面那一段的叫法:契约里的键,全图默认里的审批策略 */ +const SUB_KEYS: Record = { ...CONTRACT_KEYS, approval: '审批策略' } + +/** 值是节点 id 的几个键(报告、契约的 metrics_from,契约的 report_from):预览里写节点名,不写 id */ +const NODE_REF_KEYS = new Set(['metrics_from', 'report_from']) + +/** 节点 id → 画布上的名字;画布上没有这个节点时返回 undefined */ +export type NodeNameOf = (id: string) => string | undefined + +const isRecord = (v: unknown): v is Record => !!v && typeof v === 'object' && !Array.isArray(v) + +/** 修复改的是哪个字段:顶层键用节点定义里的叫法,契约里的键另有一张表,其余原样 */ +export function fixFieldLabel(field: string | null | undefined, nodeType?: string): string { + if (!field) return '' + const [head, ...rest] = field.split('.') + if (head === 'label') return '节点名称' + const def = nodeType ? NODE_DEFS[nodeType as keyof typeof NODE_DEFS] : undefined + const base = def?.fields.find((f) => f.key === head)?.label ?? EXTRA_FIELDS[head] ?? head + return [base, ...rest.map((k) => SUB_KEYS[k] ?? k)].join(' · ') +} + +/** + * 改动前后的值怎么写:空写「(空)」,审批策略写界面上的选项文字,节点引用写「节点名」 + * (画布上找不到的照写 id——它本来就指着一个不存在的节点),契约骨架按键写成「指标来自:…;…」, + * 其余压成一行 + */ +export function fixValueText(value: unknown, field?: string | null, nameOf?: NodeNameOf): string { + if (value === undefined || value === null || value === '' || (Array.isArray(value) && !value.length) + || (isRecord(value) && !Object.keys(value).length)) return '(空)' + const key = field?.split('.').pop() + if (key === 'approval' && typeof value === 'string' && value in APPROVAL_POLICY_LABEL) { + return APPROVAL_POLICY_LABEL[value as keyof typeof APPROVAL_POLICY_LABEL] + } + if (typeof value === 'boolean') return value ? '是' : '否' + if (key === 'workflow_version' && (typeof value === 'number' || /^\d+$/.test(String(value)))) return `v${value}` + if (key && NODE_REF_KEYS.has(key)) { + const ids = Array.isArray(value) ? value : [value] + if (ids.every((v) => typeof v === 'string')) { + return ids.map((id) => { const name = nameOf?.(id as string); return name ? `「${name}」` : id }).join('、') + } + } + const lines = fixValueLines(value, field, nameOf) + if (lines) return lines.join(';') + if (Array.isArray(value) && value.every((v) => typeof v !== 'object' || v === null)) return value.join('、') + const text = typeof value === 'string' ? value : JSON.stringify(value) + return text.length > 80 ? `${text.slice(0, 80)}…` : text +} + +/** + * 整份契约(field 为 contract、值是对象,比如「生成契约骨架」)拆成一键一行:「指标来自:「周报口径」」 + * 「必需指标:gmv、orders」。键名用界面叫法、按 CONTRACT_KEYS 的顺序,值和单独改一项时写法相同。 + * 不是整份契约的返回 null + */ +export function fixValueLines(value: unknown, field?: string | null, nameOf?: NodeNameOf): string[] | null { + if (field !== 'contract' || !isRecord(value) || !Object.keys(value).length) return null + const order = Object.keys(CONTRACT_KEYS) + const keys = Object.keys(value).sort((a, b) => { + const [x, y] = [order.indexOf(a), order.indexOf(b)] + return (x < 0 ? order.length : x) - (y < 0 ? order.length : y) + }) + return keys.map((k) => `${CONTRACT_KEYS[k] ?? k}:${fixValueText(value[k], `contract.${k}`, nameOf)}`) +} diff --git a/frontend/src/canvas/nodeDefs.ts b/frontend/src/canvas/nodeDefs.ts index 436546da..c3ce5250 100644 --- a/frontend/src/canvas/nodeDefs.ts +++ b/frontend/src/canvas/nodeDefs.ts @@ -2,13 +2,13 @@ import { Bot, Braces, Brain, CheckCircle2, Code2, Database, FileInput, FileOutput, FileText, Gauge, GitBranch, Hand, Repeat, Search, Shuffle, Users, Wrench, } from 'lucide-react' -import { APPROVAL_POLICY_LABEL, NODE_TYPE_LABEL } from '../lib/terms' +import { APPROVAL_POLICY_LABEL, NODE_TYPE_LABEL, UPGRADE_POLICY_LABEL } from '../lib/terms' import type { NodeType } from '../types' export type FieldType = | 'text' | 'textarea' | 'prompt' | 'code' | 'number' | 'select' | 'switch' | 'json' | 'model' | 'tools' | 'skills' | 'collection' - | 'ioFields' | 'cases' | 'agents' | 'metricsList' | 'nodeRefs' + | 'ioFields' | 'cases' | 'agents' | 'metricsList' | 'nodeRefs' | 'caliberFrom' /** * 这个字段里写的是什么语法。 @@ -36,11 +36,30 @@ export interface FieldDef { step?: number /** 只在满足条件时显示,避免面板一次糊一屏用不上的选项 */ when?: (config: Record) => boolean + /** + * 看得见但暂时用不了:返回一句为什么(「先写 Schema 才能开」),返回 null 就能用。 + * 和 when 的区别:藏起来的人不知道有这个选项,禁用的知道、也知道差什么 + */ + disabled?: (config: Record) => string | null /** nodeRefs:只能选这一类的上游节点(报告的 metrics_from 只收口径卡) */ refType?: NodeType advanced?: boolean } +/** + * 写没写结构化输出 Schema:对象形状(type: object 或带 properties),画布里存成 JSON 文本的也认, + * 和后端 llm._output_schema 的认法一致 + */ +export function hasOutputSchema(schema: unknown): boolean { + let value = schema + if (typeof value === 'string') { + if (!value.trim()) return false + try { value = JSON.parse(value) } catch { return false } + } + return !!value && typeof value === 'object' && !Array.isArray(value) + && ((value as any).type === 'object' || 'properties' in (value as any)) +} + /** 字段实际的语法:显式声明优先,否则按类型推 */ export function syntaxOf(field: Pick): FieldSyntax { if (field.syntax) return field.syntax @@ -199,6 +218,18 @@ export const NODE_DEFS: Record = { help: '默认关闭:一轮只调一个工具,看到结果再想下一步。开启后一轮可发多个,更快,' + '但这一批中间没有新的思考', }, + { + key: 'output_schema', label: '结构化输出 Schema', type: 'json', advanced: true, + help: '要把查到的数交给口径卡时写。写了还要开下面的「按出处核对字段」才生效;' + + '只写 Schema 不开,产出和以前一样只有文字', + }, + { + key: 'cite_fields', label: '按出处核对字段', type: 'switch', advanced: true, + // 已经开着的不锁:开了之后又清掉 Schema,得还能把它关上 + disabled: (c) => (c.cite_fields || hasOutputSchema(c.output_schema) ? null : '先在上面写「结构化输出 Schema」才能开'), + help: '结束后多一次抽取调用:模型为 Schema 里每个字段写出它来自哪次查询的哪一格,系统按查询快照取值、' + + '核对,对不上的以快照为准;查不到的记为空,不兜底成 0。下游口径卡用 vars.变量名.字段 读', + }, ...MODEL_FIELDS, ...COMMON_TAIL, ], @@ -294,6 +325,12 @@ export const NODE_DEFS: Record = { ], }, { key: 'fail_fast', label: '执行失败即中断', type: 'switch', advanced: true }, + { + key: 'evidence_role', label: '证据角色', type: 'select', advanced: true, + options: [{ value: '', label: '计算(默认)' }, { value: 'source', label: '取数' }], + help: '报告不能直接引用代码节点的产出,沙箱算出来的数要进口径卡。只是把外部数据原样取回来' + + '(调接口、读文件)的标「取数」;做了业务计算的留在「计算」,口径卡用它的数时会提醒', + }, ...COMMON_TAIL, ], defaults: { language: 'python', code: 'print("hello")', timeout: 30, network: false, fail_fast: true }, @@ -532,9 +569,24 @@ export const NODE_DEFS: Record = { description: '受控指标集:所有算术在这里发生,叙述层只能引用', hasTarget: true, sources: [{ id: 'out', label: '' }], fields: [ - { key: 'caliber', label: '口径名称', type: 'text', syntax: 'template', placeholder: '周报口径' }, - { key: 'caliber_version', label: '口径版本', type: 'text', placeholder: 'v1' }, - { key: 'metrics', label: '指标定义', type: 'metricsList' }, + { key: 'caliber_from', label: '口径卡从哪来', type: 'caliberFrom' }, + { + key: 'caliber', label: '口径名称', type: 'text', syntax: 'template', placeholder: '周报口径', + when: (c) => !c.caliber_from, + }, + { key: 'caliber_version', label: '口径版本', type: 'text', placeholder: 'v1', when: (c) => !c.caliber_from }, + { key: 'metrics', label: '指标定义', type: 'metricsList', when: (c) => !c.caliber_from }, + { + key: 'upgrade_policy', label: '上游发了新版本时', type: 'select', when: (c) => !!c.caliber_from, + options: [ + { value: '', label: '没声明:上游有新版本时挡住正式运行' }, + { value: 'recompute', label: `${UPGRADE_POLICY_LABEL.recompute}(recompute)` }, + { value: 'dual', label: `${UPGRADE_POLICY_LABEL.dual}(dual)` }, + { value: 'incomparable', label: `${UPGRADE_POLICY_LABEL.incomparable}(incomparable)` }, + ], + help: '钉住的那一版之后上游又发了新版本,正式运行前必须声明怎么处置,和子工作流的升版处置是同一套规则;' + + '声明了就照它执行,并记进运行记录', + }, { key: 'on_missing', label: '缺输入时', type: 'select', advanced: true, options: [{ value: '', label: '整个节点失败' }, { value: 'null', label: '记为空值,交给出具契约判档' }], diff --git a/frontend/src/dev/ui-harness.tsx b/frontend/src/dev/ui-harness.tsx index 169930e7..f52d0b65 100644 --- a/frontend/src/dev/ui-harness.tsx +++ b/frontend/src/dev/ui-harness.tsx @@ -25,6 +25,7 @@ import * as evidence from '../lib/evidence' import { EvidenceDoc, EvidenceField } from '../run/EvidenceDoc' import { Markdown } from '../run/Markdown' import evidenceFixture from '../run/__tests__/evidence-doc.json' +import evidenceQuery from '../run/__tests__/evidence-query.json' import mdPinned from '../run/__tests__/markdown-pinned.json' import type { EvidenceDocData } from '../types' import '../index.css' @@ -185,6 +186,8 @@ function DatasourceConsumers() { } const EV_DOC = evidenceFixture.doc as unknown as EvidenceDocData +/** 第二期:查询单元格、整表、口径卡来源(evidence-query.json,compose_doc 真跑出来的) */ +const EV_QUERY = evidenceQuery.doc as unknown as EvidenceDocData const EV_LEGACY = evidenceFixture.legacy /** 同一份旧契约的出具,去掉位置:老运行就是这样,只能按字符串标 */ const LEGACY_LOOSE = { @@ -253,6 +256,19 @@ function EvidenceOnly() {
    + {/* + 第二期:报告直接引用查询单元格、整表,口径卡的输入核对到 agent 字段和查询快照。 + 片段接口由 check-evidence 用 page.route 伪造(按 runId 认出这一份夹具) + */} +
    +
    +
    查询单元格、整表、口径卡来源
    + +
    +
    + +
    +
    长报告:{EV_LONG.blocks.length} 块,块级 content-visibility,超过 3000 字先折叠
    @@ -544,6 +560,7 @@ function Harness() { // 检查脚本直接拿应用同一份模块实例测 lib:instanceof ApiError 才靠得住 lib: { format, status, keys, terms, errors, explain, health, actor, validation, evidence }, evidenceFixture, + evidenceQuery, } createRoot(document.getElementById('root')!).render( diff --git a/frontend/src/lib/evidence.ts b/frontend/src/lib/evidence.ts index 9ab43f9d..7d4dc956 100644 --- a/frontend/src/lib/evidence.ts +++ b/frontend/src/lib/evidence.ts @@ -11,10 +11,12 @@ * 不存在)。其余几种先把外观定下,后续期接上时不用再各处补。 */ -import { EVIDENCE_STATE_LABEL, EVIDENCE_TEXT, evidenceTally } from './terms' +import { EVIDENCE_KIND_LABEL, EVIDENCE_STATE_LABEL, EVIDENCE_TEXT, UPGRADE_POLICY_LABEL, evidenceTally } from './terms' +import { NONE, formatNumber, shortId } from './format' import type { - EvidenceBlock, EvidenceDocData, EvidenceFieldRef, EvidenceGraph, EvidenceSeal, EvidenceSegment, - EvidenceSegmentDetail, EvidenceStep, EvidenceUnit, EvidenceViolation, ReviewResult, + EvidenceBlock, EvidenceCaliberSource, EvidenceCaliberUpgrade, EvidenceDocData, EvidenceFieldRef, EvidenceGraph, + EvidenceInput, EvidenceLocator, EvidenceSeal, EvidenceSegment, EvidenceSegmentDetail, EvidenceStep, EvidenceUnit, + EvidenceViolation, ReviewResult, } from '../types' import type { MarkSpec } from '../run/Markdown' @@ -54,7 +56,7 @@ export const EVIDENCE_STATE: Record = { code: 'deterministic', label: EVIDENCE_STATE_LABEL.deterministic, line: 'solid', glyph: '', color: 'var(--st-done)', decoration: mix('--st-done', 60), soft: 'var(--st-done-soft)', alert: false, phase: 1, - hint: '数字由系统从口径卡取值、按口径卡的格式渲染,不是模型写的', + hint: '由系统从证据(口径卡、查询快照、运行输入)里取值、按确定的规则渲染,不是模型写的', }, supported: { code: 'supported', label: EVIDENCE_STATE_LABEL.supported, line: 'badge', glyph: '◆', @@ -123,19 +125,210 @@ export function unitText(unit: Pick): string { return (unit.segments ?? []).filter((s) => s.kind !== 'structural').map((s) => s.text).join('') } -/** 片段的来源说一句:「口径卡指标 环比增幅」「运行输入 week」 */ +/** + * 片段的来源说一句:「口径卡指标 环比增幅」「运行输入 week」「查询 Q3 · 第 6 行 · amount」。 + * 单元格先看 cite 的种类:查询条目在目录里是 query,引用它的一格是 cell + */ export function sourceOf(seg: EvidenceSegment, doc?: Pick | null): string { const cite = seg.cite if (!cite) return '' const entry = cite.alias ? doc?.catalog?.[cite.alias] : undefined + if (cite.kind === 'cell' || (cite.kind !== 'metric' && cite.kind !== 'input' && entry?.kind === 'query')) { + const where = locatorText(cite.locator) + return [EVIDENCE_TEXT.query(cite.alias ?? ''), where].filter(Boolean).join(' · ') + } if (cite.kind === 'metric' || entry?.kind === 'metric') { const name = entry?.name ?? cite.locator?.metric ?? cite.ref ?? '' - return name ? `口径卡指标 ${name}` : '口径卡指标' + return name ? `${EVIDENCE_KIND_LABEL.metric} ${name}` : EVIDENCE_KIND_LABEL.metric } if (cite.kind === 'input' || entry?.kind === 'input') { - return `运行输入 ${entry?.locator?.field ?? cite.locator?.field ?? cite.ref ?? ''}`.trim() + return `${EVIDENCE_KIND_LABEL.input} ${entry?.locator?.field ?? cite.locator?.field ?? cite.ref ?? ''}`.trim() } - return cite.alias ?? cite.ref ?? '' + const kind = cite.kind ? EVIDENCE_KIND_LABEL[cite.kind] : '' + return [kind, cite.alias ?? cite.ref].filter(Boolean).join(' ') +} + +/** + * 查询快照里的位置怎么说:「第 6 行 · amount」「第 1–5 行 · amount」「第 1–5 行 · week、amount」。 + * 行号给人看从 1 数(引用原文 Q3.r5 里的 r5 从 0 数,面板另把原文摆出来) + */ +export function locatorText(loc: EvidenceLocator | null | undefined): string { + if (!loc) return '' + const ints = (v: unknown) => (typeof v === 'number' && Number.isInteger(v) && v >= 0 ? v : null) + let rows = '' + const row = ints(loc.row) + if (row != null) rows = `第 ${formatNumber(row + 1)} 行` + else if (Array.isArray(loc.rows) && loc.rows.length) { + const a = ints(loc.rows[0]) + const b = ints(loc.rows[loc.rows.length - 1]) + if (a != null && b != null) rows = a === b ? `第 ${formatNumber(a + 1)} 行` : `第 ${formatNumber(a + 1)}–${formatNumber(b + 1)} 行` + } + const cols = typeof loc.column === 'string' && loc.column ? loc.column + : loc.columns && typeof loc.columns === 'object' ? Object.values(loc.columns).filter((c) => typeof c === 'string').join('、') + : '' + return [rows, cols].filter(Boolean).join(' · ') +} + +/** 证据里的值怎么写:数带千分位、不丢小数;布尔写是否;拿不到写「—」 */ +export function evidenceValue(v: unknown): string { + if (v == null || v === '') return NONE + if (typeof v === 'number') return Number.isFinite(v) ? v.toLocaleString('en-US', { maximumFractionDigits: 20 }) : String(v) + if (typeof v === 'boolean') return v ? '是' : '否' + if (typeof v === 'object') return JSON.stringify(v) + return String(v) +} + +/** + * 查询步骤的窗口:接口只给被引用的行加前后各 2 行。 + * + * - offset:rows[0] 在快照里是第几行;没给就当从 0 开始、也不说位置 + * - marks:要高亮的行,换算成窗口里的下标。highlight.rows 按快照行号给(方案第 5 节); + * 全都落在窗口里才这么认,否则看它们是不是本来就是窗口下标(row_offset 之前的号、老接口) + * - windowed:窗口没盖住整份快照,要说「仅显示被引用的行及前后各 2 行」 + */ +export function queryWindow(step: Pick): { + columns: string[]; rows: unknown[][] + /** 每一行在快照里是第几行(从 0 数);不知道时是 null */ + index: number[] | null + total: number | null + marks: number[]; cols: string[] + /** 精确到格的高亮:[窗口下标, 列名];接口没给时 null,按 marks × cols 画 */ + cells: [number, string][] | null + windowed: boolean + /** 「第 4–8、10–12 行」:窗口在快照里的位置,从 1 数;不知道时 '' */ + span: string +} { + const rows = Array.isArray(step.rows) ? step.rows.filter(Array.isArray) as unknown[][] : [] + const columns = Array.isArray(step.columns) ? step.columns.map(String) : [] + const int = (v: unknown) => (typeof v === 'number' && Number.isInteger(v) && v >= 0 ? v : null) + const total = int(step.total_rows) + // 行号:row_index 一行一个(窗口可以不连续)最准;只有 row_offset 时按连续的算 + const given = Array.isArray(step.row_index) ? step.row_index.map(int) : null + const offset = int(step.row_offset) + const index = given && given.length === rows.length && given.every((r) => r != null) ? given as number[] + : offset != null ? rows.map((_, i) => offset + i) + : null + const toWindow = (r: number): number => { + if (index) { + const at = index.indexOf(r) + if (at >= 0) return at + } + return -1 + } + const inWindow = (r: number) => r >= 0 && r < rows.length + // 行号按快照认(方案第 5 节),认得出几个标几个:窗口被截到 MAX_WINDOW_ROWS 时后面几行不在窗口里, + // 不能因此一行都不标。一个都认不出、却都落在窗口里的,当成窗口下标(老接口);认不出的是 -1 + const place = (list: number[]): number[] | null => { + const hit = list.map(toWindow) + return hit.some((i) => i >= 0) ? hit : list.every(inWindow) ? list : null + } + const wanted = (Array.isArray(step.highlight?.rows) ? step.highlight.rows : []).map(int).filter((r): r is number => r != null) + const marks = ((wanted.length && place(wanted)) || []).filter((i) => i >= 0) + const cols = (Array.isArray(step.highlight?.cols) ? step.highlight.cols : []).map(String).filter((c) => columns.includes(c)) + const rawCells = (Array.isArray(step.highlight?.cells) ? step.highlight.cells : []) + .filter((c) => Array.isArray(c) && int(c[0]) != null && columns.includes(String(c[1]))) + const cellRows = rawCells.length ? place(rawCells.map((c) => c[0] as number)) : null + const placed = cellRows ? rawCells.map((c, i) => [cellRows[i], String(c[1])] as [number, string]).filter(([r]) => r >= 0) : [] + const cells = placed.length ? placed : null + const windowed = !!index && total != null && index.length < total + return { columns, rows, index, total, marks, cols, cells, windowed, span: index ? spanText(index) : '' } +} + +/** [3,4,5,6,7,9,10,11] → 「第 4–8、10–12 行」(从 1 数) */ +function spanText(index: number[]): string { + if (!index.length) return '' + const sorted = [...index].sort((a, b) => a - b) + const parts: string[] = [] + let start = sorted[0] + let prev = sorted[0] + for (const r of [...sorted.slice(1), Number.NaN]) { + if (r === prev + 1) { prev = r; continue } + parts.push(start === prev ? formatNumber(start + 1) : `${formatNumber(start + 1)}–${formatNumber(prev + 1)}`) + start = r + prev = r + } + return `第 ${parts.join('、')} 行` +} + +/** + * 这个输入对应链里的哪一个查询步骤:先按快照工件认,再按接口补的全局编号(input.query、 + * input.cell 的前缀)认。agent 字段的 ref 是那个节点内部的编号,不拿它对。认不出返回 -1 + */ +export function queryOf( + input: Pick, queries: Pick[], +): number { + if (input.artifact) { + const i = queries.findIndex((q) => q.artifact === input.artifact) + if (i >= 0) return i + } + const alias = input.query || (typeof input.cell === 'string' ? input.cell.split('.')[0] : '') + return alias ? queries.findIndex((q) => q.alias === alias) : -1 +} + +export type SourceTone = 'ok' | 'warn' | 'alert' | 'muted' + +/** + * 输入来源那一行说什么、多醒目。 + * + * - agent 字段(开了 cite_fields):与快照一致 / 模型报 X 快照是 Y 已按快照取值 / 没查到记为空; + * unresolved 和 missing(模型写了 from: null)同一种说法:值都是空的,没有兜底成 0 + * - cell() 取数:与快照一致 / 算的时候用的值和快照不一样(值没被换掉,照实说)/ 核对不到 + * - 代码节点:取数角色提醒(核对不到快照),计算角色醒目(沙箱里的算术不该绕开口径卡) + * - 其余(运行输入、老运行):一期的 ok / missing + */ +export function inputSource(input: EvidenceInput): { tone: SourceTone; text: string; reason?: string } { + const status = input.status ?? '' + if (input.via === 'agent_field') { + if (status === 'verified') return { tone: 'ok', text: EVIDENCE_TEXT.sourceVerified } + if (status === 'mismatch') { + return { tone: 'warn', text: EVIDENCE_TEXT.sourceMismatch(evidenceValue(input.model_value), evidenceValue(input.value)) } + } + return { tone: 'warn', text: EVIDENCE_TEXT.sourceEmpty, reason: input.reason || undefined } + } + if (input.via === 'tool_cell') { + if (status === 'verified') return { tone: 'ok', text: EVIDENCE_TEXT.sourceVerified } + if (status === 'mismatch') { + return { tone: 'warn', text: EVIDENCE_TEXT.cellMismatch(evidenceValue(input.value), + evidenceValue(input.snapshot_value !== undefined ? input.snapshot_value : input.value)) } + } + if (input.value == null) return { tone: 'warn', text: EVIDENCE_TEXT.sourceEmpty, reason: input.reason || undefined } + return { tone: 'warn', text: EVIDENCE_TEXT.cellUnresolved, reason: input.reason || undefined } + } + if (input.via === 'code') { + return input.role === 'source' + ? { tone: 'warn', text: EVIDENCE_TEXT.codeSource(input.node_id ?? '') } + : { tone: 'alert', text: EVIDENCE_TEXT.codeCompute(input.node_id ?? '') } + } + if (status === 'missing' || input.value == null) return { tone: 'warn', text: EVIDENCE_TEXT.inputMissing } + return { tone: 'muted', text: '' } +} + +/** 值得在「输入来源」里单独列一行的输入:能核对到快照的、代码节点的、缺的 */ +export const notableInput = (input: EvidenceInput): boolean => + input.via === 'agent_field' || input.via === 'tool_cell' || input.via === 'code' + || input.status === 'missing' || input.value == null + +/** + * 口径卡钉在哪:「口径卡「周报口径」v3,来自「销售周报」v5」。工作流名接口没给就按目录找, + * 再没有就写工作流 id 的前几位——不写 undefined + */ +export function caliberSourceText( + caliber: string | undefined, version: string | undefined, + from: EvidenceCaliberSource | null | undefined, workflowName?: string, +): string | null { + if (!from || typeof from !== 'object' || (!from.workflow_id && !from.workflow_name)) return null + const wf = from.workflow_name || workflowName || (from.workflow_id ? shortId(from.workflow_id, 12) : NONE) + const v = from.workflow_version != null && from.workflow_version !== '' ? `v${String(from.workflow_version).replace(/^v/, '')}` : '' + return EVIDENCE_TEXT.caliberFrom(caliber ?? '', version ?? '', wf, v) +} + +/** 升版处置那一句:「上游已有 v6,按「并排双印新旧口径」处置」。没有升版(null、没写 latest / policy)返回 null */ +export function caliberUpgradeText(up: EvidenceCaliberUpgrade | null | undefined): string | null { + if (!up || typeof up !== 'object' || (!up.policy && !up.policy_label)) return null + const latest = up.latest != null && String(up.latest) !== '' ? `v${String(up.latest).replace(/^v/, '')}` : null + const policy = up.policy_label || (up.policy ? UPGRADE_POLICY_LABEL[up.policy] ?? up.policy : '') + return EVIDENCE_TEXT.caliberUpgrade(latest, policy) } /** 无证据的原因:裸数字,还是引用解析不了(带后端给的人话原因) */ diff --git a/frontend/src/lib/explain.ts b/frontend/src/lib/explain.ts index e94c60f2..795efc2c 100644 --- a/frontend/src/lib/explain.ts +++ b/frontend/src/lib/explain.ts @@ -36,6 +36,13 @@ export interface RunErrorExplain { fixFirst?: boolean /** fix 的站内地址,比笼统的「去工具库」更准:直接打开要改的那一项 */ fixTo?: string + /** 入口上写什么(「去数据页接入」)。不给就按 fix 的默认说法 */ + fixLabel?: string + /** + * 该去改的节点(名字)和出错的不是同一个:整形节点解析上游的文字失败,毛病在上游那个 + * 模型节点,打开整形节点的设置什么也改不了 + */ + fixNode?: string /** 原文,放进「技术细节」 */ raw: string } @@ -61,6 +68,58 @@ export function stripClassPrefix(text: string): string { return s.trim() } +/** 后端 io.TEMPLATE_HINT:模板渲染出来不是合法 JSON、又说不出是哪一处时的老说法 */ +const TEMPLATE_HINT = '检查模板里的引号、逗号,字符串值要用 | json 过滤器输出' + +/** 后端 app/api/runs.py 的 TOOL_MISSING:发起运行时发现绑定的工具在本机不存在 */ +export const RUN_TOOL_MISSING = 'run_tool_missing' +const DATA_TOOL = /^db_(?:query|schema)__/ + +/** + * 「绑定的工具在本机不存在:「X」(调用工具)绑的 db_query__nope;「团队」的成员「研究员」绑的 mcp:a/b。 + * 去数据页接入,或在节点里重新选」。数据源工具(db_query__ / db_schema__)要去数据页接入,自定义和 + * MCP 工具去工具页;两种都缺时入口按第一个缺的走,话里两边都说到 + */ +function explainToolMissing(plain: string, raw: string, coded = false): RunErrorExplain | null { + const m = plain.match(/^绑定的工具在本机不存在[::]\s*([\s\S]+)$/) + // 带着机读码、话却改了说法:照样按这一类讲,名字认不出就只给工具页 + if (!m && !coded) return null + const body = (m ? m[1] : plain).trim() + const cut = body.lastIndexOf('。') + const list = (cut >= 0 ? body.slice(0, cut) : body).trim() + const advice = cut >= 0 ? body.slice(cut + 1).trim() : '' + // 超过 8 处时后端在最后一个名字后面接「等 N 处」:那个「等」不是名字的一部分 + const names = [...list.matchAll(/绑的\s*([^\s;;,,。]+)/g)].map((x) => x[1].replace(/等$/, '')).filter(Boolean) + const first = names[0] ?? '' + const others = names.filter((n) => !DATA_TOOL.test(n)) + const toData = DATA_TOOL.test(first) || (!names.length && /数据页/.test(advice)) + const fixTo = toData ? '/data' + : !others.length ? '/tools' + : others.every((n) => n.startsWith('mcp:')) ? '/tools/mcp' + : others.every((n) => !n.startsWith('mcp:')) ? '/tools/custom' : '/tools' + return { + title: names.length === 1 ? `绑定的工具「${first}」在本机不存在` : '绑定的工具在本机不存在', + reason: `${endStop(list)}这次运行没有发起,前面的节点一个都没跑。`, + action: `${endStop(advice || (toData ? '去数据页接入,或在节点里重新选' : '去工具页接入,或在节点里重新选'))}接入之后重新运行。`, + continuable: false, + fix: 'tools', + fixTo, + fixLabel: toData ? '去数据页接入' : '去工具页接入', + raw, + } +} + +/** + * 发起运行那一下被拒的报错(POST /runs 的 ApiError)。只认「绑定的工具在本机不存在」:认机读码 + * run_tool_missing,老后端没码时认原话。别的交给 toast 的通用翻译,返回 null + */ +export function explainStartError(e: unknown): RunErrorExplain | null { + const code = e && typeof e === 'object' ? (e as { code?: unknown }).code : undefined + const text = e instanceof Error ? e.message : typeof e === 'string' ? e : '' + if (code !== RUN_TOOL_MISSING && !/^绑定的工具在本机不存在/.test(text)) return null + return explainToolMissing(stripClassPrefix(text), text, code === RUN_TOOL_MISSING) +} + /** 直达某个自定义工具的编辑框(工具页读 ?edit= 打开它)。fixTo 和工具库里的「去改」共用 */ export const customToolEditPath = (name: string) => `/tools/custom?edit=${encodeURIComponent(name)}` @@ -135,6 +194,45 @@ export function explainRunError(error: string | null | undefined, detail?: strin if (!text) { return { title: '运行失败,但没有留下原因', action: '打开「原始事件」看最后几条记录。', continuable: true, raw } } + // 整形节点按 JSON 解析失败(后端 io._json_error)。两种原话:出错的位置落在上游模型写的文字里时 + // 点名上游(模板没错,别让人去查模板);否则是模板本身写错了。两种都是图的问题:原样接着跑 + // 拿到的还是同一段文字、同一份模板,只会再失败一次,所以不给「接着跑」,指到画布上去改 + // 标题、原因、怎么办都照原话切,不改写:展开区据此认出原话已经说完了,不再整段贴一遍。 + // 放在最前:原话里带着用户起的节点名(「查询超时订单」「额度查询」),排在后面会被超时、限流、 + // 连不上那几条按整句关键词抢走,又把「接着跑」给回来 + const upstream = plain.match(/^(上游「([^」]+)」输出的不是合法 JSON([^)]*))[,,]\s*([^;;]*)[;;]\s*([\s\S]+)$/) + if (upstream) { + return { + title: upstream[1], + reason: `${endStop(upstream[3].trim())}模板本身没有写错。`, + action: `${endStop(upstream[4].trim())}原样接着跑拿到的还是同一段文字,会再失败一次。`, + continuable: false, + fix: 'canvas', + // 要改的是写出这段文字的上游,不是整形节点:入口指到上游去 + fixNode: upstream[2], + raw, + } + } + const badJson = plain.match(/^(模板渲染出来的不是合法 JSON([^)]*))[。.]?\s*([\s\S]*)$/) + if (badJson) { + const hint = badJson[2].trim() + // 后端点名了模板里是哪一处、该怎么改(加 | json、去掉引号、取出来是空的):照它说, + // 不再追加「改用 output_schema」——那句指的是另一条路,和它给的改法对不上。 + // 只有老的笼统说法(或者什么都没说)才补这一句 + const pointed = hint.startsWith('模板里 {{') + return { + title: badJson[1], + action: `${endStop(hint || TEMPLATE_HINT)}` + + (pointed ? '改完再运行。' : '模板里插的是 agent / 模型写的文字时,改用 output_schema + cite_fields 让它交结构化数据。改完再运行。'), + continuable: false, + fix: 'canvas', + raw, + } + } + // 发起就被拒:绑定的工具在本机不存在(POST /runs 422 run_tool_missing)。这次运行根本没开始, + // 谈不上接着跑;去接入,或者回画布重新选 + const missingTool = explainToolMissing(plain, raw) + if (missingTool) return missingTool // 工具库里存着的坏参数定义。它不在这次运行的快照里:到工具页改好,回来原样接着跑 // 就能过;不先改,接着跑还是同样的失败 const broken = explainBrokenTools(plain, raw) diff --git a/frontend/src/lib/terms.ts b/frontend/src/lib/terms.ts index 273437df..5a6460fc 100644 --- a/frontend/src/lib/terms.ts +++ b/frontend/src/lib/terms.ts @@ -230,8 +230,136 @@ export const EVIDENCE_TEXT = { docMismatch: '成果字段和报告文档对不上,按普通文本显示', docOtherRun: '证据文档不是这次运行写的,按普通文本显示', ambiguous: (candidates: string[]) => `出处不唯一:候选 ${candidates.join('、')}`, + + // ---- 第二期:查询步骤、输入来源、口径卡来源 ---- + query: (alias: string) => `查询 ${alias}`.trim(), + copySql: '复制 SQL', + /** 窗口外的行:面板只拿到被引用的行加前后各 2 行 */ + windowed: '仅显示被引用的行及前后各 2 行', + openSnapshot: '打开完整快照', + snapshotTitle: (alias: string) => `查询 ${alias} · 完整快照`, + masked: '已遮罩', + /** 面板底部:被遮罩的列 + 遮罩不是安全边界(用户拍板写明) */ + maskNote: (cols: string[]) => + `已遮罩的列:${cols.join('、')}。在有身份体系之前,遮罩只减少暴露,不是安全边界:完整快照、工件接口里仍是原值`, + queryMissing: '查询的行要从证据接口取,这一次没取到', + queryPending: '正在取查询的行…', + queryHash: '查询快照取回时哈希对不上,疑似被改过:这些行不能当证据', + truncatedSnapshot: '查询撞了行数上限,库里还有更多', + windowCut: (n: number) => `被引用的行太多,这里只列了前 ${formatNumber(n)} 行,其余看完整快照`, + sources: '输入来源', + gotoQuery: (alias: string) => `看查询 ${alias}`.trim(), + sourceVerified: '与快照一致', + sourceMismatch: (told: string, truth: string) => `模型报 ${told},快照是 ${truth},已按快照取值`, + /** cell() 取数:算的时候用的值和快照不一样,值没有被换掉,照实说上游改写过 */ + cellMismatch: (used: string, truth: string) => `算的时候用的是 ${used},快照里是 ${truth}:上游改写过这份结果`, + sourceEmpty: '没查到,记为空,没有兜底成 0', + cellUnresolved: '核对不到查询快照', + codeCompute: (node: string) => + `代码节点${node ? `「${node}」` : ''}算出来的数(计算):沙箱里的算术系统核对不了,业务计算要写进口径卡`, + codeSource: (node: string) => + `来自代码节点${node ? `「${node}」` : ''}(标为取数):沙箱跑出来的结果,系统核对不到查询快照`, + inputMissing: '缺:上游没给出这个值', + caliberFrom: (caliber: string, version: string, workflow: string, wfVersion: string) => + `口径卡「${caliber || '—'}」${version},来自「${workflow}」${wfVersion}`, + caliberUpgrade: (latest: string | null, policy: string) => + `${latest ? `上游已有 ${latest}` : '上游有新版本'},按「${policy}」处置`, } as const +/** + * 口径升版的三种处置,和后端 governance.UPGRADE_POLICY_LABELS 同一套说法。子工作流和 + * 钉版本的口径卡(caliber_from)共用 + */ +export const UPGRADE_POLICY_LABEL: Record = { + recompute: '用新口径回算历史', + dual: '并排双印新旧口径', + incomparable: '标注与历史不可比', +} + +/** + * 子工作流钉住版本后的升版处置说明。选项和口径卡的那个下拉是同一份(检查器直接借用口径卡 + * 的字段定义),这里只换掉说明里「和谁同一套」那半句 + */ +export const SUBGRAPH_UPGRADE_HELP = '钉住的那一版之后,被嵌的工作流又发了新版本:正式运行前必须声明怎么处置,' + + '和钉住别处的口径卡是同一套规则;声明了就照它执行,并记进运行记录' + +/** 上游在钉住的那一版之后又发了版:子工作流和口径卡的提醒同一句 */ +export const upgradeNewerText = (latest: number, pinned: number) => + `上游已有 v${latest}(钉的是 v${pinned}):正式运行前要在下面声明升版处置,否则会被挡住` + +/** + * 发布前检查与自动修复:发布弹窗和问题面板的「发布前检查」共用一套说法。 + * 原则写在话里:修复只出预览、人确认了才存草稿,永远不替人发布 + */ +export const PUBLISH_FIX_TEXT = { + section: '发布前检查', + lint: '校验', + checking: '正在做发布前检查…', + unsupported: '这个后端还没有发布前检查:点「发布」时门禁照旧检查,拦下的问题会列在这里', + failed: '发布前检查没做成', + retry: '重试', + recheck: '重新检查', + run: '检查', + stale: '画布改过了,结果可能已经过时', + blocked: (n: number) => `发布前检查:会被门禁拦下 ${n} 处`, + gateBlocked: (n: number) => `门禁拦下了 ${n} 处,改完再发布`, + passed: '发布前检查通过:没有会被门禁拦下的问题', + warnings: (n: number) => `另有 ${n} 条提示`, + fixAll: (n: number) => `一键修复可自动修的 ${n} 处`, + fixOne: '修复', + fixHint: (label: string) => `先看预览:${label}`, + choose: '预览', + chooseFirst: '先选好再预览', + multiple: '可以多选', + suggested: '建议', + assist: '交给 Copilot', + assistHint: '让 Copilot 试着改剩下的问题。它改的同样只是预览,要人拿主意的会原样问你', + locked: (why: string) => `${why}。现在不能修复`, + previewing: '正在生成修复预览…', + assisting: 'Copilot 正在试着修,可能要几十秒…', + stop: '停下', + previewTitle: (n: number) => `修复预览 · ${n} 处改动`, + noChange: '这一次没有可以应用的改动', + rejected: '没有采用', + assistSaid: 'Copilot', + questions: 'Copilot 需要你拿主意', + afterOk: '应用后门禁不再拦', + afterLeft: (n: number) => `应用后还剩 ${n} 处会被拦下`, + apply: '应用并重新检查', + discard: '放弃', + applyNote: '应用后存成新的草稿版本,再重新检查一遍;不会自动发布', + dirtyNote: '画布上还有没保存的改动,应用时会一起存进草稿', + saving: '正在保存草稿…', + rechecking: '正在重新检查…', + saveFailed: '草稿没保存上', + saveFailedHint: '修复已经放到画布上(可以撤销);保存成功之前不能发布', + retrySave: '重试保存', + stalePreview: '预览之后画布又改过了,这份预览对不上了:重新检查一次再修', + autofixMissing: '这个后端还不支持自动修复:照提示手动改,或者打开助手描述一遍', + autofixFailed: '修复预览没生成出来', + applied: (n: number, version?: number) => `已应用 ${n} 处修复${version != null ? `,存为 v${version}` : ''}`, + saveNote: (labels: string[]) => `发布前修复:${labels[0] ?? ''}${labels.length > 1 ? ` 等 ${labels.length} 处` : ''}`, + /** 撤销栈里这一步叫什么 */ + undoLabel: (n: number) => `发布前修复(${n} 处)`, + pendingPreview: '先应用或放弃修复预览', + unsaved: '修复还没保存上', + whole: '整张工作流', +} as const + +/** 证据的种类怎么叫:aria-label、面板标题、出处那一句 */ +export const EVIDENCE_KIND_LABEL: Record = { + metric: '口径卡指标', + input: '运行输入', + cell: '查询结果', + query: '查询结果', + table: '整表', + retrieval: '知识库检索', + node_output: '代码节点的产出', + entity: '表或字段', + column: '字段', + quote: '引文', +} + /** * MCP / 自定义工具的信任档:工具页的三选一、工具徽标、审批卡的「始终允许」共用这一份。 * 「始终允许」只指 always;审批卡上那个按钮设的是 gated,按钮字短,说明里写全 diff --git a/frontend/src/pages/DataSourcesTab.tsx b/frontend/src/pages/DataSourcesTab.tsx index 7ee7a3bf..c20ed295 100644 --- a/frontend/src/pages/DataSourcesTab.tsx +++ b/frontend/src/pages/DataSourcesTab.tsx @@ -1,7 +1,7 @@ import { useEffect, useMemo, useRef, useState } from 'react' import { useNavigate } from 'react-router-dom' import { - AlertTriangle, ChevronRight, Database, FileSpreadsheet, Info, KeyRound, Lock, Plug, Plus, RefreshCw, + AlertTriangle, ChevronRight, Database, EyeOff, FileSpreadsheet, Info, KeyRound, Lock, Plug, Plus, RefreshCw, Search, Table2, Upload, X, } from 'lucide-react' import clsx from 'clsx' @@ -41,6 +41,9 @@ export const isUploadedTable = (row: any): boolean => const NAME_RE = /^[a-z][a-z0-9_]{0,40}$/ +/** 数据源 options 里的键:证据面板展示查询原始行时,这些列一律写「已遮罩」 */ +const MASK_KEY = 'mask_columns' + /** * 增、删、改、传之后让全局的数据源目录跟上:检查器挑工具、问数据的范围、助手的数据源 * 提示都读它(store/catalog 的 datasources),不重拉的话刚接入的库在那几处看不见 @@ -222,6 +225,8 @@ function SourceCard({ row, meta, onChange, onRemoved, onEdit, onReupload, kick } const uploaded = isUploadedTable(row) // 传上来的表通常只有一两张,直接摊开;库动辄几十上百个对象,默认收着 const [open, setOpen] = useState(() => uploaded && row.table_count > 0 && row.table_count <= 3) + const [masking, setMasking] = useState(false) + const masks = maskList(row.options?.[MASK_KEY]) const clock = useRunClock(!!busy) const busySince = useRef(0) useTicker(60_000) @@ -419,8 +424,24 @@ function SourceCard({ row, meta, onChange, onRemoved, onEdit, onReupload, kick }
    {row.options?.schema && schema {row.options.schema}} + {masks.length > 0 && ( + + 遮罩 {formatNumber(masks.length)} 列 + + )} + {uploaded && ( + // 传上来的表没有编辑框(只能重传),遮罩列在这里改 + + )} {!!row.tools?.length && {row.tools.join(' · ')}}
    + {masking && ( + setMasking(false)} + onSaved={(next) => { setMasking(false); onChange(next); syncCatalog() }} /> + )} {failed && (
    @@ -747,11 +768,13 @@ interface SourceForm { oracleMode: 'service_name' | 'sid' serviceName: string sid: string - /** options 里除 schema / service_name / sid 之外的键,全部摊开可编辑 */ + /** options.mask_columns:证据面板展示查询原始行时遮掉的列 */ + maskColumns: string[] + /** options 里除 schema / service_name / sid / mask_columns 之外的键,全部摊开可编辑 */ advanced: [string, string][] } -const OWN_OPTION_KEYS = new Set(['schema', 'service_name', 'sid']) +const OWN_OPTION_KEYS = new Set(['schema', 'service_name', 'sid', MASK_KEY]) function toForm(source: any): SourceForm { const o = source.options ?? {} @@ -772,6 +795,7 @@ function toForm(source: any): SourceForm { // 老数据把服务名存在 database 上:回显它,保存时后端会挪进 options serviceName: o.service_name ?? (oracle ? source.database ?? '' : ''), sid: o.sid ?? '', + maskColumns: maskList(o[MASK_KEY]), advanced: Object.entries(o) .filter(([k]) => !OWN_OPTION_KEYS.has(k)) .map(([k, v]) => [k, String(v ?? '')]), @@ -779,9 +803,10 @@ function toForm(source: any): SourceForm { } function toBody(form: SourceForm, isNew: boolean, wasOracle = true): any { - const options: Record = {} + const options: Record = {} for (const [k, v] of form.advanced) if (k.trim() && v.trim()) options[k.trim()] = v.trim() if (form.schema.trim()) options.schema = form.schema.trim() + if (form.maskColumns.length) options[MASK_KEY] = form.maskColumns const oracle = form.kind === 'oracle' if (oracle) { // 只写选中的那一个,另一个不带:两处都有值时引擎听 service_name, @@ -824,10 +849,10 @@ const FIELD_LABEL: Record = { /** * 决定连到哪个库的那几项(提交体里的)。schema 不在内:它只影响探查哪一片,不影响 - * 连不连得上,后端判断测连接结果还作不作数时同样不看它 + * 连不连得上,后端判断测连接结果还作不作数时同样不看它;遮罩列只管面板上怎么显示,也不在内 */ function connectionOf(body: any): string { - const { schema: _schema, ...options } = body.options ?? {} + const { schema: _schema, [MASK_KEY]: _mask, ...options } = body.options ?? {} return JSON.stringify([ body.kind, body.host ?? null, body.port ?? null, body.database ?? null, body.username ?? null, options, !!body.password, @@ -1120,6 +1145,9 @@ function SourceEditor({ source, kinds, onClose, onSaved }: { )} + set({ maskColumns })} /> +
    )} - {step.detail && ( + {detail && (
    {step.kind === 'query' && (
    @@ -1454,7 +1482,7 @@ function StepDetail({ step, table, explained }: {
    )}
    -            {step.detail}
    +            {detail}
               
    )} @@ -1723,11 +1751,20 @@ function download(name: string, text: string, type: string) { const fileSafe = (s: string) => s.replace(/[\\/:*?"<>|\s]+/g, '_').slice(0, 40) || '结果' -/** 查询结果画成表格。一屏 JSON 谁也看不出名堂,表格能一眼看到形状。 */ -function ResultTable({ table, artifact, title, full = false }: { +/** + * 查询结果画成表格。一屏 JSON 谁也看不出名堂,表格能一眼看到形状。 + * + * 证据面板的查询步骤也用它:highlight 标出被引用的行和格(行是 table.rows 的下标), + * 加 data-highlight,颜色只用令牌;masked 列出被遮罩的列,那几列的值一律写「已遮罩」—— + * 后端已经换过,这里再拦一道,老接口、别的入口回来的原值也不画出来 + */ +export function ResultTable({ table, artifact, title, full = false, highlight, masked }: { table: Table; artifact?: string; title?: string /** 完整证据里:不截列、行数放宽 */ full?: boolean + /** cells 精确到格([行下标, 列名]):有它就只标这几格,没有就标 rows × cols */ + highlight?: { rows: number[]; cols: string[]; cells?: [number, string][] | null } + masked?: string[] }) { const { dense, openArtifact } = useContext(Ctx) const maxCols = full ? table.columns.length : dense ? 4 : 8 @@ -1736,6 +1773,10 @@ function ResultTable({ table, artifact, title, full = false }: { const hiddenCols = table.columns.length - cols.length const rows = table.rows.slice(0, maxRows) const numeric = cols.map((c, j) => isMetricColumn(c, rows, j)) + const hlCells = highlight?.cells ? new Set(highlight.cells.map(([r, c]) => `${r}\u0000${c}`)) : null + const hlRows = new Set([...(highlight?.rows ?? []), ...(highlight?.cells ?? []).map(([r]) => r)]) + const hlCols = new Set(highlight?.cols ?? []) + const hidden = new Set(masked ?? []) return (
    @@ -1751,19 +1792,30 @@ function ResultTable({ table, artifact, title, full = false }: { - {rows.map((row, i) => ( - - {row.slice(0, maxCols).map((cell, j) => ( - - {cell == null ? null - : typeof cell === 'object' ? JSON.stringify(cell) : String(cell)} - - ))} - {hiddenCols > 0 && …} - - ))} + {rows.map((row, i) => { + const hit = hlRows.has(i) + return ( + + {row.slice(0, maxCols).map((cell, j) => { + const mark = hit && (hlCells ? hlCells.has(`${i}\u0000${cols[j]}`) : hlCols.has(cols[j])) + const mask = hidden.has(cols[j]) + return ( + + {mask ? 已遮罩 + : cell == null ? null + : typeof cell === 'object' ? JSON.stringify(cell) : String(cell)} + + ) + })} + {hiddenCols > 0 && …} + + ) + })}
    @@ -1792,7 +1844,9 @@ function ResultTable({ table, artifact, title, full = false }: { {artifact && !table.clipped && !full && ( @@ -1812,9 +1866,12 @@ function ResultTable({ table, artifact, title, full = false }: { * * 解码器早就给每一步记了工件 id,接口也一直在,界面从没渲染过——「每一步都有 * 工件、数字可回指」这个区别于普通问数工具的卖点,以前在界面上看不见。 - * 导出给运行页的工件清单复用:id 为 null 时不渲染。 + * 导出给运行页的工件清单复用:id 为 null 时不渲染。证据面板打开查询快照时带上 masked, + * 数据源设了遮罩的那几列同样写「已遮罩」。 */ -export function ArtifactViewer({ id, title, onClose }: { id: string | null; title?: string; onClose: () => void }) { +export function ArtifactViewer({ id, title, masked, onClose }: { + id: string | null; title?: string; masked?: string[]; onClose: () => void +}) { const [state, setState] = useState<{ id: string; content?: unknown; error?: unknown } | null>(null) useEffect(() => { if (!id) return @@ -1863,7 +1920,7 @@ export function ArtifactViewer({ id, title, onClose }: { id: string | null; titl
    )} - {body.table ? + {body.table ? : (
    body.text} />
    diff --git a/frontend/src/run/EvidenceDoc.tsx b/frontend/src/run/EvidenceDoc.tsx index 04dbd8a6..da9d1dae 100644 --- a/frontend/src/run/EvidenceDoc.tsx +++ b/frontend/src/run/EvidenceDoc.tsx @@ -27,8 +27,9 @@ import { BlockView, Markdown, StyledSlice, inlineStyles, numericColumns, type In * 句末挂「无证据」。 * * 键盘:整份报告只占一个 Tab 位(roving tabindex)。←/→ 在有状态的片段间走,↑/↓ 按 - * 句子走,n / N 跳到下一处 / 上一处无证据,回车打开证据面板,Esc 关掉并留在原片段; - * 面板开着时方向键走到哪、面板跟到哪,Tab 进面板。 + * 句子走(表格里按列走到上一行 / 下一行,出了表格接着按句子走),n / N 跳到下一处 / + * 上一处无证据,回车打开证据面板,Esc 关掉并留在原片段;面板开着时方向键走到哪、面板 + * 跟到哪,Tab 进面板。 * * 长报告:事件都挂在容器上(片段只带 data-seg),块用 memo,外加 * content-visibility: auto;超过 TEXT_CAP 的部分先折叠,跳转到折叠里的片段时自动展开。 @@ -56,6 +57,10 @@ interface Model { alerts: string[] /** 每块到它为止的累计字数(折叠用) */ chars: number[] + /** 表格里的片段在第几行第几列(表头 row = -1 不进来);↑/↓ 在表格里按列走 */ + cell: Map + /** `块:行:列` → 那一格里第一个可点的片段 */ + grid: Map } function buildModel(blocks: EvidenceBlock[]): Model { @@ -66,10 +71,13 @@ function buildModel(blocks: EvidenceBlock[]): Model { const unitOf = new Map() const alerts: string[] = [] const chars: number[] = [] + const cell = new Map() + const grid = new Map() let total = 0 blocks.forEach((block, b) => { for (const unit of block.units ?? []) { let first = true + const loc = block.type === 'table' && unit.loc && unit.loc.row >= 0 ? unit.loc : null for (const s of unit.segments ?? []) { if (s.kind !== 'structural') total += s.text.length const state = segmentState(s) @@ -80,11 +88,47 @@ function buildModel(blocks: EvidenceBlock[]): Model { if (first) { unitFirst.push(s.id); first = false } unitOf.set(s.id, unitFirst.length - 1) if (EVIDENCE_STATE[state].alert) alerts.push(s.id) + if (loc) { + cell.set(s.id, { block: b, row: loc.row, col: loc.col }) + const key = `${b}:${loc.row}:${loc.col}` + if (!grid.has(key)) grid.set(key, s.id) + } } } chars.push(total) }) - return { order, index, seg, unitFirst, unitOf, alerts, chars } + return { order, index, seg, unitFirst, unitOf, alerts, chars, cell, grid } +} + +/** + * ↑/↓ 该去哪。表格里按列走:下一行同一列(那一格空着就找这一行离它最近的一格); + * 走出表格的上下边,接着按句子走到表格前后那一句——不在表格里一格一格地横着挪 + */ +function verticalTarget(model: Model, id: string, down: boolean): string | null { + const n = model.unitFirst.length + if (!n) return null + const at = model.cell.get(id) + if (at) { + const rows = [...new Set([...model.cell.values()].filter((c) => c.block === at.block).map((c) => c.row))].sort((a, b) => a - b) + const next = rows[rows.indexOf(at.row) + (down ? 1 : -1)] + if (next !== undefined) { + const same = model.grid.get(`${at.block}:${next}:${at.col}`) + if (same) return same + const near = [...model.cell.entries()].filter(([, c]) => c.block === at.block && c.row === next) + .sort((a, b) => Math.abs(a[1].col - at.col) - Math.abs(b[1].col - at.col))[0] + if (near) return near[0] + } + // 出了表格:往下找表格之后的第一句,往上找表格之前的最后一句 + const u = model.unitOf.get(id) ?? 0 + const list = down ? model.unitFirst.slice(u + 1) : model.unitFirst.slice(0, u).reverse() + const out = list.find((sid) => model.seg.get(sid)?.block !== at.block) + if (out) return out + // 表格就是整份报告的首尾:和句子一样首尾相接 + const wrap = down ? model.unitFirst : [...model.unitFirst].reverse() + return wrap.find((sid) => model.seg.get(sid)?.block !== at.block) ?? null + } + const u = model.unitOf.get(id) ?? 0 + return model.unitFirst[(u + (down ? 1 : -1) + n) % n] ?? null } /** 片段的外观:线型走 data 属性(CSS 里只管线型),颜色写成两个变量交给 index.css */ @@ -246,9 +290,7 @@ export const EvidenceDoc = forwardRef= 0 ? model.order[to] : null } else if (e.key === 'ArrowDown' || e.key === 'ArrowUp') { - const u = model.unitOf.get(id) ?? 0 - const n = model.unitFirst.length - target = model.unitFirst[(u + (e.key === 'ArrowDown' ? 1 : -1) + n) % n] ?? null + target = verticalTarget(model, id, e.key === 'ArrowDown') } else if (e.key === 'n' || e.key === 'N') { target = step(model.alerts, id, e.shiftKey || e.key === 'N' ? -1 : 1) if (!target) return diff --git a/frontend/src/run/EvidencePanel.tsx b/frontend/src/run/EvidencePanel.tsx index db1120bb..d11d1afd 100644 --- a/frontend/src/run/EvidencePanel.tsx +++ b/frontend/src/run/EvidencePanel.tsx @@ -1,24 +1,34 @@ -import { useEffect, useMemo, type KeyboardEvent as ReactKeyboardEvent, type ReactNode } from 'react' -import { ArrowLeft, ListChecks, X } from 'lucide-react' +import { + useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState, type KeyboardEvent as ReactKeyboardEvent, + type ReactNode, +} from 'react' +import { createPortal } from 'react-dom' +import { ArrowLeft, CornerDownRight, ListChecks, ShieldCheck, X } from 'lucide-react' import clsx from 'clsx' import { StatusBadge, isComposing } from '../components/ui' import { - EVIDENCE_STATE, docForeign, graphDoc, integrityFailures, locatable, reasonOf, sealVerdict, segmentState, sourceOf, - type SealStatus, + EVIDENCE_STATE, caliberSourceText, caliberUpgradeText, docForeign, evidenceValue as valueText, graphDoc, inputSource, + integrityFailures, locatable, locatorText, notableInput, queryOf, queryWindow, reasonOf, sealVerdict, segmentState, + sourceOf, type SealStatus, type SourceTone, } from '../lib/evidence' import { formatNumber, NONE, shortId } from '../lib/format' import { EVIDENCE_TEXT } from '../lib/terms' +import { useCatalog } from '../store/catalog' import { segmentKey, useEvidence } from '../store/evidence' import type { EvidenceDocData, EvidenceInput, EvidenceSeal, EvidenceSegment, EvidenceStep, EvidenceUnit, EvidenceViolation, } from '../types' +import { ArtifactViewer, ResultTable } from './AssistantStream' +import { CopyChip } from './Markdown' /** * 证据面板:点开报告里的一个片段,看它从哪来。 * - * 本期(数字层)展示:片段和它所在的句子;指标步骤——名称、值、口径与版本、原式、 - * 代入式(每个输入是「值 ← 路径」的小标签)、复算结果;封存状态。没有证据的片段说清 - * 为什么(裸数字、引用解析不了),另有一份违规清单:列表序号、代码块标签里的数字 + * 展示:片段和它所在的句子;指标步骤——名称、值、口径与版本(钉在别的工作流上的写明来源 + * 和升版处置)、原式、代入式(每个输入是「值 ← 路径」的小标签,点一下跳到对应的查询)、 + * 复算结果;输入来源——agent 字段、cell() 取数和快照核对得上没有,代码节点的数醒目提醒; + * 查询步骤——SQL、被引用的行和格高亮的结果窗口、打开完整快照、遮罩;封存状态。没有证据 + * 的片段说清为什么(裸数字、引用解析不了),另有一份违规清单:列表序号、代码块标签里的数字 * 在正文里画不了线,只能在清单里找到。 * * 数据两层来源:文档自己记着的出处(doc.catalog,打开就有),和证据接口的证据链 @@ -104,7 +114,7 @@ export function EvidencePanel({ id, mode, doc, artifact, runId, view, onClose, o {view.kind === 'violations' ? : found - ? :

    {NONE}

    }
    @@ -132,16 +142,14 @@ function Part({ title, children, ...rest }: { title: string; children: ReactNode ) } -/** 值怎么写:数带千分位、不丢小数;拿不到写「—」 */ -function valueText(v: unknown): string { - if (v == null || v === '') return NONE - if (typeof v === 'number') return Number.isFinite(v) ? v.toLocaleString('en-US', { maximumFractionDigits: 20 }) : String(v) - if (typeof v === 'boolean') return v ? '是' : '否' - if (typeof v === 'object') return JSON.stringify(v) - return String(v) +/** 定位到的那一步描一下边(CSS 的 [data-flash],减少动效时由全局规则停掉) */ +function flashOnce(el: HTMLElement) { + el.dataset.flash = 'focus' + setTimeout(() => { if (el.dataset.flash === 'focus') delete el.dataset.flash }, 1600) } -function SegmentBody({ doc, artifact, runId, seg, unit, onViolations }: { +function SegmentBody({ panelId, doc, artifact, runId, seg, unit, onViolations }: { + panelId: string doc: EvidenceDocData; artifact?: string; runId?: string; seg: EvidenceSegment; unit: EvidenceUnit onViolations: () => void }) { @@ -172,11 +180,34 @@ function SegmentBody({ doc, artifact, runId, seg, unit, onViolations }: { const state = segmentState(seg) const cite = seg.cite const entry = cite?.alias ? doc.catalog?.[cite.alias] : undefined - const chain: EvidenceStep[] = Array.isArray(detail?.chain) ? detail.chain : [] + const chain: EvidenceStep[] = Array.isArray(detail?.chain) ? detail.chain.filter((s) => s && typeof s === 'object') : [] const metricStep = chain.find((s) => s.step === 'metric') const inputStep = chain.find((s) => s.step === 'run_input') + const queries = chain.filter((s) => s.step === 'query') const isMetric = cite?.kind === 'metric' || entry?.kind === 'metric' || !!metricStep const isInput = cite?.kind === 'input' || entry?.kind === 'input' || !!inputStep + // 报告直接引用的查询单元格([[v:Q3.r5.amount]]、整表里的一格):证据链就是那一次查询 + const isCell = !isMetric && !isInput && (cite?.kind === 'cell' || entry?.kind === 'query' || !!queries.length) + const inputs: EvidenceInput[] = metricStep?.inputs?.length + ? metricStep.inputs + : chain.filter((s) => s.step === 'input').map((s) => s as EvidenceInput) + const masked = (Array.isArray(detail?.redacted?.columns) ? detail.redacted.columns : []).map(String).filter(Boolean) + const workflows = useCatalog((s) => s.workflows) + const workflowName = (id?: string) => (id ? workflows.find((w) => w.id === id)?.name : undefined) + // 输入标签、输入来源那一行点一下:焦点跳到对应的查询步骤,描一下边 + const queryId = (i: number) => `${panelId}-q${i}` + const jumpTo = useCallback((i: number) => { + const part = document.getElementById(`${panelId}-q${i}`) + const head = part?.querySelector('[data-ev-query-head]') + if (!part || !head) return + head.focus({ preventScroll: true }) + head.scrollIntoView({ block: 'nearest' }) + flashOnce(part) + }, [panelId]) + const linkOf = (inp: EvidenceInput) => { + const i = queryOf(inp, queries) + return i >= 0 ? { index: i, alias: queries[i].alias ?? '', go: () => jumpTo(i) } : undefined + } const violations = (doc.violations ?? []).filter((v) => v.segment === seg.id) const seal: EvidenceSeal | undefined = docIssue ? undefined : detail?.seal ?? graphData?.seal const reason = reasonOf(seg) || detail?.note || '' @@ -217,10 +248,16 @@ function SegmentBody({ doc, artifact, runId, seg, unit, onViolations }: { )} {isMetric && ( - + resolved={state === 'deterministic'} + linkOf={linkOf} + workflowName={workflowName(pinnedWorkflow(metricStep))} /> + )} + + {isMetric && inputs.some(notableInput) && ( + )} {isInput && !isMetric && ( @@ -233,7 +270,20 @@ function SegmentBody({ doc, artifact, runId, seg, unit, onViolations }: { )} - {state === 'deterministic' && !isMetric && !isInput && ( + {isCell && !queries.length && state === 'deterministic' && ( + // 接口没给查询步骤(取不到、老后端、正在取):只说文档目录里记着的那次查询,不画假的行。 + // 解析不了的单元格引用(行越界、没有这一列、受管没声明 cells)接口本来就不给链,上面已经说了原因, + // 再摆一块「这一次没取到」就像是接口出了错 + + )} + + {queries.map((q, i) => ( + + ))} + + {state === 'deterministic' && !isMetric && !isInput && !isCell && (

    {sourceOf(seg, doc) || detail?.note}

    )} @@ -246,10 +296,25 @@ function SegmentBody({ doc, artifact, runId, seg, unit, onViolations }: { {EVIDENCE_TEXT.violations}({formatNumber(doc.violations.length)}) )} + + {!!masked.length && ( + // 用户拍板:界面上写明遮罩不是安全边界。放在最后,读完证据再看到这句 +

    + {EVIDENCE_TEXT.maskNote(masked)} +

    + )} ) } +type InputLink = { index: number; alias: string; go: () => void } | undefined + +/** 口径卡钉住的上游工作流 id:方案写在 caliber_from,接口给在 source 上 */ +function pinnedWorkflow(step?: EvidenceStep): string | undefined { + const from = step?.caliber_from ?? (step?.source && typeof step.source === 'object' ? step.source : undefined) + return from?.workflow_id || undefined +} + /** 所在的句子:点开的那一段描底,句子里的数字加粗 */ function Sentence({ unit, active }: { unit: EvidenceUnit; active: string }) { return ( @@ -271,11 +336,11 @@ function Sentence({ unit, active }: { unit: EvidenceUnit; active: string }) { ) } -/** 指标步骤:名称、值、口径与版本、原式、代入式(输入标签)、复算 */ -function MetricPart({ step, entry, chain, runId, seal, pending, failed, resolved }: { +/** 指标步骤:名称、值、口径与版本(来源、升版处置)、原式、代入式(输入标签)、复算 */ +function MetricPart({ step, entry, inputs, runId, seal, pending, failed, resolved, linkOf, workflowName }: { step?: EvidenceStep entry?: Record - chain: EvidenceStep[] + inputs: EvidenceInput[] runId?: string /** 封存状态:逐项复核里「在不在封存范围内」只在封存完好时单独报 */ seal?: EvidenceSeal @@ -284,6 +349,10 @@ function MetricPart({ step, entry, chain, runId, seal, pending, failed, resolved failed: string /** 片段的引用解析成功了(有出处);解析不了的也画这一节,好说清缺的是哪个指标 */ resolved: boolean + /** 输入对应的查询步骤:有的话输入标签是按钮,点一下跳过去 */ + linkOf: (input: EvidenceInput) => InputLink + /** 钉住的上游工作流在目录里的名字(接口没给名字时用) */ + workflowName?: string }) { const name = step?.name ?? entry?.name ?? step?.metric ?? entry?.locator?.metric ?? NONE const value = step?.value !== undefined ? step.value : entry?.value @@ -291,9 +360,11 @@ function MetricPart({ step, entry, chain, runId, seal, pending, failed, resolved const caliber = step?.caliber ?? entry?.caliber const version = step?.version ?? entry?.version const artifact = step?.artifact ?? entry?.artifact - const inputs: EvidenceInput[] = step?.inputs?.length - ? step.inputs - : chain.filter((s) => s.step === 'input').map((s) => s as EvidenceInput) + // 钉在别的工作流上的口径卡:写明来自哪一版,上游有新版本时写处置 + const pinned = step?.caliber_from ?? (step?.source && typeof step.source === 'object' ? step.source : undefined) + ?? entry?.caliber_from ?? entry?.source + const from = caliberSourceText(caliber, version, typeof pinned === 'object' ? pinned : undefined, workflowName) + const upgrade = caliberUpgradeText(step?.caliber_upgrade ?? entry?.caliber_upgrade) // 口径卡里 rendered 为「—」有两种:value 为 null 是缺输入,不为 null 是有值但按格式显示不出来 const dash = rendered === NONE || entry?.rendered === NONE const missing = entry?.status === 'missing_input' || (value == null && (dash || !resolved)) @@ -305,12 +376,17 @@ function MetricPart({ step, entry, chain, runId, seal, pending, failed, resolved {name} {resolved && rendered && {rendered}}
    - {(caliber || version || artifact) && ( + {(caliber || version || artifact || from) && (
    - {(caliber || version) && 口径卡「{caliber || NONE}」{version}} + {from + ? {from} + : (caliber || version) && 口径卡「{caliber || NONE}」{version}} {artifact && 工件 {shortId(artifact)}}
    )} + {upgrade && ( +

    {upgrade}

    + )} {missing &&

    {EVIDENCE_TEXT.missingValue}

    } {unshowable && ( @@ -335,7 +411,7 @@ function MetricPart({ step, entry, chain, runId, seal, pending, failed, resolved {!!inputs.length && (
      - {inputs.map((inp, i) => )} + {inputs.map((inp, i) => )}
    )} @@ -380,20 +456,204 @@ function IntegrityList({ code, items }: { code: string; items: string[] }) { ) } -/** 「45,678.5 ← vars.kpi.gmv」:值从哪条路径取的。缺的写「—」、标成提醒色 */ -function InputChip({ input }: { input: EvidenceInput }) { +/** + * 「45,678.5 ← vars.kpi.gmv」:值从哪条路径取的。缺的写「—」、标成提醒色。 + * 能对到链里某一次查询的(agent 字段、cell() 取数)是按钮:点一下跳到那个查询步骤 + */ +function InputChip({ input, link }: { input: EvidenceInput; link?: InputLink }) { const missing = input.status === 'missing' || input.value == null const from = [input.node_id && `来自节点 ${input.node_id}`, input.via && `(${input.via})`, input.role && ` · ${input.role}`] .filter(Boolean).join('') + const style = missing ? { color: 'var(--st-waiting)', borderColor: 'var(--st-waiting)' } : { color: 'var(--text)' } + const body = {valueText(input.value)} ← {input.path ?? NONE}{missing ? '(缺)' : ''} return ( -
  • - {valueText(input.value)} ← {input.path ?? NONE}{missing ? '(缺)' : ''} +
  • + {link ? ( + + ) : ( + + {body} + + )}
  • ) } +const TONE: Record = { + ok: { color: 'var(--st-done)', border: 'var(--border)' }, + warn: { color: 'var(--st-waiting)', border: 'var(--border)' }, + // 计算角色的代码节点:沙箱里的算术绕开了口径卡,比别的提醒都醒目 + alert: { color: 'var(--st-failed)', border: 'var(--st-failed)', background: 'var(--st-failed-soft)' }, + muted: { color: 'var(--text-dim)', border: 'var(--border)' }, +} + +/** + * 输入来源:每个能核对到快照的输入(agent 字段、cell() 取数)、代码节点的输入、缺的输入各一行—— + * 路径、从哪个节点哪一格来、核对结果。能对到查询步骤的给「看查询 Qn」 + */ +function SourcesPart({ inputs, linkOf }: { inputs: EvidenceInput[]; linkOf: (input: EvidenceInput) => InputLink }) { + return ( + +
      + {inputs.map((inp, i) => { + const said = inputSource(inp) + const tone = TONE[said.tone] + const link = linkOf(inp) + // 查询编号写报告目录里的全局编号(接口补的 query、对上的查询步骤);agent 字段自己的 ref 是 + // 那个节点内部的编号,和面板里的「查询 Qn」不是一回事,不拿来写 + const alias = link?.alias || inp.query || (typeof inp.cell === 'string' ? inp.cell.split('.')[0] : '') + const where = [ + inp.node_id && `节点 ${inp.node_id}`, + inp.field && (inp.via === 'agent_field' ? `字段 ${inp.field}` : inp.field), + [alias, locatorText(inp.locator)].filter(Boolean).join(' · '), + ].filter(Boolean).join(' · ') + return ( +
    • +
      + {inp.path ?? NONE} + {link && ( + + )} +
      + {where &&
      {where}
      } + {said.text &&

      {said.text}

      } + {said.reason &&

      {said.reason}

      } +
    • + ) + })} +
    +
    + ) +} + +/** + * 查询步骤:SQL(带复制)、结果窗口(被引用的行和格高亮)、窗口说明、打开完整快照、复核。 + * + * 接口只给被引用的行加前后各 2 行,窗口没盖住整份快照时写明;完整快照走工件接口(取回时 + * 复验哈希),复用工件查看。被遮罩的列在表里写「已遮罩」。查询快照哈希对不上时用失败色写出来: + * 这时候这些行不能当证据,比「有出处」重要得多 + */ +function QueryPart({ id, step, seal, masked }: { id: string; step: EvidenceStep; seal?: EvidenceSeal; masked: string[] }) { + const win = useMemo(() => queryWindow(step), [step]) + const [open, setOpen] = useState(false) + const scroller = useRef(null) + const alias = step.alias ?? '' + const hidden = [...new Set([...masked, ...(Array.isArray(step.masked) ? step.masked.map(String) : [])])] + .filter((c) => win.columns.includes(c)) + const bad = integrityFailures(step, seal) + // 完整快照只在这一步自己画得出行、复核也都过了时给:快照不在封存范围里(接口一行不给,工件 id 照样带着)、 + // 哈希对不上时,按钮一点就绕过证据接口把整份快照打开,还挂着「哈希已校验」 + const snapshot = !!step.artifact && step.sealed !== false && step.hash_ok !== false && !bad.length && win.rows.length > 0 + // 知道每一行在快照里是第几行时,最前面加一列行号:窗口不连续(被引用的行隔得远)也看得出跳过了哪几行 + const table = useMemo(() => (win.index + ? { columns: [ROW_NO, ...win.columns], rows: win.rows.map((r, i) => [win.index![i] + 1, ...r]), truncated: false } + : { columns: win.columns, rows: win.rows, truncated: false }), [win]) + // 被引用的格可能在右边几列:窄栏里表格横向滚到它,不让人以为没高亮 + useLayoutEffect(() => { + const box = scroller.current + const cell = box?.querySelector('td[data-highlight="cell"]') + const inner = cell?.closest('.overflow-x-auto') + if (!cell || !inner) return + const left = cell.offsetLeft + if (left < inner.scrollLeft || left + cell.offsetWidth > inner.scrollLeft + inner.clientWidth) { + inner.scrollLeft = Math.max(0, left - 8) + } + }, [win]) + return ( + // 从输入标签跳过来时描一下边(flashOnce 设 data-flash):静态描边,减少动效时照样看得见 +
    +
    + {/* 键盘跳过来时焦点落在这里:得看得见落在哪(不能 outline-none) */} + + {EVIDENCE_TEXT.query(alias)} + {step.tool && {step.tool}} + + {step.sql && step.sql ?? ''} />} +
    + {step.sql && ( +
    +          {step.sql}
    +        
    + )} + {bad.length > 0 && ( + (k === 'hash' ? 'query-hash' : k)).join(' ')} + items={bad.map((k) => (k === 'hash' ? EVIDENCE_TEXT.queryHash : EVIDENCE_TEXT.integrity[k]))} /> + )} + {win.columns.length > 0 && win.rows.length > 0 && ( +
    0 && 'mt-1.5')}> + +
    + )} + {!win.rows.length && ( + // 没有行:快照不在封存范围里、哈希对不上、取不回来。接口说了原因就照原话 +

    {step.note || EVIDENCE_TEXT.queryMissing}

    + )} +
    + {win.windowed && win.total != null && ( + + {EVIDENCE_TEXT.windowed}({win.span},共 {formatNumber(win.total)} 行) + + )} + {step.window_truncated && {EVIDENCE_TEXT.windowCut(win.rows.length)}} + {step.truncated && {EVIDENCE_TEXT.truncatedSnapshot}} + {/* 数据源改名或删掉了:遮罩按查询当时记下的那份处理,行照样给。这句要看得见—— + 现在的数据源设置已经管不到这份快照了 */} + {step.mask_note && ( + {step.mask_note} + )} + + {snapshot && ( + + )} +
    + {/* 弹窗挂到 body 上:侧边面板是 fixed 的一层,里面的遮罩盖不住整页 */} + {/* 完整快照里同样遮掉这几列:工件接口还是原值(面板底部写明了),面板自己的入口不多暴露 */} + {open && snapshot && step.artifact && createPortal( + setOpen(false)} />, + document.body, + )} +
    + ) +} + +/** 窗口表格最前面那一列:快照里的行号,从 1 数 */ +const ROW_NO = '#' + +/** 接口没给查询步骤时:文档目录里记着的那次查询(编号、工具、行数),并照实说行没取到 */ +function QueryFallback({ entry, alias, where, pending, failed }: { + entry?: Record; alias?: string; where: string; pending: boolean; failed: string +}) { + const rows = typeof entry?.rows === 'number' ? `${formatNumber(entry.rows)} 行` : '' + return ( + +

    + {[entry?.tool ?? entry?.source, rows, where].filter(Boolean).join(' · ') || NONE} +

    +

    + {pending ? EVIDENCE_TEXT.queryPending : failed === EVIDENCE_TEXT.chainMissing ? EVIDENCE_TEXT.queryMissing : failed || EVIDENCE_TEXT.queryMissing} +

    +
    + ) +} + /** * 封存状态:StatusBadge 的剪影 + 一句话。说什么由 lib/evidence 的 sealVerdict 定(顺序、 * 正文不是封存那份时的失败态都在那里);没有证据的片段封存的只是报告文档本身, diff --git a/frontend/src/run/RunControl.tsx b/frontend/src/run/RunControl.tsx index a8f4fcf3..f54d3498 100644 --- a/frontend/src/run/RunControl.tsx +++ b/frontend/src/run/RunControl.tsx @@ -1,5 +1,5 @@ import { useEffect, useMemo, useRef, useState } from 'react' -import { Link } from 'react-router-dom' +import { Link, useNavigate } from 'react-router-dom' import { ChevronDown, Play } from 'lucide-react' import clsx from 'clsx' import { api } from '../api/client' @@ -8,6 +8,7 @@ import { hintOf } from '../canvas/shortcuts' import { isComposing, Kbd, Skeleton, Spinner, toast } from '../components/ui' import { useLocalActor } from '../lib/actor' import { errorMessage } from '../lib/errors' +import { explainStartError } from '../lib/explain' import { matchShortcut } from '../lib/keys' import { runClassLabel } from '../lib/terms' import { useStudio, type FlowNode } from '../store/studio' @@ -15,6 +16,23 @@ import type { ValidationIssue, WorkflowVersion } from '../types' import { ClassDot, escapeRun, RunCapsule } from './RunHud' import { isActivePhase } from './trace' +/** + * 发起就被拒。绑定的工具在本机不存在(run_tool_missing)时给直达入口:数据源工具去数据页接入, + * 自定义、MCP 工具去工具页——这次运行还没开始,没有「接着跑」可给。别的照通用的出错提示 + */ +function startFailed(e: unknown, navigate: (to: string) => void) { + const x = explainStartError(e) + if (!x?.fixTo) { + toast.error(e) + return + } + const to = x.fixTo + toast.error(`${x.title}:${x.reason ?? ''}${x.action ?? ''}`, { + detail: x.raw, key: 'studio:run-tool-missing', + action: { label: x.fixLabel ?? '去接入', onClick: () => navigate(to) }, + }) +} + /** * 工具栏上的运行控件。 * @@ -154,13 +172,14 @@ function ExploreLauncher({ tight }: { tight: boolean }) { const errors = issues.filter((i) => i.level === 'error') const blocked = !!errors.length || !nodes.length + const navigate = useNavigate() const launch = async () => { setBusy(true) try { await startRun(payloadOf(fields, values)) setOpen(false) } catch (e) { - toast.error(e) + startFailed(e, navigate) } finally { setBusy(false) } @@ -258,13 +277,14 @@ function FormalLauncher({ workflowId, version }: { workflowId: string; version: const ahead = dirty || (canvasVersion != null && canvasVersion !== version) const label = runClassLabel('formal', version) + const navigate = useNavigate() const launch = async () => { setBusy(true) try { await startFormalRun(payloadOf(fields ?? [], values)) setOpen(false) } catch (e) { - toast.error(e) + startFailed(e, navigate) } finally { setBusy(false) } diff --git a/frontend/src/run/__tests__/evidence-query.json b/frontend/src/run/__tests__/evidence-query.json new file mode 100644 index 00000000..11df76c7 --- /dev/null +++ b/frontend/src/run/__tests__/evidence-query.json @@ -0,0 +1,5614 @@ +{ + "_comment": "可点击证据第二期(查询链、单元格、整表、口径卡来源)的前端夹具。报告文档由后端 evidence.py 的 compose_doc 真跑生成(查询快照走 loader);片段接口的答复照 api/evidence.py 的形状拼(row_index、highlight.cells、masked、source、caliber_upgrade)。snapshots.Q3 是「打开完整快照」取回的工件内容(遮罩列已替换)。只用通用示例名。", + "run_id": "run-evidence-query", + "doc_artifact": "18e1dbb10ace41fa26d4b70ee72e8da461cf3b2e4b181672dda59f65fd924ddb", + "doc": { + "schema": "agentlab.report/1", + "run_id": "run-evidence-query", + "node_id": "write", + "markdown": "## 本周概览\n\n本周(2026-W37)销售额 45,678.5元,订单 1,234单,客单价 37.0元,环比 8.7%。\n\n退款率 —,新客 —:这次都没查到。\n\n扣掉促销后的调整后销售额是 44,000.0元,按汇率 7.12 折算。\n\n## 分区域\n\n华东销售额最高,为 18,230.5,其次是华南(12,004)。\n\n| region | amount | order_cnt |\n| --- | --- | --- |\n| 华东 | 18,230.5 | 402 |\n| 华南 | 12,004 | 311 |\n| 华北 | 9,876 | 298 |\n\n## 明细\n\n单笔最大的一单金额是 1,288,来自华南。最大的一单比最后一单多 672.5元。", + "source": "## 本周概览\n\n本周([[i:week]])销售额 [[m:gmv]],订单 [[m:orders]],客单价 [[m:aov]],环比 [[m:wow]]。[[see:m:gmv,m:orders,m:aov,m:wow]]\n\n退款率 [[m:refund_rate]],新客 [[m:new_users]]:这次都没查到。\n\n扣掉促销后的调整后销售额是 [[m:adj]],按汇率 [[m:fx]] 折算。\n\n## 分区域\n\n[[v:Q2.r0.region]]销售额最高,为 [[v:Q2.r0.amount]],其次是[[v:Q2.r1.region]]([[v:Q2.r1.amount]])。[[see:Q2]]\n\n[[table:Q2 cols=region,amount,order_cnt rows=0-2]]\n\n## 明细\n\n单笔最大的一单金额是 [[v:Q3.r5.amount]],来自[[v:Q3.r5.region]]。最大的一单比最后一单多 [[m:spread]]。", + "catalog": { + "m:gmv": { + "alias": "m:gmv", + "kind": "metric", + "eid": "ev:metric:8e9e5fdb2eddb7a0", + "node_id": "caliber", + "artifact": "85c4b5948c7e4cfab4472c5f250183e44a64d9139e342bbcdf52f14881b03d55", + "locator": { + "metric": "gmv" + }, + "caliber": "周报口径", + "version": "v3", + "name": "销售额", + "unit": "元", + "decimals": 1, + "format": "thousands", + "value": 45678.5, + "status": "ok", + "rendered": "45,678.5元", + "label": "销售额 = 45,678.5元" + }, + "m:orders": { + "alias": "m:orders", + "kind": "metric", + "eid": "ev:metric:d7c6de7d408a70d2", + "node_id": "caliber", + "artifact": "85c4b5948c7e4cfab4472c5f250183e44a64d9139e342bbcdf52f14881b03d55", + "locator": { + "metric": "orders" + }, + "caliber": "周报口径", + "version": "v3", + "name": "订单数", + "unit": "单", + "decimals": 0, + "format": "thousands", + "value": 1234, + "status": "ok", + "rendered": "1,234单", + "label": "订单数 = 1,234单" + }, + "m:aov": { + "alias": "m:aov", + "kind": "metric", + "eid": "ev:metric:72d2a805ddf72604", + "node_id": "caliber", + "artifact": "85c4b5948c7e4cfab4472c5f250183e44a64d9139e342bbcdf52f14881b03d55", + "locator": { + "metric": "aov" + }, + "caliber": "周报口径", + "version": "v3", + "name": "客单价", + "unit": "元", + "decimals": 1, + "format": "thousands", + "value": 37.0, + "status": "ok", + "rendered": "37.0元", + "label": "客单价 = 37.0元" + }, + "m:wow": { + "alias": "m:wow", + "kind": "metric", + "eid": "ev:metric:74c193598c403111", + "node_id": "caliber", + "artifact": "85c4b5948c7e4cfab4472c5f250183e44a64d9139e342bbcdf52f14881b03d55", + "locator": { + "metric": "wow" + }, + "caliber": "周报口径", + "version": "v3", + "name": "环比增幅", + "unit": "%", + "decimals": 1, + "format": "plain", + "value": 8.7, + "status": "ok", + "rendered": "8.7%", + "label": "环比增幅 = 8.7%" + }, + "m:refund_rate": { + "alias": "m:refund_rate", + "kind": "metric", + "eid": "ev:metric:1f7a71663ab15697", + "node_id": "caliber", + "artifact": "85c4b5948c7e4cfab4472c5f250183e44a64d9139e342bbcdf52f14881b03d55", + "locator": { + "metric": "refund_rate" + }, + "caliber": "周报口径", + "version": "v3", + "name": "退款率", + "unit": "", + "decimals": 4, + "format": "percent_of_ratio", + "value": null, + "status": "missing_input", + "rendered": "—", + "label": "退款率 = —" + }, + "m:new_users": { + "alias": "m:new_users", + "kind": "metric", + "eid": "ev:metric:4430c7b636713290", + "node_id": "caliber", + "artifact": "85c4b5948c7e4cfab4472c5f250183e44a64d9139e342bbcdf52f14881b03d55", + "locator": { + "metric": "new_users" + }, + "caliber": "周报口径", + "version": "v3", + "name": "新客数", + "unit": "人", + "decimals": 0, + "format": "thousands", + "value": null, + "status": "missing_input", + "rendered": "—", + "label": "新客数 = —" + }, + "m:adj": { + "alias": "m:adj", + "kind": "metric", + "eid": "ev:metric:b9c97d4b59a25a23", + "node_id": "caliber", + "artifact": "85c4b5948c7e4cfab4472c5f250183e44a64d9139e342bbcdf52f14881b03d55", + "locator": { + "metric": "adj" + }, + "caliber": "周报口径", + "version": "v3", + "name": "调整后销售额", + "unit": "元", + "decimals": 1, + "format": "thousands", + "value": 44000, + "status": "ok", + "rendered": "44,000.0元", + "label": "调整后销售额 = 44,000.0元" + }, + "m:fx": { + "alias": "m:fx", + "kind": "metric", + "eid": "ev:metric:86539c1b7e4afa0e", + "node_id": "caliber", + "artifact": "85c4b5948c7e4cfab4472c5f250183e44a64d9139e342bbcdf52f14881b03d55", + "locator": { + "metric": "fx" + }, + "caliber": "周报口径", + "version": "v3", + "name": "汇率", + "unit": "", + "decimals": 2, + "format": "plain", + "value": 7.12, + "status": "ok", + "rendered": "7.12", + "label": "汇率 = 7.12" + }, + "m:spread": { + "alias": "m:spread", + "kind": "metric", + "eid": "ev:metric:dea437737bb0dfa8", + "node_id": "caliber", + "artifact": "85c4b5948c7e4cfab4472c5f250183e44a64d9139e342bbcdf52f14881b03d55", + "locator": { + "metric": "spread" + }, + "caliber": "周报口径", + "version": "v3", + "name": "最大一单比最后一单多", + "unit": "元", + "decimals": 1, + "format": "thousands", + "value": 672.5, + "status": "ok", + "rendered": "672.5元", + "label": "最大一单比最后一单多 = 672.5元" + }, + "Q1": { + "alias": "Q1", + "kind": "query", + "eid": "ev:query:925e2c675e777fe6", + "locator": {}, + "node_id": "fetch", + "exec": 1, + "artifact": "175b086d4701b620222f4b4178cee1275139cf081da12550a30ff99fd6de26e5", + "via": "3ac8020600f10936e15e0c96adddc451a119b32731970f259d71fe118e46d253", + "call_id": "toolu_01", + "tool": "db_query__shop", + "source": "shop", + "columns": [ + "gmv", + "order_cnt", + "refund_cnt" + ], + "rows": 1, + "truncated": false, + "label": "db_query__shop · 1 行" + }, + "Q2": { + "alias": "Q2", + "kind": "query", + "eid": "ev:query:c56e2ba849184526", + "locator": {}, + "node_id": "fetch", + "exec": 1, + "artifact": "4f25cb3d0ef3155f54370d8f4e2f9c3673bc28a779db0b37602bd694c6726b32", + "via": "1ed3e0dd1a88c3ab6387e74e5d4fd0057ca4e46302537ef72baf2cd698a5204b", + "call_id": "toolu_02", + "tool": "db_query__shop", + "source": "shop", + "columns": [ + "region", + "amount", + "order_cnt" + ], + "rows": 4, + "truncated": false, + "label": "db_query__shop · 4 行" + }, + "Q3": { + "alias": "Q3", + "kind": "query", + "eid": "ev:query:a452d03387ba7d32", + "locator": {}, + "node_id": "fetch", + "exec": 1, + "artifact": "5e63abde1c4e435eb447fd55b50f10cf82d0457838876fd25178f28bd5fcdef7", + "via": "3c97296442379baa383f841059c0671b39fefc0a8b84cbf4897eaed04e32af82", + "call_id": "toolu_03", + "tool": "db_query__shop", + "source": "shop", + "columns": [ + "order_id", + "region", + "amount", + "phone" + ], + "rows": 12, + "truncated": false, + "label": "db_query__shop · 12 行" + }, + "Q4": { + "alias": "Q4", + "kind": "query", + "eid": "ev:query:71bc8f01ba32cda2", + "locator": {}, + "node_id": "pull", + "exec": 1, + "artifact": "30fe0bb2c76a873f0f3dc2bd244b6ba655d7af7a4f4e863630d353cfab42b3ad", + "via": "e5fdd7a1ca18dab9e579d1245df8a730a83b4e6e43a271ecb9378d0fc7dfb22a", + "tool": "db_query__shop", + "source": "shop", + "columns": [ + "gmv" + ], + "rows": 1, + "truncated": false, + "label": "db_query__shop · 1 行" + }, + "i:week": { + "alias": "i:week", + "kind": "input", + "eid": "ev:input:a398c5e60abfeedd", + "locator": { + "field": "week" + }, + "value": "2026-W37", + "rendered": "2026-W37", + "label": "week = 2026-W37" + } + }, + "blocks": [ + { + "type": "heading", + "level": 2, + "units": [ + { + "id": "u0", + "kind": "heading", + "span": [ + 3, + 7 + ], + "cites": [], + "see": [], + "segments": [ + { + "kind": "structural", + "text": "## ", + "state": "neutral", + "span": [ + 0, + 3 + ], + "id": "s0" + }, + { + "kind": "text", + "text": "本周概览", + "state": "neutral", + "span": [ + 3, + 7 + ], + "id": "s1" + } + ] + } + ], + "id": "b0" + }, + { + "type": "paragraph", + "units": [ + { + "id": "u1", + "kind": "claim", + "span": [ + 9, + 63 + ], + "cites": [ + "i:week", + "m:gmv", + "m:orders", + "m:aov", + "m:wow" + ], + "see": [ + { + "ref": "m:gmv", + "role": "support", + "alias": "m:gmv", + "kind": "metric", + "status": "resolved", + "eid": "ev:metric:8e9e5fdb2eddb7a0", + "locator": { + "metric": "gmv" + } + }, + { + "ref": "m:orders", + "role": "support", + "alias": "m:orders", + "kind": "metric", + "status": "resolved", + "eid": "ev:metric:d7c6de7d408a70d2", + "locator": { + "metric": "orders" + } + }, + { + "ref": "m:aov", + "role": "support", + "alias": "m:aov", + "kind": "metric", + "status": "resolved", + "eid": "ev:metric:72d2a805ddf72604", + "locator": { + "metric": "aov" + } + }, + { + "ref": "m:wow", + "role": "support", + "alias": "m:wow", + "kind": "metric", + "status": "resolved", + "eid": "ev:metric:74c193598c403111", + "locator": { + "metric": "wow" + } + } + ], + "segments": [ + { + "kind": "structural", + "text": "\n\n", + "state": "neutral", + "span": [ + 7, + 9 + ], + "id": "s2" + }, + { + "kind": "text", + "text": "本周(", + "state": "neutral", + "span": [ + 9, + 12 + ], + "id": "s3" + }, + { + "kind": "value", + "text": "2026-W37", + "ref": "i:week", + "cite": { + "ref": "week", + "alias": "i:week", + "locator": { + "field": "week" + }, + "eid": "ev:input:a398c5e60abfeedd", + "kind": "input", + "role": "value", + "status": "resolved", + "value": "2026-W37", + "rendered": "2026-W37" + }, + "state": "deterministic", + "span": [ + 12, + 20 + ], + "id": "s4" + }, + { + "kind": "text", + "text": ")销售额 ", + "state": "neutral", + "span": [ + 20, + 25 + ], + "id": "s5" + }, + { + "kind": "number", + "text": "45,678.5元", + "ref": "m:gmv", + "cite": { + "ref": "gmv", + "alias": "m:gmv", + "locator": { + "metric": "gmv" + }, + "eid": "ev:metric:8e9e5fdb2eddb7a0", + "kind": "metric", + "role": "value", + "status": "resolved", + "value": 45678.5, + "rendered": "45,678.5元" + }, + "state": "deterministic", + "span": [ + 25, + 34 + ], + "id": "s6" + }, + { + "kind": "text", + "text": ",订单 ", + "state": "neutral", + "span": [ + 34, + 38 + ], + "id": "s7" + }, + { + "kind": "number", + "text": "1,234单", + "ref": "m:orders", + "cite": { + "ref": "orders", + "alias": "m:orders", + "locator": { + "metric": "orders" + }, + "eid": "ev:metric:d7c6de7d408a70d2", + "kind": "metric", + "role": "value", + "status": "resolved", + "value": 1234, + "rendered": "1,234单" + }, + "state": "deterministic", + "span": [ + 38, + 44 + ], + "id": "s8" + }, + { + "kind": "text", + "text": ",客单价 ", + "state": "neutral", + "span": [ + 44, + 49 + ], + "id": "s9" + }, + { + "kind": "number", + "text": "37.0元", + "ref": "m:aov", + "cite": { + "ref": "aov", + "alias": "m:aov", + "locator": { + "metric": "aov" + }, + "eid": "ev:metric:72d2a805ddf72604", + "kind": "metric", + "role": "value", + "status": "resolved", + "value": 37.0, + "rendered": "37.0元" + }, + "state": "deterministic", + "span": [ + 49, + 54 + ], + "id": "s10" + }, + { + "kind": "text", + "text": ",环比 ", + "state": "neutral", + "span": [ + 54, + 58 + ], + "id": "s11" + }, + { + "kind": "number", + "text": "8.7%", + "ref": "m:wow", + "cite": { + "ref": "wow", + "alias": "m:wow", + "locator": { + "metric": "wow" + }, + "eid": "ev:metric:74c193598c403111", + "kind": "metric", + "role": "value", + "status": "resolved", + "value": 8.7, + "rendered": "8.7%" + }, + "state": "deterministic", + "span": [ + 58, + 62 + ], + "id": "s12" + }, + { + "kind": "text", + "text": "。", + "state": "neutral", + "span": [ + 62, + 63 + ], + "id": "s13" + } + ] + } + ], + "id": "b1" + }, + { + "type": "paragraph", + "units": [ + { + "id": "u2", + "kind": "claim", + "span": [ + 65, + 83 + ], + "cites": [], + "see": [], + "segments": [ + { + "kind": "structural", + "text": "\n\n", + "state": "neutral", + "span": [ + 63, + 65 + ], + "id": "s14" + }, + { + "kind": "text", + "text": "退款率 ", + "state": "neutral", + "span": [ + 65, + 69 + ], + "id": "s15" + }, + { + "kind": "number", + "text": "—", + "ref": "m:refund_rate", + "cite": { + "ref": "refund_rate", + "alias": "m:refund_rate", + "kind": "metric", + "role": "value", + "status": "unresolved", + "reason": "指标「退款率」这次没有值(缺输入),不能写进报告", + "rendered": "—" + }, + "state": "none", + "issue": "unresolved_ref", + "span": [ + 69, + 70 + ], + "id": "s16" + }, + { + "kind": "text", + "text": ",新客 ", + "state": "neutral", + "span": [ + 70, + 74 + ], + "id": "s17" + }, + { + "kind": "number", + "text": "—", + "ref": "m:new_users", + "cite": { + "ref": "new_users", + "alias": "m:new_users", + "kind": "metric", + "role": "value", + "status": "unresolved", + "reason": "指标「新客数」这次没有值(缺输入),不能写进报告", + "rendered": "—" + }, + "state": "none", + "issue": "unresolved_ref", + "span": [ + 74, + 75 + ], + "id": "s18" + }, + { + "kind": "text", + "text": ":这次都没查到。", + "state": "neutral", + "span": [ + 75, + 83 + ], + "id": "s19" + } + ] + } + ], + "id": "b2" + }, + { + "type": "paragraph", + "units": [ + { + "id": "u3", + "kind": "claim", + "span": [ + 85, + 121 + ], + "cites": [ + "m:adj", + "m:fx" + ], + "see": [], + "segments": [ + { + "kind": "structural", + "text": "\n\n", + "state": "neutral", + "span": [ + 83, + 85 + ], + "id": "s20" + }, + { + "kind": "text", + "text": "扣掉促销后的调整后销售额是 ", + "state": "neutral", + "span": [ + 85, + 99 + ], + "id": "s21" + }, + { + "kind": "number", + "text": "44,000.0元", + "ref": "m:adj", + "cite": { + "ref": "adj", + "alias": "m:adj", + "locator": { + "metric": "adj" + }, + "eid": "ev:metric:b9c97d4b59a25a23", + "kind": "metric", + "role": "value", + "status": "resolved", + "value": 44000, + "rendered": "44,000.0元" + }, + "state": "deterministic", + "span": [ + 99, + 108 + ], + "id": "s22" + }, + { + "kind": "text", + "text": ",按汇率 ", + "state": "neutral", + "span": [ + 108, + 113 + ], + "id": "s23" + }, + { + "kind": "number", + "text": "7.12", + "ref": "m:fx", + "cite": { + "ref": "fx", + "alias": "m:fx", + "locator": { + "metric": "fx" + }, + "eid": "ev:metric:86539c1b7e4afa0e", + "kind": "metric", + "role": "value", + "status": "resolved", + "value": 7.12, + "rendered": "7.12" + }, + "state": "deterministic", + "span": [ + 113, + 117 + ], + "id": "s24" + }, + { + "kind": "text", + "text": " 折算。", + "state": "neutral", + "span": [ + 117, + 121 + ], + "id": "s25" + } + ] + } + ], + "id": "b3" + }, + { + "type": "heading", + "level": 2, + "units": [ + { + "id": "u4", + "kind": "heading", + "span": [ + 126, + 129 + ], + "cites": [], + "see": [], + "segments": [ + { + "kind": "structural", + "text": "\n\n## ", + "state": "neutral", + "span": [ + 121, + 126 + ], + "id": "s26" + }, + { + "kind": "text", + "text": "分区域", + "state": "neutral", + "span": [ + 126, + 129 + ], + "id": "s27" + } + ] + } + ], + "id": "b4" + }, + { + "type": "paragraph", + "units": [ + { + "id": "u5", + "kind": "claim", + "span": [ + 131, + 164 + ], + "cites": [ + "Q2" + ], + "see": [ + { + "ref": "Q2", + "role": "support", + "alias": "Q2", + "kind": "query", + "status": "resolved", + "eid": "ev:query:c56e2ba849184526", + "locator": {} + } + ], + "segments": [ + { + "kind": "structural", + "text": "\n\n", + "state": "neutral", + "span": [ + 129, + 131 + ], + "id": "s28" + }, + { + "kind": "value", + "text": "华东", + "ref": "v:Q2.r0.region", + "cite": { + "ref": "Q2.r0.region", + "alias": "Q2", + "locator": { + "row": 0, + "column": "region" + }, + "eid": "ev:cell:39caf88002d122ff", + "kind": "cell", + "role": "value", + "status": "resolved", + "value": "华东", + "rendered": "华东" + }, + "state": "deterministic", + "span": [ + 131, + 133 + ], + "id": "s29" + }, + { + "kind": "text", + "text": "销售额最高,为 ", + "state": "neutral", + "span": [ + 133, + 141 + ], + "id": "s30" + }, + { + "kind": "number", + "text": "18,230.5", + "ref": "v:Q2.r0.amount", + "cite": { + "ref": "Q2.r0.amount", + "alias": "Q2", + "locator": { + "row": 0, + "column": "amount" + }, + "eid": "ev:cell:0803f091d4c868cd", + "kind": "cell", + "role": "value", + "status": "resolved", + "value": 18230.5, + "rendered": "18,230.5" + }, + "state": "deterministic", + "span": [ + 141, + 149 + ], + "id": "s31" + }, + { + "kind": "text", + "text": ",其次是", + "state": "neutral", + "span": [ + 149, + 153 + ], + "id": "s32" + }, + { + "kind": "value", + "text": "华南", + "ref": "v:Q2.r1.region", + "cite": { + "ref": "Q2.r1.region", + "alias": "Q2", + "locator": { + "row": 1, + "column": "region" + }, + "eid": "ev:cell:7a7cb1e58c599f14", + "kind": "cell", + "role": "value", + "status": "resolved", + "value": "华南", + "rendered": "华南" + }, + "state": "deterministic", + "span": [ + 153, + 155 + ], + "id": "s33" + }, + { + "kind": "text", + "text": "(", + "state": "neutral", + "span": [ + 155, + 156 + ], + "id": "s34" + }, + { + "kind": "number", + "text": "12,004", + "ref": "v:Q2.r1.amount", + "cite": { + "ref": "Q2.r1.amount", + "alias": "Q2", + "locator": { + "row": 1, + "column": "amount" + }, + "eid": "ev:cell:85b22e06175c4c74", + "kind": "cell", + "role": "value", + "status": "resolved", + "value": 12004.0, + "rendered": "12,004" + }, + "state": "deterministic", + "span": [ + 156, + 162 + ], + "id": "s35" + }, + { + "kind": "text", + "text": ")。", + "state": "neutral", + "span": [ + 162, + 164 + ], + "id": "s36" + } + ] + } + ], + "id": "b5" + }, + { + "type": "table", + "units": [ + { + "id": "u6", + "kind": "connective", + "span": [ + 168, + 174 + ], + "cites": [], + "see": [], + "segments": [ + { + "kind": "structural", + "text": "\n\n| ", + "state": "neutral", + "span": [ + 164, + 168 + ], + "id": "s37" + }, + { + "kind": "text", + "text": "region", + "state": "neutral", + "span": [ + 168, + 174 + ], + "id": "s38" + } + ], + "loc": { + "row": -1, + "col": 0 + } + }, + { + "id": "u7", + "kind": "connective", + "span": [ + 177, + 183 + ], + "cites": [], + "see": [], + "segments": [ + { + "kind": "structural", + "text": " | ", + "state": "neutral", + "span": [ + 174, + 177 + ], + "id": "s39" + }, + { + "kind": "text", + "text": "amount", + "state": "neutral", + "span": [ + 177, + 183 + ], + "id": "s40" + } + ], + "loc": { + "row": -1, + "col": 1 + } + }, + { + "id": "u8", + "kind": "connective", + "span": [ + 186, + 195 + ], + "cites": [], + "see": [], + "segments": [ + { + "kind": "structural", + "text": " | ", + "state": "neutral", + "span": [ + 183, + 186 + ], + "id": "s41" + }, + { + "kind": "text", + "text": "order_cnt", + "state": "neutral", + "span": [ + 186, + 195 + ], + "id": "s42" + } + ], + "loc": { + "row": -1, + "col": 2 + } + }, + { + "id": "u9", + "kind": "claim", + "span": [ + 220, + 222 + ], + "cites": [ + "Q2" + ], + "see": [], + "segments": [ + { + "kind": "structural", + "text": " |\n| --- | --- | --- |\n| ", + "state": "neutral", + "span": [ + 195, + 220 + ], + "id": "s43" + }, + { + "kind": "value", + "text": "华东", + "ref": "v:Q2.r0.region", + "cite": { + "ref": "Q2.r0.region", + "alias": "Q2", + "locator": { + "row": 0, + "column": "region" + }, + "eid": "ev:cell:39caf88002d122ff", + "kind": "cell", + "role": "value", + "status": "resolved", + "value": "华东", + "rendered": "华东" + }, + "state": "deterministic", + "span": [ + 220, + 222 + ], + "id": "s44" + } + ], + "loc": { + "row": 0, + "col": 0 + } + }, + { + "id": "u10", + "kind": "claim", + "span": [ + 225, + 233 + ], + "cites": [ + "Q2" + ], + "see": [], + "segments": [ + { + "kind": "structural", + "text": " | ", + "state": "neutral", + "span": [ + 222, + 225 + ], + "id": "s45" + }, + { + "kind": "number", + "text": "18,230.5", + "ref": "v:Q2.r0.amount", + "cite": { + "ref": "Q2.r0.amount", + "alias": "Q2", + "locator": { + "row": 0, + "column": "amount" + }, + "eid": "ev:cell:0803f091d4c868cd", + "kind": "cell", + "role": "value", + "status": "resolved", + "value": 18230.5, + "rendered": "18,230.5" + }, + "state": "deterministic", + "span": [ + 225, + 233 + ], + "id": "s46" + } + ], + "loc": { + "row": 0, + "col": 1 + } + }, + { + "id": "u11", + "kind": "claim", + "span": [ + 236, + 239 + ], + "cites": [ + "Q2" + ], + "see": [], + "segments": [ + { + "kind": "structural", + "text": " | ", + "state": "neutral", + "span": [ + 233, + 236 + ], + "id": "s47" + }, + { + "kind": "number", + "text": "402", + "ref": "v:Q2.r0.order_cnt", + "cite": { + "ref": "Q2.r0.order_cnt", + "alias": "Q2", + "locator": { + "row": 0, + "column": "order_cnt" + }, + "eid": "ev:cell:c6ba44059fc49fa7", + "kind": "cell", + "role": "value", + "status": "resolved", + "value": 402, + "rendered": "402" + }, + "state": "deterministic", + "span": [ + 236, + 239 + ], + "id": "s48" + } + ], + "loc": { + "row": 0, + "col": 2 + } + }, + { + "id": "u12", + "kind": "claim", + "span": [ + 244, + 246 + ], + "cites": [ + "Q2" + ], + "see": [], + "segments": [ + { + "kind": "structural", + "text": " |\n| ", + "state": "neutral", + "span": [ + 239, + 244 + ], + "id": "s49" + }, + { + "kind": "value", + "text": "华南", + "ref": "v:Q2.r1.region", + "cite": { + "ref": "Q2.r1.region", + "alias": "Q2", + "locator": { + "row": 1, + "column": "region" + }, + "eid": "ev:cell:7a7cb1e58c599f14", + "kind": "cell", + "role": "value", + "status": "resolved", + "value": "华南", + "rendered": "华南" + }, + "state": "deterministic", + "span": [ + 244, + 246 + ], + "id": "s50" + } + ], + "loc": { + "row": 1, + "col": 0 + } + }, + { + "id": "u13", + "kind": "claim", + "span": [ + 249, + 255 + ], + "cites": [ + "Q2" + ], + "see": [], + "segments": [ + { + "kind": "structural", + "text": " | ", + "state": "neutral", + "span": [ + 246, + 249 + ], + "id": "s51" + }, + { + "kind": "number", + "text": "12,004", + "ref": "v:Q2.r1.amount", + "cite": { + "ref": "Q2.r1.amount", + "alias": "Q2", + "locator": { + "row": 1, + "column": "amount" + }, + "eid": "ev:cell:85b22e06175c4c74", + "kind": "cell", + "role": "value", + "status": "resolved", + "value": 12004.0, + "rendered": "12,004" + }, + "state": "deterministic", + "span": [ + 249, + 255 + ], + "id": "s52" + } + ], + "loc": { + "row": 1, + "col": 1 + } + }, + { + "id": "u14", + "kind": "claim", + "span": [ + 258, + 261 + ], + "cites": [ + "Q2" + ], + "see": [], + "segments": [ + { + "kind": "structural", + "text": " | ", + "state": "neutral", + "span": [ + 255, + 258 + ], + "id": "s53" + }, + { + "kind": "number", + "text": "311", + "ref": "v:Q2.r1.order_cnt", + "cite": { + "ref": "Q2.r1.order_cnt", + "alias": "Q2", + "locator": { + "row": 1, + "column": "order_cnt" + }, + "eid": "ev:cell:601c63ce11c38099", + "kind": "cell", + "role": "value", + "status": "resolved", + "value": 311, + "rendered": "311" + }, + "state": "deterministic", + "span": [ + 258, + 261 + ], + "id": "s54" + } + ], + "loc": { + "row": 1, + "col": 2 + } + }, + { + "id": "u15", + "kind": "claim", + "span": [ + 266, + 268 + ], + "cites": [ + "Q2" + ], + "see": [], + "segments": [ + { + "kind": "structural", + "text": " |\n| ", + "state": "neutral", + "span": [ + 261, + 266 + ], + "id": "s55" + }, + { + "kind": "value", + "text": "华北", + "ref": "v:Q2.r2.region", + "cite": { + "ref": "Q2.r2.region", + "alias": "Q2", + "locator": { + "row": 2, + "column": "region" + }, + "eid": "ev:cell:f18c3397a51b4e54", + "kind": "cell", + "role": "value", + "status": "resolved", + "value": "华北", + "rendered": "华北" + }, + "state": "deterministic", + "span": [ + 266, + 268 + ], + "id": "s56" + } + ], + "loc": { + "row": 2, + "col": 0 + } + }, + { + "id": "u16", + "kind": "claim", + "span": [ + 271, + 276 + ], + "cites": [ + "Q2" + ], + "see": [], + "segments": [ + { + "kind": "structural", + "text": " | ", + "state": "neutral", + "span": [ + 268, + 271 + ], + "id": "s57" + }, + { + "kind": "number", + "text": "9,876", + "ref": "v:Q2.r2.amount", + "cite": { + "ref": "Q2.r2.amount", + "alias": "Q2", + "locator": { + "row": 2, + "column": "amount" + }, + "eid": "ev:cell:715a614c0b0befe6", + "kind": "cell", + "role": "value", + "status": "resolved", + "value": 9876.0, + "rendered": "9,876" + }, + "state": "deterministic", + "span": [ + 271, + 276 + ], + "id": "s58" + } + ], + "loc": { + "row": 2, + "col": 1 + } + }, + { + "id": "u17", + "kind": "claim", + "span": [ + 279, + 282 + ], + "cites": [ + "Q2" + ], + "see": [], + "segments": [ + { + "kind": "structural", + "text": " | ", + "state": "neutral", + "span": [ + 276, + 279 + ], + "id": "s59" + }, + { + "kind": "number", + "text": "298", + "ref": "v:Q2.r2.order_cnt", + "cite": { + "ref": "Q2.r2.order_cnt", + "alias": "Q2", + "locator": { + "row": 2, + "column": "order_cnt" + }, + "eid": "ev:cell:f6f4e1d0de2b884d", + "kind": "cell", + "role": "value", + "status": "resolved", + "value": 298, + "rendered": "298" + }, + "state": "deterministic", + "span": [ + 279, + 282 + ], + "id": "s60" + } + ], + "loc": { + "row": 2, + "col": 2 + } + } + ], + "id": "b6" + }, + { + "type": "heading", + "level": 2, + "units": [ + { + "id": "u18", + "kind": "heading", + "span": [ + 289, + 291 + ], + "cites": [], + "see": [], + "segments": [ + { + "kind": "structural", + "text": " |\n\n## ", + "state": "neutral", + "span": [ + 282, + 289 + ], + "id": "s61" + }, + { + "kind": "text", + "text": "明细", + "state": "neutral", + "span": [ + 289, + 291 + ], + "id": "s62" + } + ] + } + ], + "id": "b7" + }, + { + "type": "paragraph", + "units": [ + { + "id": "u19", + "kind": "claim", + "span": [ + 293, + 315 + ], + "cites": [ + "Q3" + ], + "see": [], + "segments": [ + { + "kind": "structural", + "text": "\n\n", + "state": "neutral", + "span": [ + 291, + 293 + ], + "id": "s63" + }, + { + "kind": "text", + "text": "单笔最大的一单金额是 ", + "state": "neutral", + "span": [ + 293, + 304 + ], + "id": "s64" + }, + { + "kind": "number", + "text": "1,288", + "ref": "v:Q3.r5.amount", + "cite": { + "ref": "Q3.r5.amount", + "alias": "Q3", + "locator": { + "row": 5, + "column": "amount" + }, + "eid": "ev:cell:751535b8b3533b12", + "kind": "cell", + "role": "value", + "status": "resolved", + "value": 1288.0, + "rendered": "1,288" + }, + "state": "deterministic", + "span": [ + 304, + 309 + ], + "id": "s65" + }, + { + "kind": "text", + "text": ",来自", + "state": "neutral", + "span": [ + 309, + 312 + ], + "id": "s66" + }, + { + "kind": "value", + "text": "华南", + "ref": "v:Q3.r5.region", + "cite": { + "ref": "Q3.r5.region", + "alias": "Q3", + "locator": { + "row": 5, + "column": "region" + }, + "eid": "ev:cell:a38e95fc49a1db05", + "kind": "cell", + "role": "value", + "status": "resolved", + "value": "华南", + "rendered": "华南" + }, + "state": "deterministic", + "span": [ + 312, + 314 + ], + "id": "s67" + }, + { + "kind": "text", + "text": "。", + "state": "neutral", + "span": [ + 314, + 315 + ], + "id": "s68" + } + ] + }, + { + "id": "u20", + "kind": "claim", + "span": [ + 315, + 334 + ], + "cites": [ + "m:spread" + ], + "see": [], + "segments": [ + { + "kind": "text", + "text": "最大的一单比最后一单多 ", + "state": "neutral", + "span": [ + 315, + 327 + ], + "id": "s69" + }, + { + "kind": "number", + "text": "672.5元", + "ref": "m:spread", + "cite": { + "ref": "spread", + "alias": "m:spread", + "locator": { + "metric": "spread" + }, + "eid": "ev:metric:dea437737bb0dfa8", + "kind": "metric", + "role": "value", + "status": "resolved", + "value": 672.5, + "rendered": "672.5元" + }, + "state": "deterministic", + "span": [ + 327, + 333 + ], + "id": "s70" + }, + { + "kind": "text", + "text": "。", + "state": "neutral", + "span": [ + 333, + 334 + ], + "id": "s71" + } + ] + } + ], + "id": "b8" + } + ], + "stats": { + "units": 21, + "segments": 72, + "claims": 15, + "connective": 3, + "headings": 3, + "numbers": 18, + "numbers_cited": 16, + "values": 7, + "uncited_numbers": 0, + "unresolved": 2, + "see": 5, + "uncited_claims": 1, + "violations": 2 + }, + "violations": [ + { + "code": "unresolved_ref", + "message": "引用 [[m:refund_rate]] 解析不了:指标「退款率」这次没有值(缺输入),不能写进报告", + "ref": "m:refund_rate", + "span": [ + 69, + 70 + ], + "text": "—", + "segment": "s16", + "unit": "u2" + }, + { + "code": "unresolved_ref", + "message": "引用 [[m:new_users]] 解析不了:指标「新客数」这次没有值(缺输入),不能写进报告", + "ref": "m:new_users", + "span": [ + 74, + 75 + ], + "text": "—", + "segment": "s18", + "unit": "u2" + } + ] + }, + "graph": { + "run_id": "run-evidence-query", + "schema": "agentlab.evidence/1", + "mode": "cited", + "seal": { + "sealed": true, + "ok": true, + "manifest_seq": 64, + "legacy": false + }, + "reports": [ + { + "node_id": "write", + "doc_artifact": "18e1dbb10ace41fa26d4b70ee72e8da461cf3b2e4b181672dda59f65fd924ddb", + "doc_sealed": true, + "hash_ok": true, + "ok": false, + "repairs": 0, + "fields": [ + "answer" + ], + "stats": { + "units": 21, + "segments": 72, + "claims": 15, + "connective": 3, + "headings": 3, + "numbers": 18, + "numbers_cited": 16, + "values": 7, + "uncited_numbers": 0, + "unresolved": 2, + "see": 5, + "uncited_claims": 1, + "violations": 2 + } + } + ], + "evidence": [ + { + "alias": "m:gmv", + "eid": "ev:metric:8e9e5fdb2eddb7a0", + "kind": "metric", + "label": "销售额 = 45,678.5元", + "node_id": "caliber", + "artifact": "85c4b5948c7e4cfab4472c5f250183e44a64d9139e342bbcdf52f14881b03d55", + "sealed": true, + "cited_by": [], + "units": [], + "report": "write" + }, + { + "alias": "m:orders", + "eid": "ev:metric:d7c6de7d408a70d2", + "kind": "metric", + "label": "订单数 = 1,234单", + "node_id": "caliber", + "artifact": "85c4b5948c7e4cfab4472c5f250183e44a64d9139e342bbcdf52f14881b03d55", + "sealed": true, + "cited_by": [], + "units": [], + "report": "write" + }, + { + "alias": "m:aov", + "eid": "ev:metric:72d2a805ddf72604", + "kind": "metric", + "label": "客单价 = 37.0元", + "node_id": "caliber", + "artifact": "85c4b5948c7e4cfab4472c5f250183e44a64d9139e342bbcdf52f14881b03d55", + "sealed": true, + "cited_by": [], + "units": [], + "report": "write" + }, + { + "alias": "m:wow", + "eid": "ev:metric:74c193598c403111", + "kind": "metric", + "label": "环比增幅 = 8.7%", + "node_id": "caliber", + "artifact": "85c4b5948c7e4cfab4472c5f250183e44a64d9139e342bbcdf52f14881b03d55", + "sealed": true, + "cited_by": [], + "units": [], + "report": "write" + }, + { + "alias": "m:refund_rate", + "eid": "ev:metric:1f7a71663ab15697", + "kind": "metric", + "label": "退款率 = —", + "node_id": "caliber", + "artifact": "85c4b5948c7e4cfab4472c5f250183e44a64d9139e342bbcdf52f14881b03d55", + "sealed": true, + "cited_by": [], + "units": [], + "report": "write" + }, + { + "alias": "m:new_users", + "eid": "ev:metric:4430c7b636713290", + "kind": "metric", + "label": "新客数 = —", + "node_id": "caliber", + "artifact": "85c4b5948c7e4cfab4472c5f250183e44a64d9139e342bbcdf52f14881b03d55", + "sealed": true, + "cited_by": [], + "units": [], + "report": "write" + }, + { + "alias": "m:adj", + "eid": "ev:metric:b9c97d4b59a25a23", + "kind": "metric", + "label": "调整后销售额 = 44,000.0元", + "node_id": "caliber", + "artifact": "85c4b5948c7e4cfab4472c5f250183e44a64d9139e342bbcdf52f14881b03d55", + "sealed": true, + "cited_by": [], + "units": [], + "report": "write" + }, + { + "alias": "m:fx", + "eid": "ev:metric:86539c1b7e4afa0e", + "kind": "metric", + "label": "汇率 = 7.12", + "node_id": "caliber", + "artifact": "85c4b5948c7e4cfab4472c5f250183e44a64d9139e342bbcdf52f14881b03d55", + "sealed": true, + "cited_by": [], + "units": [], + "report": "write" + }, + { + "alias": "m:spread", + "eid": "ev:metric:dea437737bb0dfa8", + "kind": "metric", + "label": "最大一单比最后一单多 = 672.5元", + "node_id": "caliber", + "artifact": "85c4b5948c7e4cfab4472c5f250183e44a64d9139e342bbcdf52f14881b03d55", + "sealed": true, + "cited_by": [], + "units": [], + "report": "write" + }, + { + "alias": "Q1", + "eid": "ev:query:925e2c675e777fe6", + "kind": "query", + "label": "db_query__shop · 1 行", + "node_id": "fetch", + "artifact": "175b086d4701b620222f4b4178cee1275139cf081da12550a30ff99fd6de26e5", + "sealed": true, + "cited_by": [], + "units": [], + "report": "write" + }, + { + "alias": "Q2", + "eid": "ev:query:c56e2ba849184526", + "kind": "query", + "label": "db_query__shop · 4 行", + "node_id": "fetch", + "artifact": "4f25cb3d0ef3155f54370d8f4e2f9c3673bc28a779db0b37602bd694c6726b32", + "sealed": true, + "cited_by": [], + "units": [], + "report": "write" + }, + { + "alias": "Q3", + "eid": "ev:query:a452d03387ba7d32", + "kind": "query", + "label": "db_query__shop · 12 行", + "node_id": "fetch", + "artifact": "5e63abde1c4e435eb447fd55b50f10cf82d0457838876fd25178f28bd5fcdef7", + "sealed": true, + "cited_by": [], + "units": [], + "report": "write" + }, + { + "alias": "Q4", + "eid": "ev:query:71bc8f01ba32cda2", + "kind": "query", + "label": "db_query__shop · 1 行", + "node_id": "pull", + "artifact": "30fe0bb2c76a873f0f3dc2bd244b6ba655d7af7a4f4e863630d353cfab42b3ad", + "sealed": true, + "cited_by": [], + "units": [], + "report": "write" + }, + { + "alias": "i:week", + "eid": "ev:input:a398c5e60abfeedd", + "kind": "input", + "label": "week = 2026-W37", + "node_id": null, + "artifact": null, + "sealed": true, + "cited_by": [], + "units": [], + "report": "write" + } + ], + "edges": [] + }, + "segments": { + "s4": { + "report": { + "node_id": "write", + "doc_artifact": "18e1dbb10ace41fa26d4b70ee72e8da461cf3b2e4b181672dda59f65fd924ddb" + }, + "segment": { + "id": "s4", + "text": "2026-W37", + "kind": "value", + "state": "deterministic", + "span": [ + 12, + 20 + ], + "unit": "u1", + "ref": "i:week", + "cite": { + "ref": "week", + "alias": "i:week", + "locator": { + "field": "week" + }, + "eid": "ev:input:a398c5e60abfeedd", + "kind": "input", + "role": "value", + "status": "resolved", + "value": "2026-W37", + "rendered": "2026-W37" + } + }, + "unit": { + "id": "u1", + "kind": "claim", + "text": "本周(2026-W37)销售额 45,678.5元,订单 1,234单,客单价 37.0元,环比 8.7%。", + "span": [ + 9, + 63 + ], + "cites": [ + "i:week", + "m:gmv", + "m:orders", + "m:aov", + "m:wow" + ] + }, + "block": { + "id": "b1", + "type": "paragraph" + }, + "chain": [ + { + "step": "run_input", + "alias": "i:week", + "field": "week", + "value": "2026-W37", + "rendered": "2026-W37", + "eid": "ev:input:a398c5e60abfeedd", + "eid_ok": true, + "node_id": "in", + "sealed": true + } + ], + "note": "数字由系统从证据里取出、按口径卡的格式渲染,没有经过模型转写", + "violations": [], + "seal": { + "sealed": true, + "ok": true, + "covered": true + }, + "redacted": { + "columns": [] + } + }, + "s6": { + "report": { + "node_id": "write", + "doc_artifact": "18e1dbb10ace41fa26d4b70ee72e8da461cf3b2e4b181672dda59f65fd924ddb" + }, + "segment": { + "id": "s6", + "text": "45,678.5元", + "kind": "number", + "state": "deterministic", + "span": [ + 25, + 34 + ], + "unit": "u1", + "ref": "m:gmv", + "cite": { + "ref": "gmv", + "alias": "m:gmv", + "locator": { + "metric": "gmv" + }, + "eid": "ev:metric:8e9e5fdb2eddb7a0", + "kind": "metric", + "role": "value", + "status": "resolved", + "value": 45678.5, + "rendered": "45,678.5元" + } + }, + "unit": { + "id": "u1", + "kind": "claim", + "text": "本周(2026-W37)销售额 45,678.5元,订单 1,234单,客单价 37.0元,环比 8.7%。", + "span": [ + 9, + 63 + ], + "cites": [ + "i:week", + "m:gmv", + "m:orders", + "m:aov", + "m:wow" + ] + }, + "block": { + "id": "b1", + "type": "paragraph" + }, + "chain": [ + { + "step": "metric", + "alias": "m:gmv", + "metric": "gmv", + "name": "销售额", + "value": 45678.5, + "unit": "元", + "decimals": 1, + "format": "thousands", + "conv": null, + "rendered": "45,678.5元", + "caliber": "周报口径", + "version": "v3", + "expression": "vars.kpi.gmv", + "substituted": "45678.5", + "recompute_ok": true, + "status": "ok", + "node_id": "caliber", + "artifact": "85c4b5948c7e4cfab4472c5f250183e44a64d9139e342bbcdf52f14881b03d55", + "eid": "ev:metric:8e9e5fdb2eddb7a0", + "eid_ok": true, + "hash_ok": true, + "render_ok": true, + "sealed": true, + "source": { + "workflow_id": "wf-demo-weekly", + "workflow_version": 5, + "node_id": "caliber", + "workflow_name": "销售周报" + }, + "caliber_upgrade": { + "workflow_id": "wf-demo-weekly", + "caliber_node": "caliber", + "pinned": 5, + "latest": 6, + "policy": "dual", + "policy_label": "并排双印新旧口径" + } + }, + { + "step": "input", + "path": "vars.kpi.gmv", + "value": 45678.5, + "node_id": "fetch", + "via": "agent_field", + "field": "gmv", + "status": "verified", + "ref": "Q1.r0.gmv", + "artifact": "175b086d4701b620222f4b4178cee1275139cf081da12550a30ff99fd6de26e5", + "locator": { + "row": 0, + "column": "gmv" + }, + "eid": "ev:cell:c8c89e05312eaba7", + "query": "Q1", + "cell": "Q1.r0.gmv" + }, + { + "step": "query", + "alias": "Q1", + "artifact": "175b086d4701b620222f4b4178cee1275139cf081da12550a30ff99fd6de26e5", + "node_id": "fetch", + "tool": "db_query__shop", + "source": "shop", + "sql": "SELECT SUM(amount) AS gmv, COUNT(*) AS order_cnt, SUM(is_refund) AS refund_cnt\nFROM orders\nWHERE week = '2026-W37'", + "columns": [ + "gmv", + "order_cnt", + "refund_cnt" + ], + "rows": [ + [ + 45678.5, + 1234, + 29 + ] + ], + "row_offset": 0, + "row_index": [ + 0 + ], + "total_rows": 1, + "truncated": false, + "highlight": { + "rows": [ + 0 + ], + "cols": [ + "gmv" + ], + "cells": [ + [ + 0, + "gmv" + ] + ] + }, + "masked": [], + "hash_ok": true, + "sealed": true + } + ], + "note": "数字由系统从证据里取出、按口径卡的格式渲染,没有经过模型转写", + "violations": [], + "seal": { + "sealed": true, + "ok": true, + "covered": true + }, + "redacted": { + "columns": [] + } + }, + "s8": { + "report": { + "node_id": "write", + "doc_artifact": "18e1dbb10ace41fa26d4b70ee72e8da461cf3b2e4b181672dda59f65fd924ddb" + }, + "segment": { + "id": "s8", + "text": "1,234单", + "kind": "number", + "state": "deterministic", + "span": [ + 38, + 44 + ], + "unit": "u1", + "ref": "m:orders", + "cite": { + "ref": "orders", + "alias": "m:orders", + "locator": { + "metric": "orders" + }, + "eid": "ev:metric:d7c6de7d408a70d2", + "kind": "metric", + "role": "value", + "status": "resolved", + "value": 1234, + "rendered": "1,234单" + } + }, + "unit": { + "id": "u1", + "kind": "claim", + "text": "本周(2026-W37)销售额 45,678.5元,订单 1,234单,客单价 37.0元,环比 8.7%。", + "span": [ + 9, + 63 + ], + "cites": [ + "i:week", + "m:gmv", + "m:orders", + "m:aov", + "m:wow" + ] + }, + "block": { + "id": "b1", + "type": "paragraph" + }, + "chain": [ + { + "step": "metric", + "alias": "m:orders", + "metric": "orders", + "name": "订单数", + "value": 1234, + "unit": "单", + "decimals": 0, + "format": "thousands", + "conv": null, + "rendered": "1,234单", + "caliber": "周报口径", + "version": "v3", + "expression": "vars.kpi.order_cnt", + "substituted": "1234", + "recompute_ok": true, + "status": "ok", + "node_id": "caliber", + "artifact": "85c4b5948c7e4cfab4472c5f250183e44a64d9139e342bbcdf52f14881b03d55", + "eid": "ev:metric:d7c6de7d408a70d2", + "eid_ok": true, + "hash_ok": true, + "render_ok": true, + "sealed": true, + "source": { + "workflow_id": "wf-demo-weekly", + "workflow_version": 5, + "node_id": "caliber", + "workflow_name": "销售周报" + }, + "caliber_upgrade": { + "workflow_id": "wf-demo-weekly", + "caliber_node": "caliber", + "pinned": 5, + "latest": 6, + "policy": "dual", + "policy_label": "并排双印新旧口径" + } + }, + { + "step": "input", + "path": "vars.kpi.order_cnt", + "value": 1234, + "node_id": "fetch", + "via": "agent_field", + "field": "order_cnt", + "status": "mismatch", + "ref": "Q1.r0.order_cnt", + "artifact": "175b086d4701b620222f4b4178cee1275139cf081da12550a30ff99fd6de26e5", + "locator": { + "row": 0, + "column": "order_cnt" + }, + "eid": "ev:cell:f39bedaf6a670641", + "model_value": 1240, + "query": "Q1", + "cell": "Q1.r0.order_cnt" + }, + { + "step": "query", + "alias": "Q1", + "artifact": "175b086d4701b620222f4b4178cee1275139cf081da12550a30ff99fd6de26e5", + "node_id": "fetch", + "tool": "db_query__shop", + "source": "shop", + "sql": "SELECT SUM(amount) AS gmv, COUNT(*) AS order_cnt, SUM(is_refund) AS refund_cnt\nFROM orders\nWHERE week = '2026-W37'", + "columns": [ + "gmv", + "order_cnt", + "refund_cnt" + ], + "rows": [ + [ + 45678.5, + 1234, + 29 + ] + ], + "row_offset": 0, + "row_index": [ + 0 + ], + "total_rows": 1, + "truncated": false, + "highlight": { + "rows": [ + 0 + ], + "cols": [ + "order_cnt" + ], + "cells": [ + [ + 0, + "order_cnt" + ] + ] + }, + "masked": [], + "hash_ok": true, + "sealed": true + } + ], + "note": "数字由系统从证据里取出、按口径卡的格式渲染,没有经过模型转写", + "violations": [], + "seal": { + "sealed": true, + "ok": true, + "covered": true + }, + "redacted": { + "columns": [] + } + }, + "s10": { + "report": { + "node_id": "write", + "doc_artifact": "18e1dbb10ace41fa26d4b70ee72e8da461cf3b2e4b181672dda59f65fd924ddb" + }, + "segment": { + "id": "s10", + "text": "37.0元", + "kind": "number", + "state": "deterministic", + "span": [ + 49, + 54 + ], + "unit": "u1", + "ref": "m:aov", + "cite": { + "ref": "aov", + "alias": "m:aov", + "locator": { + "metric": "aov" + }, + "eid": "ev:metric:72d2a805ddf72604", + "kind": "metric", + "role": "value", + "status": "resolved", + "value": 37.0, + "rendered": "37.0元" + } + }, + "unit": { + "id": "u1", + "kind": "claim", + "text": "本周(2026-W37)销售额 45,678.5元,订单 1,234单,客单价 37.0元,环比 8.7%。", + "span": [ + 9, + 63 + ], + "cites": [ + "i:week", + "m:gmv", + "m:orders", + "m:aov", + "m:wow" + ] + }, + "block": { + "id": "b1", + "type": "paragraph" + }, + "chain": [ + { + "step": "metric", + "alias": "m:aov", + "metric": "aov", + "name": "客单价", + "value": 37.0, + "unit": "元", + "decimals": 1, + "format": "thousands", + "conv": null, + "rendered": "37.0元", + "caliber": "周报口径", + "version": "v3", + "expression": "round(vars.kpi.gmv / vars.kpi.order_cnt, 1)", + "substituted": "round(45678.5 / 1234, 1)", + "recompute_ok": true, + "status": "ok", + "node_id": "caliber", + "artifact": "85c4b5948c7e4cfab4472c5f250183e44a64d9139e342bbcdf52f14881b03d55", + "eid": "ev:metric:72d2a805ddf72604", + "eid_ok": true, + "hash_ok": true, + "render_ok": true, + "sealed": true, + "source": { + "workflow_id": "wf-demo-weekly", + "workflow_version": 5, + "node_id": "caliber", + "workflow_name": "销售周报" + }, + "caliber_upgrade": { + "workflow_id": "wf-demo-weekly", + "caliber_node": "caliber", + "pinned": 5, + "latest": 6, + "policy": "dual", + "policy_label": "并排双印新旧口径" + } + }, + { + "step": "input", + "path": "vars.kpi.gmv", + "value": 45678.5, + "node_id": "fetch", + "via": "agent_field", + "field": "gmv", + "status": "verified", + "ref": "Q1.r0.gmv", + "artifact": "175b086d4701b620222f4b4178cee1275139cf081da12550a30ff99fd6de26e5", + "locator": { + "row": 0, + "column": "gmv" + }, + "eid": "ev:cell:c8c89e05312eaba7", + "query": "Q1", + "cell": "Q1.r0.gmv" + }, + { + "step": "input", + "path": "vars.kpi.order_cnt", + "value": 1234, + "node_id": "fetch", + "via": "agent_field", + "field": "order_cnt", + "status": "mismatch", + "ref": "Q1.r0.order_cnt", + "artifact": "175b086d4701b620222f4b4178cee1275139cf081da12550a30ff99fd6de26e5", + "locator": { + "row": 0, + "column": "order_cnt" + }, + "eid": "ev:cell:f39bedaf6a670641", + "model_value": 1240, + "query": "Q1", + "cell": "Q1.r0.order_cnt" + }, + { + "step": "query", + "alias": "Q1", + "artifact": "175b086d4701b620222f4b4178cee1275139cf081da12550a30ff99fd6de26e5", + "node_id": "fetch", + "tool": "db_query__shop", + "source": "shop", + "sql": "SELECT SUM(amount) AS gmv, COUNT(*) AS order_cnt, SUM(is_refund) AS refund_cnt\nFROM orders\nWHERE week = '2026-W37'", + "columns": [ + "gmv", + "order_cnt", + "refund_cnt" + ], + "rows": [ + [ + 45678.5, + 1234, + 29 + ] + ], + "row_offset": 0, + "row_index": [ + 0 + ], + "total_rows": 1, + "truncated": false, + "highlight": { + "rows": [ + 0 + ], + "cols": [ + "gmv", + "order_cnt" + ], + "cells": [ + [ + 0, + "gmv" + ], + [ + 0, + "order_cnt" + ] + ] + }, + "masked": [], + "hash_ok": true, + "sealed": true + } + ], + "note": "数字由系统从证据里取出、按口径卡的格式渲染,没有经过模型转写", + "violations": [], + "seal": { + "sealed": true, + "ok": true, + "covered": true + }, + "redacted": { + "columns": [] + } + }, + "s12": { + "report": { + "node_id": "write", + "doc_artifact": "18e1dbb10ace41fa26d4b70ee72e8da461cf3b2e4b181672dda59f65fd924ddb" + }, + "segment": { + "id": "s12", + "text": "8.7%", + "kind": "number", + "state": "deterministic", + "span": [ + 58, + 62 + ], + "unit": "u1", + "ref": "m:wow", + "cite": { + "ref": "wow", + "alias": "m:wow", + "locator": { + "metric": "wow" + }, + "eid": "ev:metric:74c193598c403111", + "kind": "metric", + "role": "value", + "status": "resolved", + "value": 8.7, + "rendered": "8.7%" + } + }, + "unit": { + "id": "u1", + "kind": "claim", + "text": "本周(2026-W37)销售额 45,678.5元,订单 1,234单,客单价 37.0元,环比 8.7%。", + "span": [ + 9, + 63 + ], + "cites": [ + "i:week", + "m:gmv", + "m:orders", + "m:aov", + "m:wow" + ] + }, + "block": { + "id": "b1", + "type": "paragraph" + }, + "chain": [ + { + "step": "metric", + "alias": "m:wow", + "metric": "wow", + "name": "环比增幅", + "value": 8.7, + "unit": "%", + "decimals": 1, + "format": "plain", + "conv": null, + "rendered": "8.7%", + "caliber": "周报口径", + "version": "v3", + "expression": "round((vars.kpi.gmv - cell(nodes.pull, 0, 'gmv')) / cell(nodes.pull, 0, 'gmv') * 100, 1)", + "substituted": "round((45678.5 - 42010.0) / 42010.0 * 100, 1)", + "recompute_ok": true, + "status": "ok", + "node_id": "caliber", + "artifact": "85c4b5948c7e4cfab4472c5f250183e44a64d9139e342bbcdf52f14881b03d55", + "eid": "ev:metric:74c193598c403111", + "eid_ok": true, + "hash_ok": true, + "render_ok": true, + "sealed": true, + "source": { + "workflow_id": "wf-demo-weekly", + "workflow_version": 5, + "node_id": "caliber", + "workflow_name": "销售周报" + }, + "caliber_upgrade": { + "workflow_id": "wf-demo-weekly", + "caliber_node": "caliber", + "pinned": 5, + "latest": 6, + "policy": "dual", + "policy_label": "并排双印新旧口径" + } + }, + { + "step": "input", + "path": "vars.kpi.gmv", + "value": 45678.5, + "node_id": "fetch", + "via": "agent_field", + "field": "gmv", + "status": "verified", + "ref": "Q1.r0.gmv", + "artifact": "175b086d4701b620222f4b4178cee1275139cf081da12550a30ff99fd6de26e5", + "locator": { + "row": 0, + "column": "gmv" + }, + "eid": "ev:cell:c8c89e05312eaba7", + "query": "Q1", + "cell": "Q1.r0.gmv" + }, + { + "step": "input", + "path": "cell(nodes.pull, 0, 'gmv')", + "value": 42010.0, + "node_id": "pull", + "via": "tool_cell", + "locator": { + "row": 0, + "column": "gmv" + }, + "artifact": "30fe0bb2c76a873f0f3dc2bd244b6ba655d7af7a4f4e863630d353cfab42b3ad", + "eid": "ev:cell:d22c08cb7caec778", + "status": "verified", + "query": "Q4", + "cell": "Q4.r0.gmv" + }, + { + "step": "query", + "alias": "Q1", + "artifact": "175b086d4701b620222f4b4178cee1275139cf081da12550a30ff99fd6de26e5", + "node_id": "fetch", + "tool": "db_query__shop", + "source": "shop", + "sql": "SELECT SUM(amount) AS gmv, COUNT(*) AS order_cnt, SUM(is_refund) AS refund_cnt\nFROM orders\nWHERE week = '2026-W37'", + "columns": [ + "gmv", + "order_cnt", + "refund_cnt" + ], + "rows": [ + [ + 45678.5, + 1234, + 29 + ] + ], + "row_offset": 0, + "row_index": [ + 0 + ], + "total_rows": 1, + "truncated": false, + "highlight": { + "rows": [ + 0 + ], + "cols": [ + "gmv" + ], + "cells": [ + [ + 0, + "gmv" + ] + ] + }, + "masked": [], + "hash_ok": true, + "sealed": true + }, + { + "step": "query", + "alias": "Q4", + "artifact": "30fe0bb2c76a873f0f3dc2bd244b6ba655d7af7a4f4e863630d353cfab42b3ad", + "node_id": "pull", + "tool": "db_query__shop", + "source": "shop", + "sql": "SELECT SUM(amount) AS gmv FROM orders WHERE week = '2026-W36'", + "columns": [ + "gmv" + ], + "rows": [ + [ + 42010.0 + ] + ], + "row_offset": 0, + "row_index": [ + 0 + ], + "total_rows": 1, + "truncated": false, + "highlight": { + "rows": [ + 0 + ], + "cols": [ + "gmv" + ], + "cells": [ + [ + 0, + "gmv" + ] + ] + }, + "masked": [], + "hash_ok": true, + "sealed": true + } + ], + "note": "数字由系统从证据里取出、按口径卡的格式渲染,没有经过模型转写", + "violations": [], + "seal": { + "sealed": true, + "ok": true, + "covered": true + }, + "redacted": { + "columns": [] + } + }, + "s16": { + "report": { + "node_id": "write", + "doc_artifact": "18e1dbb10ace41fa26d4b70ee72e8da461cf3b2e4b181672dda59f65fd924ddb" + }, + "segment": { + "id": "s16", + "text": "—", + "kind": "number", + "state": "none", + "span": [ + 69, + 70 + ], + "unit": "u2", + "ref": "m:refund_rate", + "issue": "unresolved_ref", + "cite": { + "ref": "refund_rate", + "alias": "m:refund_rate", + "kind": "metric", + "role": "value", + "status": "unresolved", + "reason": "指标「退款率」这次没有值(缺输入),不能写进报告", + "rendered": "—" + } + }, + "unit": { + "id": "u2", + "kind": "claim", + "text": "退款率 —,新客 —:这次都没查到。", + "span": [ + 65, + 83 + ], + "cites": [] + }, + "block": { + "id": "b2", + "type": "paragraph" + }, + "chain": [ + { + "step": "metric", + "alias": "m:refund_rate", + "metric": "refund_rate", + "name": "退款率", + "value": null, + "unit": "", + "decimals": 4, + "format": "percent_of_ratio", + "conv": null, + "rendered": "—", + "caliber": "周报口径", + "version": "v3", + "expression": "vars.kpi.refund_cnt / vars.kpi.order_cnt", + "substituted": "vars.kpi.refund_cnt / 1234", + "recompute_ok": null, + "status": "missing_input", + "node_id": "caliber", + "artifact": "85c4b5948c7e4cfab4472c5f250183e44a64d9139e342bbcdf52f14881b03d55", + "eid": "ev:metric:1f7a71663ab15697", + "eid_ok": true, + "hash_ok": true, + "render_ok": true, + "sealed": true, + "source": { + "workflow_id": "wf-demo-weekly", + "workflow_version": 5, + "node_id": "caliber", + "workflow_name": "销售周报" + }, + "caliber_upgrade": { + "workflow_id": "wf-demo-weekly", + "caliber_node": "caliber", + "pinned": 5, + "latest": 6, + "policy": "dual", + "policy_label": "并排双印新旧口径" + } + }, + { + "step": "input", + "path": "vars.kpi.refund_cnt", + "value": null, + "node_id": "fetch", + "via": "agent_field", + "field": "refund_cnt", + "status": "missing", + "ref": null + }, + { + "step": "input", + "path": "vars.kpi.order_cnt", + "value": 1234, + "node_id": "fetch", + "via": "agent_field", + "field": "order_cnt", + "status": "mismatch", + "ref": "Q1.r0.order_cnt", + "artifact": "175b086d4701b620222f4b4178cee1275139cf081da12550a30ff99fd6de26e5", + "locator": { + "row": 0, + "column": "order_cnt" + }, + "eid": "ev:cell:f39bedaf6a670641", + "model_value": 1240, + "query": "Q1", + "cell": "Q1.r0.order_cnt" + }, + { + "step": "query", + "alias": "Q1", + "artifact": "175b086d4701b620222f4b4178cee1275139cf081da12550a30ff99fd6de26e5", + "node_id": "fetch", + "tool": "db_query__shop", + "source": "shop", + "sql": "SELECT SUM(amount) AS gmv, COUNT(*) AS order_cnt, SUM(is_refund) AS refund_cnt\nFROM orders\nWHERE week = '2026-W37'", + "columns": [ + "gmv", + "order_cnt", + "refund_cnt" + ], + "rows": [ + [ + 45678.5, + 1234, + 29 + ] + ], + "row_offset": 0, + "row_index": [ + 0 + ], + "total_rows": 1, + "truncated": false, + "highlight": { + "rows": [ + 0 + ], + "cols": [ + "order_cnt" + ], + "cells": [ + [ + 0, + "order_cnt" + ] + ] + }, + "masked": [], + "hash_ok": true, + "sealed": true + } + ], + "note": "引用解析不了:指标「退款率」这次没有值(缺输入),不能写进报告", + "violations": [ + { + "code": "unresolved_ref", + "message": "引用 [[m:refund_rate]] 解析不了:指标「退款率」这次没有值(缺输入),不能写进报告", + "ref": "m:refund_rate", + "span": [ + 69, + 70 + ], + "text": "—", + "segment": "s16", + "unit": "u2" + } + ], + "seal": { + "sealed": true, + "ok": true, + "covered": true + }, + "redacted": { + "columns": [] + } + }, + "s18": { + "report": { + "node_id": "write", + "doc_artifact": "18e1dbb10ace41fa26d4b70ee72e8da461cf3b2e4b181672dda59f65fd924ddb" + }, + "segment": { + "id": "s18", + "text": "—", + "kind": "number", + "state": "none", + "span": [ + 74, + 75 + ], + "unit": "u2", + "ref": "m:new_users", + "issue": "unresolved_ref", + "cite": { + "ref": "new_users", + "alias": "m:new_users", + "kind": "metric", + "role": "value", + "status": "unresolved", + "reason": "指标「新客数」这次没有值(缺输入),不能写进报告", + "rendered": "—" + } + }, + "unit": { + "id": "u2", + "kind": "claim", + "text": "退款率 —,新客 —:这次都没查到。", + "span": [ + 65, + 83 + ], + "cites": [] + }, + "block": { + "id": "b2", + "type": "paragraph" + }, + "chain": [ + { + "step": "metric", + "alias": "m:new_users", + "metric": "new_users", + "name": "新客数", + "value": null, + "unit": "人", + "decimals": 0, + "format": "thousands", + "conv": null, + "rendered": "—", + "caliber": "周报口径", + "version": "v3", + "expression": "vars.kpi.new_users", + "substituted": "vars.kpi.new_users", + "recompute_ok": null, + "status": "missing_input", + "node_id": "caliber", + "artifact": "85c4b5948c7e4cfab4472c5f250183e44a64d9139e342bbcdf52f14881b03d55", + "eid": "ev:metric:4430c7b636713290", + "eid_ok": true, + "hash_ok": true, + "render_ok": true, + "sealed": true, + "source": { + "workflow_id": "wf-demo-weekly", + "workflow_version": 5, + "node_id": "caliber", + "workflow_name": "销售周报" + }, + "caliber_upgrade": { + "workflow_id": "wf-demo-weekly", + "caliber_node": "caliber", + "pinned": 5, + "latest": 6, + "policy": "dual", + "policy_label": "并排双印新旧口径" + } + }, + { + "step": "input", + "path": "vars.kpi.new_users", + "value": null, + "node_id": "fetch", + "via": "agent_field", + "field": "new_users", + "status": "unresolved", + "ref": "Q1.r0.new_users", + "artifact": "175b086d4701b620222f4b4178cee1275139cf081da12550a30ff99fd6de26e5", + "reason": "Q1 没有列「new_users」,有:gmv、order_cnt、refund_cnt", + "query": "Q1" + } + ], + "note": "引用解析不了:指标「新客数」这次没有值(缺输入),不能写进报告", + "violations": [ + { + "code": "unresolved_ref", + "message": "引用 [[m:new_users]] 解析不了:指标「新客数」这次没有值(缺输入),不能写进报告", + "ref": "m:new_users", + "span": [ + 74, + 75 + ], + "text": "—", + "segment": "s18", + "unit": "u2" + } + ], + "seal": { + "sealed": true, + "ok": true, + "covered": true + }, + "redacted": { + "columns": [] + } + }, + "s22": { + "report": { + "node_id": "write", + "doc_artifact": "18e1dbb10ace41fa26d4b70ee72e8da461cf3b2e4b181672dda59f65fd924ddb" + }, + "segment": { + "id": "s22", + "text": "44,000.0元", + "kind": "number", + "state": "deterministic", + "span": [ + 99, + 108 + ], + "unit": "u3", + "ref": "m:adj", + "cite": { + "ref": "adj", + "alias": "m:adj", + "locator": { + "metric": "adj" + }, + "eid": "ev:metric:b9c97d4b59a25a23", + "kind": "metric", + "role": "value", + "status": "resolved", + "value": 44000, + "rendered": "44,000.0元" + } + }, + "unit": { + "id": "u3", + "kind": "claim", + "text": "扣掉促销后的调整后销售额是 44,000.0元,按汇率 7.12 折算。", + "span": [ + 85, + 121 + ], + "cites": [ + "m:adj", + "m:fx" + ] + }, + "block": { + "id": "b3", + "type": "paragraph" + }, + "chain": [ + { + "step": "metric", + "alias": "m:adj", + "metric": "adj", + "name": "调整后销售额", + "value": 44000, + "unit": "元", + "decimals": 1, + "format": "thousands", + "conv": null, + "rendered": "44,000.0元", + "caliber": "周报口径", + "version": "v3", + "expression": "nodes.adjust.total", + "substituted": "44000", + "recompute_ok": true, + "status": "ok", + "node_id": "caliber", + "artifact": "85c4b5948c7e4cfab4472c5f250183e44a64d9139e342bbcdf52f14881b03d55", + "eid": "ev:metric:b9c97d4b59a25a23", + "eid_ok": true, + "hash_ok": true, + "render_ok": true, + "sealed": true, + "source": { + "workflow_id": "wf-demo-weekly", + "workflow_version": 5, + "node_id": "caliber", + "workflow_name": "销售周报" + }, + "caliber_upgrade": { + "workflow_id": "wf-demo-weekly", + "caliber_node": "caliber", + "pinned": 5, + "latest": 6, + "policy": "dual", + "policy_label": "并排双印新旧口径" + } + }, + { + "step": "input", + "path": "nodes.adjust.total", + "value": 44000, + "node_id": "adjust", + "via": "code", + "field": "total", + "role": "compute", + "status": "ok" + } + ], + "note": "数字由系统从证据里取出、按口径卡的格式渲染,没有经过模型转写", + "violations": [], + "seal": { + "sealed": true, + "ok": true, + "covered": true + }, + "redacted": { + "columns": [] + } + }, + "s24": { + "report": { + "node_id": "write", + "doc_artifact": "18e1dbb10ace41fa26d4b70ee72e8da461cf3b2e4b181672dda59f65fd924ddb" + }, + "segment": { + "id": "s24", + "text": "7.12", + "kind": "number", + "state": "deterministic", + "span": [ + 113, + 117 + ], + "unit": "u3", + "ref": "m:fx", + "cite": { + "ref": "fx", + "alias": "m:fx", + "locator": { + "metric": "fx" + }, + "eid": "ev:metric:86539c1b7e4afa0e", + "kind": "metric", + "role": "value", + "status": "resolved", + "value": 7.12, + "rendered": "7.12" + } + }, + "unit": { + "id": "u3", + "kind": "claim", + "text": "扣掉促销后的调整后销售额是 44,000.0元,按汇率 7.12 折算。", + "span": [ + 85, + 121 + ], + "cites": [ + "m:adj", + "m:fx" + ] + }, + "block": { + "id": "b3", + "type": "paragraph" + }, + "chain": [ + { + "step": "metric", + "alias": "m:fx", + "metric": "fx", + "name": "汇率", + "value": 7.12, + "unit": "", + "decimals": 2, + "format": "plain", + "conv": null, + "rendered": "7.12", + "caliber": "周报口径", + "version": "v3", + "expression": "nodes.rate.value", + "substituted": "7.12", + "recompute_ok": true, + "status": "ok", + "node_id": "caliber", + "artifact": "85c4b5948c7e4cfab4472c5f250183e44a64d9139e342bbcdf52f14881b03d55", + "eid": "ev:metric:86539c1b7e4afa0e", + "eid_ok": true, + "hash_ok": true, + "render_ok": true, + "sealed": true, + "source": { + "workflow_id": "wf-demo-weekly", + "workflow_version": 5, + "node_id": "caliber", + "workflow_name": "销售周报" + }, + "caliber_upgrade": { + "workflow_id": "wf-demo-weekly", + "caliber_node": "caliber", + "pinned": 5, + "latest": 6, + "policy": "dual", + "policy_label": "并排双印新旧口径" + } + }, + { + "step": "input", + "path": "nodes.rate.value", + "value": 7.12, + "node_id": "rate", + "via": "code", + "field": "value", + "role": "source", + "status": "ok" + } + ], + "note": "数字由系统从证据里取出、按口径卡的格式渲染,没有经过模型转写", + "violations": [], + "seal": { + "sealed": true, + "ok": true, + "covered": true + }, + "redacted": { + "columns": [] + } + }, + "s29": { + "report": { + "node_id": "write", + "doc_artifact": "18e1dbb10ace41fa26d4b70ee72e8da461cf3b2e4b181672dda59f65fd924ddb" + }, + "segment": { + "id": "s29", + "text": "华东", + "kind": "value", + "state": "deterministic", + "span": [ + 131, + 133 + ], + "unit": "u5", + "ref": "v:Q2.r0.region", + "cite": { + "ref": "Q2.r0.region", + "alias": "Q2", + "locator": { + "row": 0, + "column": "region" + }, + "eid": "ev:cell:39caf88002d122ff", + "kind": "cell", + "role": "value", + "status": "resolved", + "value": "华东", + "rendered": "华东" + } + }, + "unit": { + "id": "u5", + "kind": "claim", + "text": "华东销售额最高,为 18,230.5,其次是华南(12,004)。", + "span": [ + 131, + 164 + ], + "cites": [ + "Q2" + ] + }, + "block": { + "id": "b5", + "type": "paragraph" + }, + "chain": [ + { + "step": "query", + "alias": "Q2", + "artifact": "4f25cb3d0ef3155f54370d8f4e2f9c3673bc28a779db0b37602bd694c6726b32", + "node_id": "fetch", + "tool": "db_query__shop", + "source": "shop", + "sql": "SELECT region, SUM(amount) AS amount, COUNT(*) AS order_cnt\nFROM orders\nWHERE week = '2026-W37'\nGROUP BY region\nORDER BY amount DESC", + "columns": [ + "region", + "amount", + "order_cnt" + ], + "rows": [ + [ + "华东", + 18230.5, + 402 + ], + [ + "华南", + 12004.0, + 311 + ], + [ + "华北", + 9876.0, + 298 + ] + ], + "row_offset": 0, + "row_index": [ + 0, + 1, + 2 + ], + "total_rows": 4, + "truncated": false, + "highlight": { + "rows": [ + 0 + ], + "cols": [ + "region" + ], + "cells": [ + [ + 0, + "region" + ] + ] + }, + "masked": [], + "hash_ok": true, + "sealed": true + } + ], + "note": "数字由系统从证据里取出、按口径卡的格式渲染,没有经过模型转写", + "violations": [], + "seal": { + "sealed": true, + "ok": true, + "covered": true + }, + "redacted": { + "columns": [] + } + }, + "s31": { + "report": { + "node_id": "write", + "doc_artifact": "18e1dbb10ace41fa26d4b70ee72e8da461cf3b2e4b181672dda59f65fd924ddb" + }, + "segment": { + "id": "s31", + "text": "18,230.5", + "kind": "number", + "state": "deterministic", + "span": [ + 141, + 149 + ], + "unit": "u5", + "ref": "v:Q2.r0.amount", + "cite": { + "ref": "Q2.r0.amount", + "alias": "Q2", + "locator": { + "row": 0, + "column": "amount" + }, + "eid": "ev:cell:0803f091d4c868cd", + "kind": "cell", + "role": "value", + "status": "resolved", + "value": 18230.5, + "rendered": "18,230.5" + } + }, + "unit": { + "id": "u5", + "kind": "claim", + "text": "华东销售额最高,为 18,230.5,其次是华南(12,004)。", + "span": [ + 131, + 164 + ], + "cites": [ + "Q2" + ] + }, + "block": { + "id": "b5", + "type": "paragraph" + }, + "chain": [ + { + "step": "query", + "alias": "Q2", + "artifact": "4f25cb3d0ef3155f54370d8f4e2f9c3673bc28a779db0b37602bd694c6726b32", + "node_id": "fetch", + "tool": "db_query__shop", + "source": "shop", + "sql": "SELECT region, SUM(amount) AS amount, COUNT(*) AS order_cnt\nFROM orders\nWHERE week = '2026-W37'\nGROUP BY region\nORDER BY amount DESC", + "columns": [ + "region", + "amount", + "order_cnt" + ], + "rows": [ + [ + "华东", + 18230.5, + 402 + ], + [ + "华南", + 12004.0, + 311 + ], + [ + "华北", + 9876.0, + 298 + ] + ], + "row_offset": 0, + "row_index": [ + 0, + 1, + 2 + ], + "total_rows": 4, + "truncated": false, + "highlight": { + "rows": [ + 0 + ], + "cols": [ + "amount" + ], + "cells": [ + [ + 0, + "amount" + ] + ] + }, + "masked": [], + "hash_ok": true, + "sealed": true + } + ], + "note": "数字由系统从证据里取出、按口径卡的格式渲染,没有经过模型转写", + "violations": [], + "seal": { + "sealed": true, + "ok": true, + "covered": true + }, + "redacted": { + "columns": [] + } + }, + "s33": { + "report": { + "node_id": "write", + "doc_artifact": "18e1dbb10ace41fa26d4b70ee72e8da461cf3b2e4b181672dda59f65fd924ddb" + }, + "segment": { + "id": "s33", + "text": "华南", + "kind": "value", + "state": "deterministic", + "span": [ + 153, + 155 + ], + "unit": "u5", + "ref": "v:Q2.r1.region", + "cite": { + "ref": "Q2.r1.region", + "alias": "Q2", + "locator": { + "row": 1, + "column": "region" + }, + "eid": "ev:cell:7a7cb1e58c599f14", + "kind": "cell", + "role": "value", + "status": "resolved", + "value": "华南", + "rendered": "华南" + } + }, + "unit": { + "id": "u5", + "kind": "claim", + "text": "华东销售额最高,为 18,230.5,其次是华南(12,004)。", + "span": [ + 131, + 164 + ], + "cites": [ + "Q2" + ] + }, + "block": { + "id": "b5", + "type": "paragraph" + }, + "chain": [ + { + "step": "query", + "alias": "Q2", + "artifact": "4f25cb3d0ef3155f54370d8f4e2f9c3673bc28a779db0b37602bd694c6726b32", + "node_id": "fetch", + "tool": "db_query__shop", + "source": "shop", + "sql": "SELECT region, SUM(amount) AS amount, COUNT(*) AS order_cnt\nFROM orders\nWHERE week = '2026-W37'\nGROUP BY region\nORDER BY amount DESC", + "columns": [ + "region", + "amount", + "order_cnt" + ], + "rows": [ + [ + "华东", + 18230.5, + 402 + ], + [ + "华南", + 12004.0, + 311 + ], + [ + "华北", + 9876.0, + 298 + ], + [ + "西南", + 5568.0, + 223 + ] + ], + "row_offset": 0, + "row_index": [ + 0, + 1, + 2, + 3 + ], + "total_rows": 4, + "truncated": false, + "highlight": { + "rows": [ + 1 + ], + "cols": [ + "region" + ], + "cells": [ + [ + 1, + "region" + ] + ] + }, + "masked": [], + "hash_ok": true, + "sealed": true + } + ], + "note": "数字由系统从证据里取出、按口径卡的格式渲染,没有经过模型转写", + "violations": [], + "seal": { + "sealed": true, + "ok": true, + "covered": true + }, + "redacted": { + "columns": [] + } + }, + "s35": { + "report": { + "node_id": "write", + "doc_artifact": "18e1dbb10ace41fa26d4b70ee72e8da461cf3b2e4b181672dda59f65fd924ddb" + }, + "segment": { + "id": "s35", + "text": "12,004", + "kind": "number", + "state": "deterministic", + "span": [ + 156, + 162 + ], + "unit": "u5", + "ref": "v:Q2.r1.amount", + "cite": { + "ref": "Q2.r1.amount", + "alias": "Q2", + "locator": { + "row": 1, + "column": "amount" + }, + "eid": "ev:cell:85b22e06175c4c74", + "kind": "cell", + "role": "value", + "status": "resolved", + "value": 12004.0, + "rendered": "12,004" + } + }, + "unit": { + "id": "u5", + "kind": "claim", + "text": "华东销售额最高,为 18,230.5,其次是华南(12,004)。", + "span": [ + 131, + 164 + ], + "cites": [ + "Q2" + ] + }, + "block": { + "id": "b5", + "type": "paragraph" + }, + "chain": [ + { + "step": "query", + "alias": "Q2", + "artifact": "4f25cb3d0ef3155f54370d8f4e2f9c3673bc28a779db0b37602bd694c6726b32", + "node_id": "fetch", + "tool": "db_query__shop", + "source": "shop", + "sql": "SELECT region, SUM(amount) AS amount, COUNT(*) AS order_cnt\nFROM orders\nWHERE week = '2026-W37'\nGROUP BY region\nORDER BY amount DESC", + "columns": [ + "region", + "amount", + "order_cnt" + ], + "rows": [ + [ + "华东", + 18230.5, + 402 + ], + [ + "华南", + 12004.0, + 311 + ], + [ + "华北", + 9876.0, + 298 + ], + [ + "西南", + 5568.0, + 223 + ] + ], + "row_offset": 0, + "row_index": [ + 0, + 1, + 2, + 3 + ], + "total_rows": 4, + "truncated": false, + "highlight": { + "rows": [ + 1 + ], + "cols": [ + "amount" + ], + "cells": [ + [ + 1, + "amount" + ] + ] + }, + "masked": [], + "hash_ok": true, + "sealed": true + } + ], + "note": "数字由系统从证据里取出、按口径卡的格式渲染,没有经过模型转写", + "violations": [], + "seal": { + "sealed": true, + "ok": true, + "covered": true + }, + "redacted": { + "columns": [] + } + }, + "s44": { + "report": { + "node_id": "write", + "doc_artifact": "18e1dbb10ace41fa26d4b70ee72e8da461cf3b2e4b181672dda59f65fd924ddb" + }, + "segment": { + "id": "s44", + "text": "华东", + "kind": "value", + "state": "deterministic", + "span": [ + 220, + 222 + ], + "unit": "u9", + "ref": "v:Q2.r0.region", + "cite": { + "ref": "Q2.r0.region", + "alias": "Q2", + "locator": { + "row": 0, + "column": "region" + }, + "eid": "ev:cell:39caf88002d122ff", + "kind": "cell", + "role": "value", + "status": "resolved", + "value": "华东", + "rendered": "华东" + } + }, + "unit": { + "id": "u9", + "kind": "claim", + "text": "华东", + "span": [ + 220, + 222 + ], + "cites": [ + "Q2" + ] + }, + "block": { + "id": "b6", + "type": "table" + }, + "chain": [ + { + "step": "query", + "alias": "Q2", + "artifact": "4f25cb3d0ef3155f54370d8f4e2f9c3673bc28a779db0b37602bd694c6726b32", + "node_id": "fetch", + "tool": "db_query__shop", + "source": "shop", + "sql": "SELECT region, SUM(amount) AS amount, COUNT(*) AS order_cnt\nFROM orders\nWHERE week = '2026-W37'\nGROUP BY region\nORDER BY amount DESC", + "columns": [ + "region", + "amount", + "order_cnt" + ], + "rows": [ + [ + "华东", + 18230.5, + 402 + ], + [ + "华南", + 12004.0, + 311 + ], + [ + "华北", + 9876.0, + 298 + ] + ], + "row_offset": 0, + "row_index": [ + 0, + 1, + 2 + ], + "total_rows": 4, + "truncated": false, + "highlight": { + "rows": [ + 0 + ], + "cols": [ + "region" + ], + "cells": [ + [ + 0, + "region" + ] + ] + }, + "masked": [], + "hash_ok": true, + "sealed": true + } + ], + "note": "数字由系统从证据里取出、按口径卡的格式渲染,没有经过模型转写", + "violations": [], + "seal": { + "sealed": true, + "ok": true, + "covered": true + }, + "redacted": { + "columns": [] + } + }, + "s46": { + "report": { + "node_id": "write", + "doc_artifact": "18e1dbb10ace41fa26d4b70ee72e8da461cf3b2e4b181672dda59f65fd924ddb" + }, + "segment": { + "id": "s46", + "text": "18,230.5", + "kind": "number", + "state": "deterministic", + "span": [ + 225, + 233 + ], + "unit": "u10", + "ref": "v:Q2.r0.amount", + "cite": { + "ref": "Q2.r0.amount", + "alias": "Q2", + "locator": { + "row": 0, + "column": "amount" + }, + "eid": "ev:cell:0803f091d4c868cd", + "kind": "cell", + "role": "value", + "status": "resolved", + "value": 18230.5, + "rendered": "18,230.5" + } + }, + "unit": { + "id": "u10", + "kind": "claim", + "text": "18,230.5", + "span": [ + 225, + 233 + ], + "cites": [ + "Q2" + ] + }, + "block": { + "id": "b6", + "type": "table" + }, + "chain": [ + { + "step": "query", + "alias": "Q2", + "artifact": "4f25cb3d0ef3155f54370d8f4e2f9c3673bc28a779db0b37602bd694c6726b32", + "node_id": "fetch", + "tool": "db_query__shop", + "source": "shop", + "sql": "SELECT region, SUM(amount) AS amount, COUNT(*) AS order_cnt\nFROM orders\nWHERE week = '2026-W37'\nGROUP BY region\nORDER BY amount DESC", + "columns": [ + "region", + "amount", + "order_cnt" + ], + "rows": [ + [ + "华东", + 18230.5, + 402 + ], + [ + "华南", + 12004.0, + 311 + ], + [ + "华北", + 9876.0, + 298 + ] + ], + "row_offset": 0, + "row_index": [ + 0, + 1, + 2 + ], + "total_rows": 4, + "truncated": false, + "highlight": { + "rows": [ + 0 + ], + "cols": [ + "amount" + ], + "cells": [ + [ + 0, + "amount" + ] + ] + }, + "masked": [], + "hash_ok": true, + "sealed": true + } + ], + "note": "数字由系统从证据里取出、按口径卡的格式渲染,没有经过模型转写", + "violations": [], + "seal": { + "sealed": true, + "ok": true, + "covered": true + }, + "redacted": { + "columns": [] + } + }, + "s48": { + "report": { + "node_id": "write", + "doc_artifact": "18e1dbb10ace41fa26d4b70ee72e8da461cf3b2e4b181672dda59f65fd924ddb" + }, + "segment": { + "id": "s48", + "text": "402", + "kind": "number", + "state": "deterministic", + "span": [ + 236, + 239 + ], + "unit": "u11", + "ref": "v:Q2.r0.order_cnt", + "cite": { + "ref": "Q2.r0.order_cnt", + "alias": "Q2", + "locator": { + "row": 0, + "column": "order_cnt" + }, + "eid": "ev:cell:c6ba44059fc49fa7", + "kind": "cell", + "role": "value", + "status": "resolved", + "value": 402, + "rendered": "402" + } + }, + "unit": { + "id": "u11", + "kind": "claim", + "text": "402", + "span": [ + 236, + 239 + ], + "cites": [ + "Q2" + ] + }, + "block": { + "id": "b6", + "type": "table" + }, + "chain": [ + { + "step": "query", + "alias": "Q2", + "artifact": "4f25cb3d0ef3155f54370d8f4e2f9c3673bc28a779db0b37602bd694c6726b32", + "node_id": "fetch", + "tool": "db_query__shop", + "source": "shop", + "sql": "SELECT region, SUM(amount) AS amount, COUNT(*) AS order_cnt\nFROM orders\nWHERE week = '2026-W37'\nGROUP BY region\nORDER BY amount DESC", + "columns": [ + "region", + "amount", + "order_cnt" + ], + "rows": [ + [ + "华东", + 18230.5, + 402 + ], + [ + "华南", + 12004.0, + 311 + ], + [ + "华北", + 9876.0, + 298 + ] + ], + "row_offset": 0, + "row_index": [ + 0, + 1, + 2 + ], + "total_rows": 4, + "truncated": false, + "highlight": { + "rows": [ + 0 + ], + "cols": [ + "order_cnt" + ], + "cells": [ + [ + 0, + "order_cnt" + ] + ] + }, + "masked": [], + "hash_ok": true, + "sealed": true + } + ], + "note": "数字由系统从证据里取出、按口径卡的格式渲染,没有经过模型转写", + "violations": [], + "seal": { + "sealed": true, + "ok": true, + "covered": true + }, + "redacted": { + "columns": [] + } + }, + "s50": { + "report": { + "node_id": "write", + "doc_artifact": "18e1dbb10ace41fa26d4b70ee72e8da461cf3b2e4b181672dda59f65fd924ddb" + }, + "segment": { + "id": "s50", + "text": "华南", + "kind": "value", + "state": "deterministic", + "span": [ + 244, + 246 + ], + "unit": "u12", + "ref": "v:Q2.r1.region", + "cite": { + "ref": "Q2.r1.region", + "alias": "Q2", + "locator": { + "row": 1, + "column": "region" + }, + "eid": "ev:cell:7a7cb1e58c599f14", + "kind": "cell", + "role": "value", + "status": "resolved", + "value": "华南", + "rendered": "华南" + } + }, + "unit": { + "id": "u12", + "kind": "claim", + "text": "华南", + "span": [ + 244, + 246 + ], + "cites": [ + "Q2" + ] + }, + "block": { + "id": "b6", + "type": "table" + }, + "chain": [ + { + "step": "query", + "alias": "Q2", + "artifact": "4f25cb3d0ef3155f54370d8f4e2f9c3673bc28a779db0b37602bd694c6726b32", + "node_id": "fetch", + "tool": "db_query__shop", + "source": "shop", + "sql": "SELECT region, SUM(amount) AS amount, COUNT(*) AS order_cnt\nFROM orders\nWHERE week = '2026-W37'\nGROUP BY region\nORDER BY amount DESC", + "columns": [ + "region", + "amount", + "order_cnt" + ], + "rows": [ + [ + "华东", + 18230.5, + 402 + ], + [ + "华南", + 12004.0, + 311 + ], + [ + "华北", + 9876.0, + 298 + ], + [ + "西南", + 5568.0, + 223 + ] + ], + "row_offset": 0, + "row_index": [ + 0, + 1, + 2, + 3 + ], + "total_rows": 4, + "truncated": false, + "highlight": { + "rows": [ + 1 + ], + "cols": [ + "region" + ], + "cells": [ + [ + 1, + "region" + ] + ] + }, + "masked": [], + "hash_ok": true, + "sealed": true + } + ], + "note": "数字由系统从证据里取出、按口径卡的格式渲染,没有经过模型转写", + "violations": [], + "seal": { + "sealed": true, + "ok": true, + "covered": true + }, + "redacted": { + "columns": [] + } + }, + "s52": { + "report": { + "node_id": "write", + "doc_artifact": "18e1dbb10ace41fa26d4b70ee72e8da461cf3b2e4b181672dda59f65fd924ddb" + }, + "segment": { + "id": "s52", + "text": "12,004", + "kind": "number", + "state": "deterministic", + "span": [ + 249, + 255 + ], + "unit": "u13", + "ref": "v:Q2.r1.amount", + "cite": { + "ref": "Q2.r1.amount", + "alias": "Q2", + "locator": { + "row": 1, + "column": "amount" + }, + "eid": "ev:cell:85b22e06175c4c74", + "kind": "cell", + "role": "value", + "status": "resolved", + "value": 12004.0, + "rendered": "12,004" + } + }, + "unit": { + "id": "u13", + "kind": "claim", + "text": "12,004", + "span": [ + 249, + 255 + ], + "cites": [ + "Q2" + ] + }, + "block": { + "id": "b6", + "type": "table" + }, + "chain": [ + { + "step": "query", + "alias": "Q2", + "artifact": "4f25cb3d0ef3155f54370d8f4e2f9c3673bc28a779db0b37602bd694c6726b32", + "node_id": "fetch", + "tool": "db_query__shop", + "source": "shop", + "sql": "SELECT region, SUM(amount) AS amount, COUNT(*) AS order_cnt\nFROM orders\nWHERE week = '2026-W37'\nGROUP BY region\nORDER BY amount DESC", + "columns": [ + "region", + "amount", + "order_cnt" + ], + "rows": [ + [ + "华东", + 18230.5, + 402 + ], + [ + "华南", + 12004.0, + 311 + ], + [ + "华北", + 9876.0, + 298 + ], + [ + "西南", + 5568.0, + 223 + ] + ], + "row_offset": 0, + "row_index": [ + 0, + 1, + 2, + 3 + ], + "total_rows": 4, + "truncated": false, + "highlight": { + "rows": [ + 1 + ], + "cols": [ + "amount" + ], + "cells": [ + [ + 1, + "amount" + ] + ] + }, + "masked": [], + "hash_ok": true, + "sealed": true + } + ], + "note": "数字由系统从证据里取出、按口径卡的格式渲染,没有经过模型转写", + "violations": [], + "seal": { + "sealed": true, + "ok": true, + "covered": true + }, + "redacted": { + "columns": [] + } + }, + "s54": { + "report": { + "node_id": "write", + "doc_artifact": "18e1dbb10ace41fa26d4b70ee72e8da461cf3b2e4b181672dda59f65fd924ddb" + }, + "segment": { + "id": "s54", + "text": "311", + "kind": "number", + "state": "deterministic", + "span": [ + 258, + 261 + ], + "unit": "u14", + "ref": "v:Q2.r1.order_cnt", + "cite": { + "ref": "Q2.r1.order_cnt", + "alias": "Q2", + "locator": { + "row": 1, + "column": "order_cnt" + }, + "eid": "ev:cell:601c63ce11c38099", + "kind": "cell", + "role": "value", + "status": "resolved", + "value": 311, + "rendered": "311" + } + }, + "unit": { + "id": "u14", + "kind": "claim", + "text": "311", + "span": [ + 258, + 261 + ], + "cites": [ + "Q2" + ] + }, + "block": { + "id": "b6", + "type": "table" + }, + "chain": [ + { + "step": "query", + "alias": "Q2", + "artifact": "4f25cb3d0ef3155f54370d8f4e2f9c3673bc28a779db0b37602bd694c6726b32", + "node_id": "fetch", + "tool": "db_query__shop", + "source": "shop", + "sql": "SELECT region, SUM(amount) AS amount, COUNT(*) AS order_cnt\nFROM orders\nWHERE week = '2026-W37'\nGROUP BY region\nORDER BY amount DESC", + "columns": [ + "region", + "amount", + "order_cnt" + ], + "rows": [ + [ + "华东", + 18230.5, + 402 + ], + [ + "华南", + 12004.0, + 311 + ], + [ + "华北", + 9876.0, + 298 + ], + [ + "西南", + 5568.0, + 223 + ] + ], + "row_offset": 0, + "row_index": [ + 0, + 1, + 2, + 3 + ], + "total_rows": 4, + "truncated": false, + "highlight": { + "rows": [ + 1 + ], + "cols": [ + "order_cnt" + ], + "cells": [ + [ + 1, + "order_cnt" + ] + ] + }, + "masked": [], + "hash_ok": true, + "sealed": true + } + ], + "note": "数字由系统从证据里取出、按口径卡的格式渲染,没有经过模型转写", + "violations": [], + "seal": { + "sealed": true, + "ok": true, + "covered": true + }, + "redacted": { + "columns": [] + } + }, + "s56": { + "report": { + "node_id": "write", + "doc_artifact": "18e1dbb10ace41fa26d4b70ee72e8da461cf3b2e4b181672dda59f65fd924ddb" + }, + "segment": { + "id": "s56", + "text": "华北", + "kind": "value", + "state": "deterministic", + "span": [ + 266, + 268 + ], + "unit": "u15", + "ref": "v:Q2.r2.region", + "cite": { + "ref": "Q2.r2.region", + "alias": "Q2", + "locator": { + "row": 2, + "column": "region" + }, + "eid": "ev:cell:f18c3397a51b4e54", + "kind": "cell", + "role": "value", + "status": "resolved", + "value": "华北", + "rendered": "华北" + } + }, + "unit": { + "id": "u15", + "kind": "claim", + "text": "华北", + "span": [ + 266, + 268 + ], + "cites": [ + "Q2" + ] + }, + "block": { + "id": "b6", + "type": "table" + }, + "chain": [ + { + "step": "query", + "alias": "Q2", + "artifact": "4f25cb3d0ef3155f54370d8f4e2f9c3673bc28a779db0b37602bd694c6726b32", + "node_id": "fetch", + "tool": "db_query__shop", + "source": "shop", + "sql": "SELECT region, SUM(amount) AS amount, COUNT(*) AS order_cnt\nFROM orders\nWHERE week = '2026-W37'\nGROUP BY region\nORDER BY amount DESC", + "columns": [ + "region", + "amount", + "order_cnt" + ], + "rows": [ + [ + "华东", + 18230.5, + 402 + ], + [ + "华南", + 12004.0, + 311 + ], + [ + "华北", + 9876.0, + 298 + ], + [ + "西南", + 5568.0, + 223 + ] + ], + "row_offset": 0, + "row_index": [ + 0, + 1, + 2, + 3 + ], + "total_rows": 4, + "truncated": false, + "highlight": { + "rows": [ + 2 + ], + "cols": [ + "region" + ], + "cells": [ + [ + 2, + "region" + ] + ] + }, + "masked": [], + "hash_ok": true, + "sealed": true + } + ], + "note": "数字由系统从证据里取出、按口径卡的格式渲染,没有经过模型转写", + "violations": [], + "seal": { + "sealed": true, + "ok": true, + "covered": true + }, + "redacted": { + "columns": [] + } + }, + "s58": { + "report": { + "node_id": "write", + "doc_artifact": "18e1dbb10ace41fa26d4b70ee72e8da461cf3b2e4b181672dda59f65fd924ddb" + }, + "segment": { + "id": "s58", + "text": "9,876", + "kind": "number", + "state": "deterministic", + "span": [ + 271, + 276 + ], + "unit": "u16", + "ref": "v:Q2.r2.amount", + "cite": { + "ref": "Q2.r2.amount", + "alias": "Q2", + "locator": { + "row": 2, + "column": "amount" + }, + "eid": "ev:cell:715a614c0b0befe6", + "kind": "cell", + "role": "value", + "status": "resolved", + "value": 9876.0, + "rendered": "9,876" + } + }, + "unit": { + "id": "u16", + "kind": "claim", + "text": "9,876", + "span": [ + 271, + 276 + ], + "cites": [ + "Q2" + ] + }, + "block": { + "id": "b6", + "type": "table" + }, + "chain": [ + { + "step": "query", + "alias": "Q2", + "artifact": "4f25cb3d0ef3155f54370d8f4e2f9c3673bc28a779db0b37602bd694c6726b32", + "node_id": "fetch", + "tool": "db_query__shop", + "source": "shop", + "sql": "SELECT region, SUM(amount) AS amount, COUNT(*) AS order_cnt\nFROM orders\nWHERE week = '2026-W37'\nGROUP BY region\nORDER BY amount DESC", + "columns": [ + "region", + "amount", + "order_cnt" + ], + "rows": [ + [ + "华东", + 18230.5, + 402 + ], + [ + "华南", + 12004.0, + 311 + ], + [ + "华北", + 9876.0, + 298 + ], + [ + "西南", + 5568.0, + 223 + ] + ], + "row_offset": 0, + "row_index": [ + 0, + 1, + 2, + 3 + ], + "total_rows": 4, + "truncated": false, + "highlight": { + "rows": [ + 2 + ], + "cols": [ + "amount" + ], + "cells": [ + [ + 2, + "amount" + ] + ] + }, + "masked": [], + "hash_ok": true, + "sealed": true + } + ], + "note": "数字由系统从证据里取出、按口径卡的格式渲染,没有经过模型转写", + "violations": [], + "seal": { + "sealed": true, + "ok": true, + "covered": true + }, + "redacted": { + "columns": [] + } + }, + "s60": { + "report": { + "node_id": "write", + "doc_artifact": "18e1dbb10ace41fa26d4b70ee72e8da461cf3b2e4b181672dda59f65fd924ddb" + }, + "segment": { + "id": "s60", + "text": "298", + "kind": "number", + "state": "deterministic", + "span": [ + 279, + 282 + ], + "unit": "u17", + "ref": "v:Q2.r2.order_cnt", + "cite": { + "ref": "Q2.r2.order_cnt", + "alias": "Q2", + "locator": { + "row": 2, + "column": "order_cnt" + }, + "eid": "ev:cell:f6f4e1d0de2b884d", + "kind": "cell", + "role": "value", + "status": "resolved", + "value": 298, + "rendered": "298" + } + }, + "unit": { + "id": "u17", + "kind": "claim", + "text": "298", + "span": [ + 279, + 282 + ], + "cites": [ + "Q2" + ] + }, + "block": { + "id": "b6", + "type": "table" + }, + "chain": [ + { + "step": "query", + "alias": "Q2", + "artifact": "4f25cb3d0ef3155f54370d8f4e2f9c3673bc28a779db0b37602bd694c6726b32", + "node_id": "fetch", + "tool": "db_query__shop", + "source": "shop", + "sql": "SELECT region, SUM(amount) AS amount, COUNT(*) AS order_cnt\nFROM orders\nWHERE week = '2026-W37'\nGROUP BY region\nORDER BY amount DESC", + "columns": [ + "region", + "amount", + "order_cnt" + ], + "rows": [ + [ + "华东", + 18230.5, + 402 + ], + [ + "华南", + 12004.0, + 311 + ], + [ + "华北", + 9876.0, + 298 + ], + [ + "西南", + 5568.0, + 223 + ] + ], + "row_offset": 0, + "row_index": [ + 0, + 1, + 2, + 3 + ], + "total_rows": 4, + "truncated": false, + "highlight": { + "rows": [ + 2 + ], + "cols": [ + "order_cnt" + ], + "cells": [ + [ + 2, + "order_cnt" + ] + ] + }, + "masked": [], + "hash_ok": true, + "sealed": true + } + ], + "note": "数字由系统从证据里取出、按口径卡的格式渲染,没有经过模型转写", + "violations": [], + "seal": { + "sealed": true, + "ok": true, + "covered": true + }, + "redacted": { + "columns": [] + } + }, + "s65": { + "report": { + "node_id": "write", + "doc_artifact": "18e1dbb10ace41fa26d4b70ee72e8da461cf3b2e4b181672dda59f65fd924ddb" + }, + "segment": { + "id": "s65", + "text": "1,288", + "kind": "number", + "state": "deterministic", + "span": [ + 304, + 309 + ], + "unit": "u19", + "ref": "v:Q3.r5.amount", + "cite": { + "ref": "Q3.r5.amount", + "alias": "Q3", + "locator": { + "row": 5, + "column": "amount" + }, + "eid": "ev:cell:751535b8b3533b12", + "kind": "cell", + "role": "value", + "status": "resolved", + "value": 1288.0, + "rendered": "1,288" + } + }, + "unit": { + "id": "u19", + "kind": "claim", + "text": "单笔最大的一单金额是 1,288,来自华南。", + "span": [ + 293, + 315 + ], + "cites": [ + "Q3" + ] + }, + "block": { + "id": "b8", + "type": "paragraph" + }, + "chain": [ + { + "step": "query", + "alias": "Q3", + "artifact": "5e63abde1c4e435eb447fd55b50f10cf82d0457838876fd25178f28bd5fcdef7", + "node_id": "fetch", + "tool": "db_query__shop", + "source": "shop", + "sql": "SELECT order_id, region, amount, phone\nFROM orders\nWHERE week = '2026-W37'\nORDER BY order_id", + "columns": [ + "order_id", + "region", + "amount", + "phone" + ], + "rows": [ + [ + 10004, + "西南", + 129.9, + "已遮罩" + ], + [ + 10005, + "华东", + 705.2, + "已遮罩" + ], + [ + 10006, + "华南", + 1288.0, + "已遮罩" + ], + [ + 10007, + "华北", + 56.0, + "已遮罩" + ], + [ + 10008, + "西南", + 942.3, + "已遮罩" + ] + ], + "row_offset": 3, + "row_index": [ + 3, + 4, + 5, + 6, + 7 + ], + "total_rows": 12, + "truncated": false, + "highlight": { + "rows": [ + 5 + ], + "cols": [ + "amount" + ], + "cells": [ + [ + 5, + "amount" + ] + ] + }, + "masked": [ + "phone" + ], + "hash_ok": true, + "sealed": true + } + ], + "note": "数字由系统从证据里取出、按口径卡的格式渲染,没有经过模型转写", + "violations": [], + "seal": { + "sealed": true, + "ok": true, + "covered": true + }, + "redacted": { + "columns": [ + "phone" + ], + "note": "这些列在数据源里设了遮罩,面板上不显示原值。在有身份体系之前,遮罩只减少暴露,不是安全边界" + } + }, + "s67": { + "report": { + "node_id": "write", + "doc_artifact": "18e1dbb10ace41fa26d4b70ee72e8da461cf3b2e4b181672dda59f65fd924ddb" + }, + "segment": { + "id": "s67", + "text": "华南", + "kind": "value", + "state": "deterministic", + "span": [ + 312, + 314 + ], + "unit": "u19", + "ref": "v:Q3.r5.region", + "cite": { + "ref": "Q3.r5.region", + "alias": "Q3", + "locator": { + "row": 5, + "column": "region" + }, + "eid": "ev:cell:a38e95fc49a1db05", + "kind": "cell", + "role": "value", + "status": "resolved", + "value": "华南", + "rendered": "华南" + } + }, + "unit": { + "id": "u19", + "kind": "claim", + "text": "单笔最大的一单金额是 1,288,来自华南。", + "span": [ + 293, + 315 + ], + "cites": [ + "Q3" + ] + }, + "block": { + "id": "b8", + "type": "paragraph" + }, + "chain": [ + { + "step": "query", + "alias": "Q3", + "artifact": "5e63abde1c4e435eb447fd55b50f10cf82d0457838876fd25178f28bd5fcdef7", + "node_id": "fetch", + "tool": "db_query__shop", + "source": "shop", + "sql": "SELECT order_id, region, amount, phone\nFROM orders\nWHERE week = '2026-W37'\nORDER BY order_id", + "columns": [ + "order_id", + "region", + "amount", + "phone" + ], + "rows": [ + [ + 10004, + "西南", + 129.9, + "已遮罩" + ], + [ + 10005, + "华东", + 705.2, + "已遮罩" + ], + [ + 10006, + "华南", + 1288.0, + "已遮罩" + ], + [ + 10007, + "华北", + 56.0, + "已遮罩" + ], + [ + 10008, + "西南", + 942.3, + "已遮罩" + ] + ], + "row_offset": 3, + "row_index": [ + 3, + 4, + 5, + 6, + 7 + ], + "total_rows": 12, + "truncated": false, + "highlight": { + "rows": [ + 5 + ], + "cols": [ + "region" + ], + "cells": [ + [ + 5, + "region" + ] + ] + }, + "masked": [ + "phone" + ], + "hash_ok": true, + "sealed": true + } + ], + "note": "数字由系统从证据里取出、按口径卡的格式渲染,没有经过模型转写", + "violations": [], + "seal": { + "sealed": true, + "ok": true, + "covered": true + }, + "redacted": { + "columns": [ + "phone" + ], + "note": "这些列在数据源里设了遮罩,面板上不显示原值。在有身份体系之前,遮罩只减少暴露,不是安全边界" + } + }, + "s70": { + "report": { + "node_id": "write", + "doc_artifact": "18e1dbb10ace41fa26d4b70ee72e8da461cf3b2e4b181672dda59f65fd924ddb" + }, + "segment": { + "id": "s70", + "text": "672.5元", + "kind": "number", + "state": "deterministic", + "span": [ + 327, + 333 + ], + "unit": "u20", + "ref": "m:spread", + "cite": { + "ref": "spread", + "alias": "m:spread", + "locator": { + "metric": "spread" + }, + "eid": "ev:metric:dea437737bb0dfa8", + "kind": "metric", + "role": "value", + "status": "resolved", + "value": 672.5, + "rendered": "672.5元" + } + }, + "unit": { + "id": "u20", + "kind": "claim", + "text": "最大的一单比最后一单多 672.5元。", + "span": [ + 315, + 334 + ], + "cites": [ + "m:spread" + ] + }, + "block": { + "id": "b8", + "type": "paragraph" + }, + "chain": [ + { + "step": "metric", + "alias": "m:spread", + "metric": "spread", + "name": "最大一单比最后一单多", + "value": 672.5, + "unit": "元", + "decimals": 1, + "format": "thousands", + "conv": null, + "rendered": "672.5元", + "caliber": "周报口径", + "version": "v3", + "expression": "round(vars.kpi.top - vars.kpi.last, 1)", + "substituted": "round(1288.0 - 615.5, 1)", + "recompute_ok": true, + "status": "ok", + "node_id": "caliber", + "artifact": "85c4b5948c7e4cfab4472c5f250183e44a64d9139e342bbcdf52f14881b03d55", + "eid": "ev:metric:dea437737bb0dfa8", + "eid_ok": true, + "hash_ok": true, + "render_ok": true, + "sealed": true, + "source": { + "workflow_id": "wf-demo-weekly", + "workflow_version": 5, + "node_id": "caliber", + "workflow_name": "销售周报" + }, + "caliber_upgrade": { + "workflow_id": "wf-demo-weekly", + "caliber_node": "caliber", + "pinned": 5, + "latest": 6, + "policy": "dual", + "policy_label": "并排双印新旧口径" + } + }, + { + "step": "input", + "path": "vars.kpi.top", + "value": 1288.0, + "node_id": "fetch", + "via": "agent_field", + "field": "top", + "status": "verified", + "ref": "Q3.r5.amount", + "artifact": "5e63abde1c4e435eb447fd55b50f10cf82d0457838876fd25178f28bd5fcdef7", + "locator": { + "row": 5, + "column": "amount" + }, + "eid": "ev:cell:751535b8b3533b12", + "query": "Q3", + "cell": "Q3.r5.amount" + }, + { + "step": "input", + "path": "vars.kpi.last", + "value": 615.5, + "node_id": "fetch", + "via": "agent_field", + "field": "last", + "status": "verified", + "ref": "Q3.r11.amount", + "artifact": "5e63abde1c4e435eb447fd55b50f10cf82d0457838876fd25178f28bd5fcdef7", + "locator": { + "row": 11, + "column": "amount" + }, + "eid": "ev:cell:f3f82a4172e8b244", + "query": "Q3", + "cell": "Q3.r11.amount" + }, + { + "step": "query", + "alias": "Q3", + "artifact": "5e63abde1c4e435eb447fd55b50f10cf82d0457838876fd25178f28bd5fcdef7", + "node_id": "fetch", + "tool": "db_query__shop", + "source": "shop", + "sql": "SELECT order_id, region, amount, phone\nFROM orders\nWHERE week = '2026-W37'\nORDER BY order_id", + "columns": [ + "order_id", + "region", + "amount", + "phone" + ], + "rows": [ + [ + 10004, + "西南", + 129.9, + "已遮罩" + ], + [ + 10005, + "华东", + 705.2, + "已遮罩" + ], + [ + 10006, + "华南", + 1288.0, + "已遮罩" + ], + [ + 10007, + "华北", + 56.0, + "已遮罩" + ], + [ + 10008, + "西南", + 942.3, + "已遮罩" + ], + [ + 10010, + "华南", + 401.0, + "已遮罩" + ], + [ + 10011, + "华北", + 230.0, + "已遮罩" + ], + [ + 10012, + "西南", + 615.5, + "已遮罩" + ] + ], + "row_offset": 3, + "row_index": [ + 3, + 4, + 5, + 6, + 7, + 9, + 10, + 11 + ], + "total_rows": 12, + "truncated": false, + "highlight": { + "rows": [ + 5, + 11 + ], + "cols": [ + "amount" + ], + "cells": [ + [ + 5, + "amount" + ], + [ + 11, + "amount" + ] + ] + }, + "masked": [ + "phone" + ], + "hash_ok": true, + "sealed": true + } + ], + "note": "数字由系统从证据里取出、按口径卡的格式渲染,没有经过模型转写", + "violations": [], + "seal": { + "sealed": true, + "ok": true, + "covered": true + }, + "redacted": { + "columns": [ + "phone" + ], + "note": "这些列在数据源里设了遮罩,面板上不显示原值。在有身份体系之前,遮罩只减少暴露,不是安全边界" + } + } + }, + "output": { + "answer": "## 本周概览\n\n本周(2026-W37)销售额 45,678.5元,订单 1,234单,客单价 37.0元,环比 8.7%。\n\n退款率 —,新客 —:这次都没查到。\n\n扣掉促销后的调整后销售额是 44,000.0元,按汇率 7.12 折算。\n\n## 分区域\n\n华东销售额最高,为 18,230.5,其次是华南(12,004)。\n\n| region | amount | order_cnt |\n| --- | --- | --- |\n| 华东 | 18,230.5 | 402 |\n| 华南 | 12,004 | 311 |\n| 华北 | 9,876 | 298 |\n\n## 明细\n\n单笔最大的一单金额是 1,288,来自华南。最大的一单比最后一单多 672.5元。", + "_evidence": { + "report_node": "write", + "doc_artifact": "18e1dbb10ace41fa26d4b70ee72e8da461cf3b2e4b181672dda59f65fd924ddb", + "fields": [ + "answer" + ] + } + }, + "report_checked": { + "doc_artifact": "18e1dbb10ace41fa26d4b70ee72e8da461cf3b2e4b181672dda59f65fd924ddb", + "ok": false, + "stats": { + "units": 21, + "segments": 72, + "claims": 15, + "connective": 3, + "headings": 3, + "numbers": 18, + "numbers_cited": 16, + "values": 7, + "uncited_numbers": 0, + "unresolved": 2, + "see": 5, + "uncited_claims": 1, + "violations": 2 + }, + "violations": [ + { + "code": "unresolved_ref", + "message": "引用 [[m:refund_rate]] 解析不了:指标「退款率」这次没有值(缺输入),不能写进报告", + "ref": "m:refund_rate", + "span": [ + 69, + 70 + ], + "text": "—", + "segment": "s16", + "unit": "u2" + }, + { + "code": "unresolved_ref", + "message": "引用 [[m:new_users]] 解析不了:指标「新客数」这次没有值(缺输入),不能写进报告", + "ref": "m:new_users", + "span": [ + 74, + 75 + ], + "text": "—", + "segment": "s18", + "unit": "u2" + } + ], + "repairs": 0 + }, + "snapshots": { + "Q3": { + "artifact": "5e63abde1c4e435eb447fd55b50f10cf82d0457838876fd25178f28bd5fcdef7", + "content": { + "columns": [ + "order_id", + "region", + "amount", + "phone" + ], + "rows": [ + [ + 10001, + "华东", + 312.0, + "已遮罩" + ], + [ + 10002, + "华南", + 88.5, + "已遮罩" + ], + [ + 10003, + "华北", + 460.0, + "已遮罩" + ], + [ + 10004, + "西南", + 129.9, + "已遮罩" + ], + [ + 10005, + "华东", + 705.2, + "已遮罩" + ], + [ + 10006, + "华南", + 1288.0, + "已遮罩" + ], + [ + 10007, + "华北", + 56.0, + "已遮罩" + ], + [ + 10008, + "西南", + 942.3, + "已遮罩" + ], + [ + 10009, + "华东", + 77.7, + "已遮罩" + ], + [ + 10010, + "华南", + 401.0, + "已遮罩" + ], + [ + 10011, + "华北", + 230.0, + "已遮罩" + ], + [ + 10012, + "西南", + 615.5, + "已遮罩" + ] + ], + "row_count": 12, + "truncated": false, + "elapsed_ms": 12, + "sql": "SELECT order_id, region, amount, phone\nFROM orders\nWHERE week = '2026-W37'\nORDER BY order_id", + "source": "shop" + } + } + } +} diff --git a/frontend/src/run/decode.ts b/frontend/src/run/decode.ts index f0ae1700..703b72c8 100644 --- a/frontend/src/run/decode.ts +++ b/frontend/src/run/decode.ts @@ -644,6 +644,9 @@ function queryTitle(sql: string, source: string): string { return clip(`查询 ${what}${how ? ` · ${how}` : ''}`) } +/** agent 开了 cite_fields 时循环结束后那次结构化抽取 */ +const EXTRACT_TITLE = '按出处抽取字段' + /** 审批行末尾:批的时候点了「始终允许」,这个工具之后不再问人 */ const ALWAYS_NOTE = ',并设为「始终允许 · 门控把关」' @@ -700,6 +703,34 @@ function explainLog(code: string | undefined, message: string): Pick。N 个字段都记为空值(没有兜底成 0)」,原因是「结构化抽取没跑成:<调用的错>」 + // 或者「抽取结果不是一个对象」(llm.py 的 failed) + const failed = message.match(/^(?:结构化抽取没跑成[::])?([\s\S]+?)。\s*(\d+)\s*个字段都记为空值/) + if (failed) { + return { + title: `按出处抽取字段没跑成,${failed[2]} 个字段记为空值(没有兜底成 0)`, + sub: failed[1].trim() || undefined, + next: '这些字段交给下游的是空值;口径卡按缺输入处理。原因解决后重跑', + } + } + const n = message.match(/有\s*(\d+)\s*个字段/)?.[1] + return { + title: `有${n ? ` ${n} 个` : ''}字段核对不了出处,记为空值(没有兜底成 0)`, + sub: message.match(/没有兜底成 0)[::]\s*(.+)$/)?.[1]?.trim() || undefined, + next: '模型说的出处在查询结果里找不到。让它先查到这些字段再交,查不到的就是空', + } + } case 'repair_invented': { // 「第 1 次修复作废:修复时出现了原文没有的值:total_count=0」 const values = message.match(/原文没有的值[::]\s*(.+)$/)?.[1]?.trim() @@ -1356,9 +1387,12 @@ export function decodeRun(events: RunEvent[], final?: RunFinal, opts?: DecodeOpt // 一出现就成行:模型想的那几十秒里得有一行在走,而不是等 end 才冒出来。 // 团队成员和调度者的调用没有 start(它们的 end 只用来记账),不会走到这 closeLlm(nodeId, 'done', at) + // agent 开了 cite_fields:循环结束后多一次结构化抽取,按出处把字段交出来。它不是在「作答」 + const extract = d.purpose === 'cite_fields' const step: Step = { - id: `llm-${seq}`, seq, kind: 'llm', title: '思考并作答', + id: `llm-${seq}`, seq, kind: 'llm', title: extract ? EXTRACT_TITLE : '思考并作答', status: 'running', nodeId, startedAt: at, + ...(extract ? { code: 'cite_fields' } : {}), ...(d.model ? { detail: `模型:${d.model}` } : {}), } pendingLlm.set(nodeId ?? '_', step) @@ -1388,6 +1422,13 @@ export function decodeRun(events: RunEvent[], final?: RunFinal, opts?: DecodeOpt step.ms = num(d.duration_ms) step.meta = dur(step.ms) if (d.model && !step.detail) step.detail = `模型:${d.model}` + if (d.purpose === 'cite_fields' && d.error) { + // 抽取没跑成:agent 的结论还在,字段记空值、接着发警告——节点不失败,这一行是提醒。 + // 状态记成做完(琥珀色)而不是 failed:failed 会把这一行和「执行」那一栏的标头都画成红的 + step.level = 'warn' + step.title = `${EXTRACT_TITLE}没跑成` + step.detail = [String(d.error), step.detail].filter(Boolean).join('\n') + } pendingLlm.delete(nodeId ?? '_') break } diff --git a/frontend/src/store/chat.ts b/frontend/src/store/chat.ts index 971f11c6..01da34ea 100644 --- a/frontend/src/store/chat.ts +++ b/frontend/src/store/chat.ts @@ -4,7 +4,7 @@ import type { CopilotOp, FixKind, RunFinal } from '../run/decode' import { decodeRun } from '../run/decode' import { errorMessage, humanizeError, isNetworkError } from '../lib/errors' import { guardReview, hasEvidence } from '../lib/evidence' -import { explainRunError } from '../lib/explain' +import { explainRunError, explainStartError } from '../lib/explain' import { parseServerTime } from '../lib/format' import { useCatalog } from './catalog' import { useConversations } from './conversations' @@ -484,6 +484,27 @@ function runFailure(error: string | null | undefined, detail?: string | null): F const lineOf = (f: Failure) => (f.reason ? `${f.title}:${f.reason}` : f.title) +/** + * 发起运行那一下被拒(POST /runs 的 ApiError)的说明。绑定的工具在本机不存在(422 run_tool_missing) + * 按 lib/explain 讲,带上直达入口(数据源工具去数据页,自定义 / MCP 工具去工具页),和画布上 + * 发起被拒(run/RunControl)同一套;这时还没有运行 id,流里的报错块不会再拿原话认一遍, + * 所以入口得在这里就记进 failure。别的照旧按一句报错拆 + */ +function startFailure(e: unknown): Failure { + const x = explainStartError(e) + if (!x) return failureOf(e) + return { + title: x.title, + reason: x.reason, + hint: x.action, + detail: x.raw && x.raw !== x.title ? x.raw : undefined, + continuable: false, + ...(x.fix ? { fix: x.fix } : {}), + ...(x.fixTo ? { fixTo: x.fixTo } : {}), + ...(e instanceof ApiError ? { source: e.message } : {}), + } +} + /** 运行记录被删了:没有断点可续,也补不回完整答案 */ const GONE: Failure = { title: '上次没有跑完', @@ -1437,7 +1458,7 @@ async function launch( // 在这里不成立——这一页没有谁会替它重发 const failure: Failure = isNetworkError(e) ? { ...failureOf(e, { hint: '后端连上之后点「重跑这一轮」,搭好的工作流不用重来。' }), rerun: true } - : failureOf(e) + : startFailure(e) patch(() => ({ phase: 'error', status: PHASE_TEXT.error, error: lineOf(failure), failure, cancel: null, endedAt: Date.now(), diff --git a/frontend/src/store/studio.ts b/frontend/src/store/studio.ts index 83f4f731..cf62dce9 100644 --- a/frontend/src/store/studio.ts +++ b/frontend/src/store/studio.ts @@ -57,8 +57,23 @@ export function toFlow(graph: GraphSpec): { nodes: FlowNode[]; edges: Edge[] } { } } +/** + * 图上节点和连线以外的部分(全图默认 defaults、viewport)。画布只管节点和连线,以前 toGraph + * 只拼这两样,保存时 PATCH 整张图,defaults 被整个抹掉——其中审批默认 always 会悄悄放宽成 + * 全局设置。打开工作流时记下来,toGraph 原样带回去;和 savedSig 一样只属于当前这张图 + */ +let graphExtras: Pick = {} + +const extrasOf = (graph: GraphSpec | null | undefined): Pick => { + const out: Pick = {} + if (graph?.defaults && Object.keys(graph.defaults).length) out.defaults = structuredClone(graph.defaults) + if (graph?.viewport && Object.keys(graph.viewport).length) out.viewport = structuredClone(graph.viewport) + return out +} + export function toGraph(nodes: FlowNode[], edges: Edge[]): GraphSpec { return { + ...structuredClone(graphExtras), nodes: nodes.map((n) => ({ id: n.id, type: n.data.nodeType, @@ -560,6 +575,11 @@ interface StudioState { load: (workflow: Workflow | null) => void /** 整张换掉(自动排版)。画布锁着时不换、返回 false:调用方别再报「已换好」 */ setGraph: (graph: GraphSpec) => boolean + /** + * 发布前修复的预览落到画布上:记一步撤销(label),节点的位置和选中照旧、不重新取景—— + * 改的只是几个配置项,镜头一跳人就找不到刚才在看的节点了。锁着时不落、返回 false + */ + applyFixes: (graph: GraphSpec, label: string) => boolean onNodesChange: (changes: NodeChange[]) => void onEdgesChange: (changes: EdgeChange[]) => void onConnect: (conn: Connection) => void @@ -1087,6 +1107,7 @@ export const useStudio = create((set, get) => ({ get().cancelCopilot?.() dragOrigin = null glideSeq++ + graphExtras = extrasOf(workflow?.graph) savedSig = graphSig(nodes, edges) lastVarSignature = '' set({ @@ -1120,6 +1141,8 @@ export const useStudio = create((set, get) => ({ setGraph: (graph) => { if (locked(get)) return false const { nodes, edges } = toFlow(graph) + // 整图替换(Copilot、导入)带了 defaults 才换;没带的不能当成「清空」 + if (graph.defaults !== undefined) graphExtras = { ...graphExtras, ...extrasOf({ ...graph, viewport: undefined }) } glideSeq++ commit(set, get, '替换整个画布') set({ nodes, edges, selectedId: null, dirty: true, fitRequest: get().fitRequest + 1 }) @@ -1127,6 +1150,33 @@ export const useStudio = create((set, get) => ({ return true }, + applyFixes: (graph, label) => { + if (locked(get)) return false + const next = toFlow(graph) + // 发布前修复可能改的是全图默认(比如把 defaults.approval 从 never 改成 dangerous)。 + // 后端回来的整张图总带着 defaults,照它换;viewport 留着画布自己的 + if (graph.defaults !== undefined) { + const { viewport } = graphExtras + graphExtras = { ...extrasOf({ ...graph, viewport: undefined }), ...(viewport ? { viewport } : {}) } + } + const { nodes: cur, selectedId } = get() + const byId = new Map(cur.map((n) => [n.id, n])) + // 已有的节点只换名字和配置:位置、量好的尺寸、选中都留着,React Flow 不用重新量一遍 + const nodes = next.nodes.map((n) => { + const old = byId.get(n.id) + return old ? { ...old, data: { ...old.data, label: n.data.label, config: n.data.config } } : n + }) + glideSeq++ + commit(set, get, label) + set({ + nodes, edges: next.edges, + selectedId: selectedId && nodes.some((n) => n.id === selectedId) ? selectedId : null, + dirty: graphSig(nodes, next.edges) !== savedSig, + }) + void get().validate() + return true + }, + onNodesChange: (changes) => { if (locked(get)) { // 锁定期间只放行量尺寸和选中:前者是 React Flow 自己的,后者不改图 diff --git a/frontend/src/types.ts b/frontend/src/types.ts index 7b0c22bc..68f3575f 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -304,6 +304,64 @@ export interface ValidationIssue { message: string /** 出问题的是节点配置里的哪一项:'prompt'、'tools'、'cases[1].condition'。老后端不给 */ field?: string | null + /** 机读编号(governed.agent_approval_never 这类),发布前检查按它认修复。老后端不给 */ + code?: string | null + /** 这条问题对应的修复 id:publish-check 的 fixes 里同 id 的那一项。没有修复时为空 */ + fix?: string | null +} + +/** 发布等级:发布弹窗、问题面板的发布前检查共用 */ +export type PublishLevel = 'published' | 'governed' + +/** + * 发布前检查给的一项修复(POST /workflows/{id}/publish-check 的 fixes)。 + * auto:答案唯一、不降低要求,预览后一键修;choice:要人拿主意,options 里挑(multiple 可多选); + * assist:结构性的,交给 Copilot 试着改,产出同样只是预览 + */ +export interface PublishFix { + id: string + code: string + kind: 'auto' | 'choice' | 'assist' + node_id?: string | null + label: string + preview?: { field?: string | null; before?: unknown; after?: unknown } | null + options?: { value: unknown; label: string; hint?: string | null }[] + multiple?: boolean + /** 建议值:界面上标「建议」,仍然要人选 */ + default?: unknown +} + +export interface PublishCheck { + level: PublishLevel + ok: boolean + issues: ValidationIssue[] + fixes: PublishFix[] +} + +/** 自动修复预览里给人看的一项改动:「节点 · 字段:原值 → 新值」 */ +export interface AutofixChange { + fix_id: string + node_id?: string | null + node_title?: string | null + field?: string | null + before?: unknown + after?: unknown + label?: string | null +} + +/** POST /workflows/{id}/autofix:只给预览,不改库、不发布 */ +export interface AutofixResult { + /** 修复后的整张图;人确认后前端走保存接口存成草稿 */ + graph: GraphSpec | null + changes: AutofixChange[] + applied: string[] + rejected: { fix_id: string; reason: string }[] + /** 修复后仍然存在的问题(带 code 和 fix) */ + remaining: ValidationIssue[] + /** 交给 Copilot 的那一段:它认为要人拿主意的放进 questions,不硬改 */ + assist: { ok: boolean; summary?: string; questions?: string[] } | null + /** 修复后是否已经没有 error */ + ok: boolean } /** 单个节点在一次运行中的实时状态,驱动画布上的高亮。 */ @@ -675,23 +733,79 @@ export interface EvidenceGraph { edges?: { from: string; to: string; rel: string }[] } +/** + * 查询快照里的位置:单元格 {row, column};agent 的数组字段按行映射时是 {rows: [首, 尾], column | columns} + * (columns 是「字段 → 列」)。行号从 0 数 + */ +export interface EvidenceLocator { + row?: number + column?: string + rows?: [number, number] | number[] + columns?: Record +} + /** 指标的一个输入:值从哪来 */ export interface EvidenceInput { path?: string value?: unknown node_id?: string + /** + * 一期:input / transform / agent / code / tool / llm …(产出节点的类型); + * 二期能追到查询快照那一格的两种:agent_field(agent 开了 cite_fields 的字段)、tool_cell(cell() 取数) + */ via?: string field?: string + /** 只有 code 节点有:source 取数 / compute 计算(缺省) */ role?: string - /** ok / missing */ + /** + * 一期 ok / missing;agent_field、tool_cell 是核对状态:verified 与快照一致、mismatch 对不上、 + * unresolved 核对不了、missing 模型照实说没查到(from 为 null) + */ status?: string + /** agent 字段的出处,agent 节点内的编号(Q1.r0.gmv)。和报告目录的全局编号不是一回事,靠 artifact 对应 */ + ref?: string | null + /** 查询快照的工件 id:跳到哪一个查询步骤按它认 */ + artifact?: string + locator?: EvidenceLocator + eid?: string + /** agent_field mismatch:模型报的值(value 是快照里的) */ + model_value?: unknown + /** tool_cell mismatch:快照里的值(value 是算的时候用的) */ + snapshot_value?: unknown + /** unresolved 的原因(人话) */ + reason?: string + /** 输入对到的查询在报告目录里的编号(Q3):接口按工件补的全局编号 */ + query?: string | null + /** 输入落在哪一格,全局编号(Q3.r5.amount) */ + cell?: string +} + +/** 口径卡钉在另一个已发布工作流某个版本里的口径卡上 */ +export interface EvidenceCaliberSource { + workflow_id?: string + workflow_version?: number | string + node_id?: string + /** 接口顺手给的工作流名;没给时前端按工作流目录找 */ + workflow_name?: string +} + +/** 口径卡上游有新版本时声明的处置(和子工作流的升版处置同一套) */ +export interface EvidenceCaliberUpgrade { + node_id?: string + workflow_id?: string + pinned?: number + latest?: number + /** recompute / dual / incomparable */ + policy?: string + policy_label?: string } /** - * 片段证据链的一步。本期有三种:metric(指标)、input(指标的一个输入,也可能挂在 - * metric.inputs 里)、run_input(报告直接引用的运行输入) + * 片段证据链的一步:metric(指标)、input(指标的一个输入,也可能挂在 metric.inputs 里)、 + * run_input(报告直接引用的运行输入)、query(查询快照:被引用的行加前后各 2 行)。 + * input 步骤同时带着 EvidenceInput 的字段(via、status、model_value、ref、locator、artifact) */ -export interface EvidenceStep { +export interface EvidenceStep extends Omit { step: string metric?: string decimals?: number | null @@ -720,9 +834,36 @@ export interface EvidenceStep { recompute_ok?: boolean | null status?: string inputs?: EvidenceInput[] - path?: string - field?: string - via?: string + /** 口径卡钉在哪个工作流的哪一版(metric;方案第 5 节的写法,接口实际给在 source 上) */ + caliber_from?: EvidenceCaliberSource | null + /** 上游有新版本时的处置(metric);没有升版是 null */ + caliber_upgrade?: EvidenceCaliberUpgrade | null + // ---- query:查询快照的一个窗口 ---- + tool?: string + /** query:数据源名;metric:口径卡钉住的来源 {workflow_id, workflow_version, node_id, workflow_name} */ + source?: string | EvidenceCaliberSource | null + sql?: string + columns?: string[] + /** 只有被引用的行加前后各 2 行;遮罩列的值已经换成「已遮罩」 */ + rows?: unknown[][] + /** rows[0] 在快照里是第几行(从 0 数) */ + row_offset?: number + /** rows 里每一行在快照里是第几行:被引用的行隔得远时窗口不连续,按它认 */ + row_index?: number[] + /** 快照一共几行 */ + total_rows?: number | null + /** 查询撞了行数上限,库里还有更多 */ + truncated?: boolean | null + /** 被引用的行太多,窗口只给了前面一截 */ + window_truncated?: boolean + /** 被引用的行(快照里的行号)和列;cells 是精确到格的 [行, 列],有它就只高亮这几格 */ + highlight?: { rows?: number[]; cols?: string[]; cells?: [number, string][] } + /** 这一步里被遮罩的列 */ + masked?: string[] + /** 这一步为什么没有行(快照不在封存范围里、哈希对不上、取不回来) */ + note?: string + /** 数据源改名或删掉了、按查询当时记下的遮罩处理时的那句说明。行照样有 */ + mask_note?: string } /** GET /runs/{id}/evidence/segments/{sid}:点开一个片段 */ @@ -737,4 +878,6 @@ export interface EvidenceSegmentDetail { /** 落在这个片段(或这一句、没有片段的)上的违规 */ violations?: EvidenceViolation[] seal?: EvidenceSeal + /** 查询步骤里被遮罩的列(数据源 options.mask_columns)。遮罩只减少暴露,不是安全边界 */ + redacted?: { columns?: string[] } } diff --git a/scripts/check-all.mjs b/scripts/check-all.mjs index 5fc0ccca..91666e6c 100644 --- a/scripts/check-all.mjs +++ b/scripts/check-all.mjs @@ -44,6 +44,7 @@ const ORDER = [ 'run-states', // 节点卡的运行态 'canvas-fx', // 画布表层的运行态 'studio', // 编排页的编辑 + 'publish', // 发布前检查与自动修复:发布弹窗、问题面板 'chat', // 问数据 'runs', // 记录 'manage', // 数据、工具、知识、设置 @@ -53,7 +54,7 @@ const ORDER = [ // 新加的检查不在表里就按 60 秒算 const ESTIMATE = { 'run-states': 220, 'canvas-fx': 181, stream: 162, studio: 125, chat: 124, manage: 115, runs: 91, - shell: 84, 'ui-kit': 69, ui: 61, evidence: 21, guards: 10, 'canvas-layout': 4, tokens: 1, trace: 1, decode: 1, + shell: 84, 'ui-kit': 69, ui: 61, evidence: 56, publish: 31, guards: 10, 'canvas-layout': 4, tokens: 1, trace: 1, decode: 1, } // 量时间最多、以前并行最容易挂的:同一时刻最多跑其中一项 const EXCLUSIVE = new Set(['run-states', 'canvas-fx']) @@ -61,7 +62,7 @@ const TIMEOUT_MS = Number(process.env.CHECK_TIMEOUT_MS ?? 15 * 60_000) // 各检查只跑其中几段、只跑一套主题的开关。壳里留着一个 ONLY=… 就会让整套检查悄悄只跑一小截, // 却照样报「全部通过」,所以一律不传给子进程 const FILTERS = ['CHECK_ONLY', 'ONLY', 'THEMES', 'FX_ONLY', 'STUDIO_ONLY', 'RUN_STATES_ONLY', 'UI_KIT_ONLY', 'CHECK_THEME', - 'EVIDENCE_ONLY'] + 'EVIDENCE_ONLY', 'PUBLISH_ONLY'] // 新加的 check-*.mjs 忘了写进 ORDER 就永远不会跑:列出来提醒 const unlisted = readdirSync(HERE) .map((f) => f.match(/^check-(.+)\.mjs$/)?.[1]) diff --git a/scripts/check-chat.mjs b/scripts/check-chat.mjs index 8260594a..6703bebe 100644 --- a/scripts/check-chat.mjs +++ b/scripts/check-chat.mjs @@ -107,6 +107,20 @@ const GRAPH = { edges: [{ source: 'in', target: 'ag' }, { source: 'ag', target: 'out' }], } +/** + * 问数据的答案(可点击证据第二期):input → agent → report → output,报告直接引用 agent 查过的 + * 单元格、整表。文档是 compose_doc 真跑出来的,片段接口的答复带查询步骤(窗口、高亮、遮罩) + */ +const EVQ = JSON.parse(readFileSync(new URL('../frontend/src/run/__tests__/evidence-query.json', import.meta.url), 'utf8')) +const EVQ_DOC = { ...EVQ.doc, run_id: 'run-evq' } +/** 整形节点按 JSON 解析失败的两种原话(backend/app/engine/nodes/io.py 的 _json_error) */ +const JSON_TEMPLATE_ERROR = '模板渲染出来的不是合法 JSON(第 1 行第 934 列附近)。检查模板里的引号、逗号,字符串值要用 | json 过滤器输出' +const JSON_UPSTREAM_ERROR = '上游「查数」输出的不是合法 JSON(第 934 列附近),常见原因是字符串里有没转义的英文引号;' + + '让模型交结构化数据请用 output_schema + cite_fields,别用整形节点解析它写的文字' +/** 同一句上游说法,节点名里带着别的规则的关键词(超时):不能被当成超时、又把「接着跑」给回来 */ +const JSON_SLOW_ERROR = JSON_UPSTREAM_ERROR.replace('上游「查数」', '上游「查询超时订单」') +const EVQ_SEG = (text, nth = 0) => Object.values(EVQ.segments).filter((d) => d.segment.text === text)[nth]?.segment.id + const CONVS = { busyA: conv('c0busya', '会话 A:跑得很久的那个', 0, TODAY_RECENT), idleB: conv('c0idleb', '会话 B:已经答完的那个', 1, 3), @@ -165,7 +179,15 @@ const GUARD_CONVS = { steps: conv('c0gsteps', '护栏:写死 8 步、用满了', 1, 60 * 24 * 31), dflt: conv('c0gdflt', '护栏:跟随默认步数', 1, 60 * 24 * 32), } +/** 只在「问数据的证据」那段出现:库里存着一轮带证据的答案,刷新后回运行取 _evidence */ +const ASK_CONVS = { + askZ: conv('c0askz', '问数据:各区域销售额和最大的一单', 1, 60 * 24 * 33), +} const DETAIL = { + c0askz: [turn('tz9', '上周各区域的销售额,最大的一单是多少', { + answer: EVQ.output.answer, graph: GRAPH, run_id: 'run-evq', + meta: { v: 1, runId: 'run-evq', runClass: 'exploratory', runStatus: 'succeeded', evidence: true }, + })], c0gstall: [guardTurn('tg1', GRAPH8, 'stall', 'agent 连续 3 步没有拿到新信息,收尾轮也没有给出结论。')], c0gsteps: [guardTurn('tg2', GRAPH8, 'steps', 'agent 用满了 8 步。收尾轮也没有给出结论。')], c0gdflt: [guardTurn('tg3', GRAPH, null, 'agent 用满了 12 步还没给出结论。')], @@ -299,6 +321,7 @@ const DETAIL = { const EVIDENCE = JSON.parse(readFileSync(new URL('../frontend/src/run/__tests__/evidence-doc.json', import.meta.url), 'utf8')) const RUNS = { 'run-evid': { status: 'succeeded', output: EVIDENCE.output, error: null }, + 'run-evq': { status: 'succeeded', output: EVQ.output, error: null }, 'run-cancelled': { status: 'cancelled', output: {}, error: null }, 'run-failed': { status: 'failed', output: {}, error: '查询超时' }, 'run-rejected': { status: 'failed', output: {}, error: '人工驳回:数字对不上' }, @@ -337,6 +360,8 @@ const ctl = { trashMore: false, /** 列表里多摆「护栏」那段的三个会话(GUARD_CONVS) */ guardConvs: false, + /** 列表里多摆「问数据的证据」那段的会话(ASK_CONVS) */ + askConvs: false, /** 数据源列表取不回来 */ sourcesFail: false, /** 数据源列表里多一个别处刚加的库 */ @@ -350,7 +375,7 @@ const resetDb = () => { } resetDb() const allConvs = () => [...Object.values(CONVS), ...Object.values(TRASHED), ...(ctl.trashMore ? Object.values(TRASH_MORE) : []), - ...(ctl.guardConvs ? Object.values(GUARD_CONVS) : [])] + ...(ctl.guardConvs ? Object.values(GUARD_CONVS) : []), ...(ctl.askConvs ? Object.values(ASK_CONVS) : [])] .filter((c) => !db.purged.has(c.id)) .map((c) => ({ ...c, archived: db.archived.has(c.id) })) let runSeq = 0 @@ -435,9 +460,9 @@ async function fakeApi(route) { const wait = ctl.reviewDelay[body().run_id] if (wait) await new Promise((r) => setTimeout(r, wait)) // 老后端的复核不认 _evidence,照样回一次改写 - if (body().run_id === 'run-evid') { + if (body().run_id === 'run-evid' || body().run_id === 'run-evq') { return json({ verdict: 'rewritten', note: '检索降级过,结论请对照原始数据', answer: '改写后的答案:销售额大约四万多', - original: EVIDENCE.output['周报'], retry: false, severity: 'degraded', + original: body().run_id === 'run-evq' ? EVQ.output.answer : EVIDENCE.output['周报'], retry: false, severity: 'degraded', signals: [{ kind: 'retrieval_degraded', detail: '检索退回关键词', severity: 'degraded' }] }) } return json({ verdict: 'ok', note: '', answer: null, retry: false, severity: '', signals: [] }) @@ -447,7 +472,16 @@ async function fakeApi(route) { const conversation = String(b.input?.question ?? '') // 发起请求根本没到后端(网断了、后端正在重启) if (conversation.includes('启动失败') && ctl.launchFail) return route.abort('connectionrefused') - const id = conversation.includes('带证据') ? 'run-evid' + // 发起就被拒:绑定的工具在本机不存在(后端 runs.TOOL_MISSING,{detail, code} 的形状) + if (conversation.includes('工具不在')) { + return json({ detail: '绑定的工具在本机不存在:「查数」(调用工具)绑的 db_query__nope。去数据页接入,或在节点里重新选', + code: 'run_tool_missing' }, 422) + } + const id = conversation.includes('整形解析失败') ? (conversation.includes('节点名带超时') ? 'run-jsonslow' + : conversation.includes('上游') ? 'run-jsonup' + : conversation.includes('改写') ? 'run-jsonauth' : 'run-jsonerr') + : conversation.includes('问数据带证据') ? 'run-evq' + : conversation.includes('带证据') ? 'run-evid' : conversation.includes('长答案') ? (conversation.includes('取不到运行') ? 'run-noget' : 'run-long') : conversation.includes('审批') ? 'run-wait' : conversation.includes('重启') ? 'run-restart' @@ -513,6 +547,48 @@ function script(runId, after = 0) { end: 'succeeded', } } + if (runId === 'run-jsonerr' || runId === 'run-jsonup' || runId === 'run-jsonauth' || runId === 'run-jsonslow') { + // 用户真实踩到的:整形节点按 JSON 解析上游的文字失败。旧说法(让人查模板)和新说法(点名上游)各一种; + // 对照:说法被改写过的(鉴权失败)原话还得留着 + const error = runId === 'run-jsonerr' ? JSON_TEMPLATE_ERROR : runId === 'run-jsonup' ? JSON_UPSTREAM_ERROR + : runId === 'run-jsonslow' ? JSON_SLOW_ERROR + : 'AuthenticationError: Error code: 401 - invalid x-api-key' + const detail = "JSONDecodeError: Expecting ',' delimiter: line 1 column 934 (char 933)\n出错位置前后的原文:…\"gmv\": 45678.5, \"note\": \"含\"⟨此处⟩促销\"…" + return { + events: [ + ev(1, 'run.started', null, { nodes: 3 }), + ev(2, 'node.started', 'ag', { node_type: 'agent', label: runId === 'run-jsonslow' ? '查询超时订单' : '查数' }), + ev(3, 'node.finished', 'ag', { duration_ms: 900 }), + ev(4, 'node.started', 'tf', { node_type: 'transform', label: '解析取数结果为变量' }), + ev(5, 'node.failed', 'tf', { error, detail, duration_ms: 3 }), + ev(6, 'run.failed', null, { error, detail }), + ], + end: 'failed', + } + } + if (runId === 'run-evq') { + // 问数据:agent 查库(新运行的 tool.end 多一个 query_artifact)→ 按出处抽取字段 → 报告核对 → 成果 + return { + events: [ + ev(1, 'run.started', null, { nodes: 4 }), + ev(2, 'node.started', 'ag', { node_type: 'agent', label: '查数' }), + ev(3, 'llm.start', 'ag', { model: 'm' }), + ev(4, 'tool.start', 'ag', { tool: 'db_query__shop', call_id: 'q1', args: { sql: 'SELECT region, SUM(amount) AS amount FROM orders GROUP BY region' } }), + ev(5, 'tool.end', 'ag', { tool: 'db_query__shop', call_id: 'q1', duration_ms: 30, artifact: 'tool-snap', query_artifact: 'query-snap', rows: 4 }), + ev(6, 'llm.end', 'ag', { model: 'm', duration_ms: 800 }), + ev(7, 'llm.start', 'ag', { model: 'm', structured: true, purpose: 'cite_fields', message_count: 6 }), + ev(8, 'llm.end', 'ag', { agent: '查数', model: 'm', purpose: 'cite_fields', duration_ms: 900 }), + ev(9, 'log', 'ag', { level: 'warn', code: 'agent_field_mismatch', fields: ['order_cnt'], + message: '有 1 个字段和查询快照对不上,已按快照取值:order_cnt 模型报 1240,快照是 1234' }), + ev(10, 'node.finished', 'ag', { duration_ms: 2100 }), + ev(11, 'node.started', 'write', { node_type: 'report', label: '写答案' }), + ev(12, 'report.checked', 'write', EVQ.report_checked), + ev(13, 'node.finished', 'write', { duration_ms: 900 }), + ev(14, 'run.finished', null, { output: EVQ.output }), + ], + end: 'succeeded', + } + } if (runId === 'run-evid') { return { events: [ @@ -955,6 +1031,149 @@ for (const theme of THEMES) { await ctx.close() }) + await section('askdata', '问数据的答案带逐段证据:实时、刷新后补回、复核守卫、窄屏抽屉(可点击证据第二期)', async () => { + const routeEvidence = async (page) => { + // 文档记着的运行要和成果的运行对得上(run-evq),片段按运行认 + await page.route(/\/api\/artifacts\//, (route) => route.fulfill({ json: { id: EVQ.doc_artifact, content: EVQ_DOC } })) + await page.route(/\/api\/runs\/run-evq\/evidence(\/.*)?(\?.*)?$/, (route) => { + const m = new URL(route.request().url()).pathname.match(/\/segments\/([^/]+)$/) + if (!m) return route.fulfill({ json: EVQ.graph }) + const body = EVQ.segments[decodeURIComponent(m[1])] + return body ? route.fulfill({ json: body }) : route.fulfill({ status: 404, json: { detail: '没有这个片段', code: 'evidence_segment_not_found' } }) + }) + } + { + const { page, ctx, errors } = await open(theme) + await routeEvidence(page) + const patchesBefore = log.patches.length + await goto(page, 'c0trunc') + await send(page, '问数据带证据:各区域销售额,最大的一单是多少') + check('答完了', await page.waitForFunction( + () => window.__chat.getState().byConversation.c0trunc?.at(-1)?.phase === 'done', null, { timeout: 10000 }) + .then(() => true, () => false)) + const t = (await chatState(page)).byConversation.c0trunc.at(-1) + check('复核的改写没落到答案上:成果还是报告原文和 _evidence', t.output?.answer === EVQ.output.answer && !!t.output?._evidence + && t.review?.answer === null, JSON.stringify({ keys: Object.keys(t.output ?? {}), review: t.review?.verdict })) + await page.waitForSelector('[data-evidence-doc] [data-seg]', { timeout: 6000 }).catch(() => {}) + const turn = page.locator('[data-turn]').last() + check('答案是逐段可点的报告', await turn.locator('[data-evidence-doc] [data-seg]').count() > 10) + check('没有出具契约:文档自己给计数条', (await turn.locator('[data-evidence-tally]').innerText().catch(() => '')).includes('数字有出处')) + const text = await turn.innerText() + check('执行过程里有「按出处抽取字段」和字段对不上的提醒', text.includes('按出处抽取字段') && text.includes('有 1 个字段和查询快照对不上'), + text.replace(/\s+/g, ' ').slice(0, 200)) + check('整表的每格都是可点的片段', await turn.locator('[data-evidence-doc] table td [data-seg]').count() === 9) + await turn.locator(`[data-seg="${EVQ_SEG('1,288')}"]`).click() + await page.waitForSelector('[data-evidence-panel] [data-ev-query]', { timeout: 5000 }).catch(() => {}) + const panel = page.locator('[data-evidence-panel]') + check('宽屏:点开数字从侧边弹出,看得到查询步骤', await panel.getAttribute('data-evidence-panel') === 'side' + && await panel.locator('[data-ev-query]').count() === 1) + check('……被引用的格高亮,SQL 在,遮罩说明在', (await panel.locator('td[data-highlight="cell"]').innerText().catch(() => '')) === '1288' + && (await panel.locator('[data-ev-sql]').innerText().catch(() => '')).includes('FROM orders') + && (await panel.locator('[data-ev-mask-note]').innerText().catch(() => '')).includes('不是安全边界')) + await page.waitForTimeout(300) + await page.screenshot({ path: `${SHOTS}/askdata-evidence-${theme}.png` }) + await page.keyboard.press('Escape') + await until(() => log.patches.slice(patchesBefore).some((p) => p.body.meta)) + const saved = log.patches.slice(patchesBefore) + check('落库的是报告原文,meta 记下这一轮有证据', saved.filter((p) => typeof p.body.answer === 'string' && p.body.answer) + .every((p) => p.body.answer === EVQ.output.answer) && saved.map((p) => p.body.meta).filter(Boolean).at(-1)?.evidence === true) + check('没有运行时报错(实时)', errors.length === 0, errors[0] ?? '') + await ctx.close() + } + resetDb() + ctl.askConvs = true + try { + const { page, ctx, errors } = await open(theme) + await routeEvidence(page) + const gets = log.runGets.length + await goto(page, 'c0askz') + await page.waitForSelector('[data-evidence-doc] [data-seg]', { timeout: 8000 }).catch(() => {}) + check('刷新后补回:库里只有文字,回运行取回 _evidence,答案照样逐段可点', + log.runGets.slice(gets).includes('run-evq') && await page.locator('[data-evidence-doc] [data-seg]').count() > 10) + await page.locator(`[data-evidence-doc] [data-seg="${EVQ_SEG('8.7%')}"]`).click() + await page.waitForSelector('[data-evidence-panel] [data-ev-sources]', { timeout: 5000 }).catch(() => {}) + const panel = page.locator('[data-evidence-panel]') + check('……点开指标:口径卡来源、输入来源、两次查询都在', (await panel.locator('[data-ev-caliber-from]').innerText().catch(() => '')).includes('销售周报') + && await panel.locator('[data-ev-source]').count() === 2 && await panel.locator('[data-ev-query]').count() === 2) + check('没有运行时报错(刷新后)', errors.length === 0, errors[0] ?? '') + await ctx.close() + + const narrow = await open(theme, { width: 800, height: 900 }) + await routeEvidence(narrow.page) + await goto(narrow.page, 'c0askz') + await narrow.page.waitForSelector('[data-evidence-doc] [data-seg]', { timeout: 8000 }).catch(() => {}) + await narrow.page.locator(`[data-evidence-doc] [data-seg="${EVQ_SEG('1,288')}"]`).click() + await narrow.page.waitForSelector('[data-evidence-panel] [data-ev-query]', { timeout: 5000 }).catch(() => {}) + const drawer = narrow.page.locator('[data-evidence-panel]') + const sc = await narrow.page.evaluate(() => ({ sw: document.documentElement.scrollWidth, vw: innerWidth })) + check('窄屏:面板从底部抽出,里面是查询步骤', await drawer.getAttribute('data-evidence-panel') === 'drawer' + && await drawer.locator('[data-ev-query]').count() === 1) + check('……整页没有横向滚动', sc.sw <= sc.vw, JSON.stringify(sc)) + await narrow.page.waitForTimeout(300) + await narrow.page.screenshot({ path: `${SHOTS}/askdata-drawer-${theme}.png` }) + await narrow.page.keyboard.press('Escape') + await narrow.page.waitForTimeout(200) + check('……Esc 收起抽屉', await narrow.page.locator('[data-evidence-panel]').count() === 0) + check('没有运行时报错(窄屏)', narrow.errors.length === 0, narrow.errors[0] ?? '') + await narrow.ctx.close() + } finally { + ctl.askConvs = false + } + }) + + await section('nodeerror', '节点报错说一遍:原因、怎么办不在展开区里再贴一遍原话(整形节点解析 JSON 失败)', async () => { + for (const [ask, said] of [['整形解析失败:旧说法', JSON_TEMPLATE_ERROR], ['整形解析失败:上游说法', JSON_UPSTREAM_ERROR], + ['整形解析失败:节点名带超时', JSON_SLOW_ERROR]]) { + const { page, ctx, errors } = await open(theme) + await goto(page, 'c0trunc') + await send(page, ask) + await page.waitForFunction(() => window.__chat.getState().byConversation.c0trunc?.at(-1)?.phase === 'error', null, { timeout: 10000 }) + .catch(() => {}) + const row = page.locator('[data-turn]').last().locator('[data-node-id="tf"][data-step-status="failed"]').first() + await row.locator('button[aria-expanded]').first().click() + await page.waitForTimeout(250) + const shown = await row.innerText() + const head = said.split(/[。;,]/)[0] + const times = shown.split(head).length - 1 + check(`「${ask}」:节点那一行把报错说一遍,展开后不再整段贴一遍原话`, times === 1, `出现 ${times} 次:${shown.replace(/\s+/g, ' ').slice(0, 240)}`) + const tail = said.split(/[;]|。/).at(-1).slice(0, 12) + check(`「${ask}」:怎么办那半句照样在`, shown.includes(tail), tail) + const resume = await page.getByRole('button', { name: /^接着跑/ }).count() + const canvas = await page.getByRole('button', { name: '在画布里打开' }).count() + await page.locator('[data-fix="canvas"]').count() + check(`「${ask}」:原样接着跑只会再失败一次,不给「接着跑」,给去画布改的入口`, resume === 0 && canvas > 0, + `接着跑 ${resume} 个 · 画布入口 ${canvas} 个`) + await row.locator('summary', { hasText: '技术细节' }).click().catch(() => {}) + check(`「${ask}」:原始异常和出错位置前后的原文收在技术细节里`, (await row.innerText()).includes('JSONDecodeError') + && (await row.innerText()).includes('出错位置前后的原文')) + if (said === JSON_UPSTREAM_ERROR) await page.screenshot({ path: `${SHOTS}/nodeerror-json-${theme}.png` }) + check('没有运行时报错', errors.length === 0, errors[0] ?? '') + await ctx.close() + } + const { page, ctx } = await open(theme) + await goto(page, 'c0trunc') + // 节点名是用户起的,里面带着超时、额度、连不上这些别的规则的关键词:照样认作 JSON 解析失败 + const named = await page.evaluate(async (said) => { + const { explainRunError } = await import('/src/lib/explain.ts') + return ['查询超时订单', '额度查询', '连不上的库', '服务器 500 统计'].map((t) => { + const x = explainRunError(said.replace('上游「查数」', `上游「${t}」`)) + return { t, title: x.title, continuable: x.continuable, fix: x.fix } + }) + }, JSON_UPSTREAM_ERROR) + check('节点名里带超时 / 额度 / 连不上 / 500:照样认作「上游输出的不是合法 JSON」,不给接着跑', + named.every((x) => x.title.startsWith(`上游「${x.t}」输出的不是合法 JSON`) && x.continuable === false && x.fix === 'canvas'), + JSON.stringify(named.filter((x) => x.continuable !== false))) + await send(page, '整形解析失败:改写过的说法') + await page.waitForFunction(() => window.__chat.getState().byConversation.c0trunc?.at(-1)?.phase === 'error', null, { timeout: 10000 }) + .catch(() => {}) + const row = page.locator('[data-turn]').last().locator('[data-node-id="tf"][data-step-status="failed"]').first() + await row.locator('button[aria-expanded]').first().click() + await page.waitForTimeout(250) + const t = await row.innerText() + check('对照:说法改写过(鉴权没通过)时,原话还在展开区里', t.includes('模型鉴权没通过') && t.includes('invalid x-api-key'), + t.replace(/\s+/g, ' ').slice(0, 200)) + await ctx.close() + }) + await section('restart', '服务重启:interrupted 且没有待审批,收尾而不是转圈', async () => { const { page, ctx, errors } = await open(theme) await goto(page, 'c0resti') @@ -1596,6 +1815,31 @@ for (const theme of THEMES) { await ctx.close() }) + await section('toolmissing', '发起就被拒(绑定的工具在本机不存在):报错里给直达入口,不给接着跑', async () => { + const { page, ctx, errors } = await open(theme) + await goto(page, 'c0launu') + await send(page, '一个工具不在的问题') + const alert = page.locator('[data-turn] [data-turn-error]').first() + await alert.waitFor({ timeout: 8000 }).catch(() => {}) + const text = await alert.innerText().catch(() => '') + check('标题点名那个工具', text.includes('绑定的工具「db_query__nope」在本机不存在'), text.replace(/\n/g, ' / ')) + check('说清这次运行没有发起、怎么办', text.includes('没有发起') && text.includes('去数据页接入'), text.replace(/\n/g, ' / ')) + const fix = alert.locator('a[data-fix="data"]') + check('报错里有直达入口「去数据页接入」(和画布上发起被拒同一套)', await fix.count() === 1 + && (await fix.innerText().catch(() => '')).includes('去数据页接入')) + check('不给「接着跑」(原样接着跑还是缺这个工具)', await page.getByRole('button', { name: '接着跑' }).count() === 0) + // 这时还没有运行 id,流里的报错块不会再拿原话认一遍:入口得在 store 里就记进这一轮的 failure, + // 刷新之后(meta 里的 failure)照样有 + const stored = await page.evaluate(() => (window.__chat.getState().byConversation.c0launu ?? []).at(-1)?.failure ?? null) + check('入口记进这一轮的 failure(fix / fixTo,不可接着跑)', stored?.fix === 'tools' && stored?.fixTo === '/data' + && stored?.continuable === false, JSON.stringify(stored)) + await fix.click().catch(() => {}) + await page.waitForURL((u) => u.pathname.startsWith('/data'), { timeout: 5000 }).catch(() => {}) + check('点了去数据页', new URL(page.url()).pathname.startsWith('/data'), page.url()) + check('没有运行时报错', errors.length === 0, errors[0] ?? '') + await ctx.close() + }) + await section('guard', '放宽步数只在对症时给:停滞、预算收的尾不给,跟随默认 100 步的也不给', async () => { resetDb() ctl.guardConvs = true diff --git a/scripts/check-decode.mjs b/scripts/check-decode.mjs index 45720f90..6736bd21 100644 --- a/scripts/check-decode.mjs +++ b/scripts/check-decode.mjs @@ -1232,6 +1232,87 @@ await section('工具门控 tool.gated(工具信任三档)', async () => { check('……不是「一条还没翻译的记录」', !bare.some((s) => s.title === '一条还没翻译的记录')) }) +await section('agent 字段按出处核对(可点击证据第二期):抽取调用单独成行、两个新警告码说人话', async () => { + const E = (seq, type, node_id, data = {}) => ({ seq, type, node_id, ts: 1790000000 + seq, data }) + // 两条警告的原话:和 backend/app/engine/nodes/llm.py 的 _field_warnings 同一个格式。先核对后端源码还是这么说的 + const MISMATCH = '有 1 个字段和查询快照对不上,已按快照取值:order_cnt 模型报 1240,快照是 1234' + const UNVERIFIED = '有 2 个字段核对不了出处,记为空值(没有兜底成 0):new_users(Q1 没有列「new_users」);refund_cnt(快照取不回来)' + const FAILED = '结构化抽取没跑成:模型调用超时。3 个字段都记为空值(没有兜底成 0)' + // 抽取调用成了、但交回来的不是对象:同一句式,前面没有「结构化抽取没跑成:」 + const NOT_OBJECT = '抽取结果不是一个对象。2 个字段都记为空值(没有兜底成 0)' + const llmPy = readFileSync(`${root}backend/app/engine/nodes/llm.py`, 'utf8') + check('后端发字段核对警告的格式还是夹具里这一种', llmPy.includes('"agent_field_mismatch"') + && llmPy.includes('个字段和查询快照对不上,已按快照取值:{detail}') && llmPy.includes('模型报 {_shown(e.get(\'model_value\'))},快照是 ') + && llmPy.includes('个字段核对不了出处,记为空值(没有兜底成 0):{detail}') && llmPy.includes('个字段都记为空值(没有兜底成 0)') + && llmPy.includes('purpose="cite_fields"') && llmPy.includes('"抽取结果不是一个对象"') && llmPy.includes('结构化抽取没跑成:{'), + '改了 llm.py 的说法就同步改 decode.ts 的 explainLog 和这里的夹具') + const events = [ + E(1, 'run.started', null, { total: 2 }), + E(2, 'node.started', 'fetch', { node_type: 'agent', label: '查数' }), + E(3, 'llm.start', 'fetch', { model: 'm1' }), + E(4, 'tool.start', 'fetch', { tool: 'db_query__shop', call_id: 'c1', args: { sql: 'SELECT SUM(amount) AS gmv FROM orders' } }), + // 新运行查库成功时 tool.end 多一个 query_artifact:照常解码成那一次查询,不另起行 + E(5, 'tool.end', 'fetch', { tool: 'db_query__shop', call_id: 'c1', duration_ms: 40, artifact: 'tool-snap', query_artifact: 'query-snap', + preview: '{"columns":["gmv"],"rows":[[45678.5]]}' }), + E(6, 'llm.end', 'fetch', { model: 'm1', duration_ms: 900 }), + E(7, 'llm.start', 'fetch', { model: 'm1', structured: true, purpose: 'cite_fields', message_count: 5 }), + E(8, 'llm.end', 'fetch', { agent: '查数', model: 'm1', purpose: 'cite_fields', duration_ms: 1200, input_tokens: 900, output_tokens: 80 }), + E(9, 'log', 'fetch', { level: 'warn', code: 'agent_field_mismatch', message: MISMATCH, fields: ['order_cnt'] }), + E(10, 'log', 'fetch', { level: 'warn', code: 'agent_field_unverified', message: UNVERIFIED, fields: ['new_users', 'refund_cnt'] }), + E(11, 'node.finished', 'fetch', { duration_ms: 2400 }), + ] + const all = flatten(mod.decodeRun(events)) + check('新事件都有翻译', !all.some((s) => s.title === '一条还没翻译的记录')) + const llms = all.filter((s) => s.kind === 'llm') + check('循环里那次照旧叫「思考并作答」,抽取那次单独起名「按出处抽取字段」', llms.map((s) => s.title).join('|') === '思考并作答|按出处抽取字段', + llms.map((s) => s.title).join('|')) + check('抽取那次跑完了、带耗时', llms[1]?.status === 'done' && llms[1]?.meta === '1.2 s', `${llms[1]?.status} ${llms[1]?.meta}`) + const q = all.filter((s) => s.kind === 'query') + check('tool.end 多了 query_artifact:照常是一次查询,工件仍是 tool 快照', q.length === 1 && q[0].status === 'done' && q[0].artifact === 'tool-snap', + JSON.stringify(q.map((s) => [s.status, s.artifact]))) + const mm = all.find((s) => s.code === 'agent_field_mismatch') + check('agent_field_mismatch 说人话:几个字段对不上、已按快照取值', mm?.title === '有 1 个字段和查询快照对不上,已按快照取值', mm?.title) + check('……点名的字段和两边的值放副标题,原话留在展开区', mm?.sub === 'order_cnt 模型报 1240,快照是 1234' && mm?.detail === MISMATCH, + `${mm?.sub} / ${mm?.detail}`) + check('……是提醒(warn),并说下游用的是哪个值', mm?.level === 'warn' && !!mm?.next?.includes('快照'), `${mm?.level} ${mm?.next}`) + const un = all.find((s) => s.code === 'agent_field_unverified') + check('agent_field_unverified 说人话:几个字段核对不了、记为空、没有兜底成 0', un?.title === '有 2 个字段核对不了出处,记为空值(没有兜底成 0)', un?.title) + check('……原因放副标题', un?.sub === 'new_users(Q1 没有列「new_users」);refund_cnt(快照取不回来)' && un?.level === 'warn', un?.sub) + check('……两条警告都挂在 agent 节点下面', all.find((s) => s.nodeId === 'fetch' && s.kind === 'node')?.children?.filter((c) => c.code?.startsWith('agent_field')).length === 2) + + const failed = flatten(mod.decodeRun([ + E(1, 'node.started', 'fetch', { node_type: 'agent' }), + E(2, 'llm.start', 'fetch', { model: 'm1', structured: true, purpose: 'cite_fields', message_count: 5 }), + E(3, 'llm.end', 'fetch', { agent: '查数', model: 'm1', purpose: 'cite_fields', duration_ms: 30000, error: 'TimeoutError: 模型调用超时' }), + E(4, 'log', 'fetch', { level: 'warn', code: 'agent_field_unverified', message: FAILED, fields: ['a', 'b', 'c'] }), + E(5, 'node.finished', 'fetch', { duration_ms: 31000 }), + ])) + const ex = failed.find((s) => s.kind === 'llm') + check('抽取调用失败:那一行说「没跑成」,原因放展开区,是提醒不是整个节点失败', ex?.title === '按出处抽取字段没跑成' && ex?.level === 'warn' + && !!ex?.detail?.includes('模型调用超时'), `${ex?.title} ${ex?.level} ${ex?.detail}`) + // status 是 failed 的话,这一行和「执行」那一栏的标头都画成红的失败,可节点、运行都成功了 + check('……这一行是做完了的提醒(done + warn),不是 failed', ex?.status === 'done' && ex?.level === 'warn', `${ex?.status} ${ex?.level}`) + check('……节点本身照样成功', failed.find((s) => s.kind === 'node')?.status === 'done', failed.find((s) => s.kind === 'node')?.status) + const fu = failed.find((s) => s.code === 'agent_field_unverified') + check('……接着的警告说「抽取没跑成,N 个字段记为空值」', fu?.title === '按出处抽取字段没跑成,3 个字段记为空值(没有兜底成 0)' + && fu?.sub === '模型调用超时', `${fu?.title} / ${fu?.sub}`) + const notObject = flatten(mod.decodeRun([ + E(1, 'node.started', 'fetch', { node_type: 'agent' }), + E(2, 'log', 'fetch', { level: 'warn', code: 'agent_field_unverified', message: NOT_OBJECT, fields: ['a', 'b'] }), + E(3, 'node.finished', 'fetch', { duration_ms: 10 }), + ])).find((s) => s.code === 'agent_field_unverified') + check('「抽取结果不是一个对象」:同样说「没跑成,N 个字段记为空值」,原因放副标题,不说「出处找不到」', + notObject?.title === '按出处抽取字段没跑成,2 个字段记为空值(没有兜底成 0)' && notObject?.sub === '抽取结果不是一个对象' + && !notObject?.next?.includes('出处在查询结果里找不到'), `${notObject?.title} / ${notObject?.sub} / ${notObject?.next}`) + const node = mod.decodeRun([E(1, 'node.started', 'fetch', { node_type: 'agent' }), E(2, 'node.finished', 'fetch', { duration_ms: 1 })]) + check('对照:普通 agent 节点不多出任何行', flatten(node).filter((s) => s.kind !== 'node').length === 0) + const odd = flatten(mod.decodeRun([E(1, 'node.started', 'x', { node_type: 'agent' }), + E(2, 'log', 'x', { level: 'warn', code: 'agent_field_mismatch', message: '字段对不上' }), + E(3, 'log', 'x', { level: 'warn', code: 'agent_field_unverified' })])) + check('别的说法(缺字段):标题照样说人话,不出 undefined、不瞎填数', odd.find((s) => s.code === 'agent_field_mismatch')?.title === '有字段和查询快照对不上,已按快照取值' + && !odd.some((s) => /undefined|NaN/.test(`${s.title}${s.sub ?? ''}`)), odd.map((s) => s.title).join(' | ')) +}) + await section('术语', async () => { // 没起名的节点退到类型名,类型名跟全站同一张表 const h = mod.decodeRun([{ seq: 1, type: 'node.started', node_id: 'h', ts: 1, data: { node_type: 'human', label: 'h' } }]) diff --git a/scripts/check-evidence.mjs b/scripts/check-evidence.mjs index e2bea00a..7a29780d 100644 --- a/scripts/check-evidence.mjs +++ b/scripts/check-evidence.mjs @@ -24,6 +24,9 @@ const CHROME = process.env.CHROME_PATH const SHOTS = process.env.EVIDENCE_SHOTS const root = new URL('..', import.meta.url).pathname const fx = JSON.parse(readFileSync(`${root}frontend/src/run/__tests__/evidence-doc.json`, 'utf8')) +// 第二期:报告直接引用查询单元格、整表,口径卡的输入核对到 agent 字段和查询快照。文档同样是 +// compose_doc 真跑出来的(查询快照走 loader),片段接口的答复按方案第 5 节和 NOTES-A2 的形状拼 +const fxq = JSON.parse(readFileSync(`${root}frontend/src/run/__tests__/evidence-query.json`, 'utf8')) let failed = 0 const check = (name, cond, detail = '') => { @@ -51,26 +54,43 @@ async function routed(page) { const req = r.request() const url = new URL(req.url()) if (req.method() !== 'GET') return r.abort() - const seg = url.pathname.match(/\/api\/runs\/[^/]+\/evidence\/segments\/([^/]+)$/) + const seg = url.pathname.match(/\/api\/runs\/([^/]+)\/evidence\/segments\/([^/]+)$/) + // 按运行认夹具:第二期那份(查询链)和第一期那份各有自己的片段 + const set = url.pathname.includes(`/runs/${fxq.run_id}/`) ? fxq : fx if (seg) { - hits.push(decodeURIComponent(seg[1])) - const body = fx.segments[decodeURIComponent(seg[1])] + hits.push(`${set === fxq ? 'q:' : ''}${decodeURIComponent(seg[2])}`) + const body = set.segments[decodeURIComponent(seg[2])] return body ? r.fulfill({ json: body }) : r.fulfill({ status: 404, json: { detail: '报告里没有这个片段', code: 'evidence_segment_not_found' } }) } - if (/\/api\/runs\/[^/]+\/evidence$/.test(url.pathname)) return r.fulfill({ json: fx.graph }) + if (/\/api\/runs\/[^/]+\/evidence$/.test(url.pathname)) return r.fulfill({ json: set.graph }) if (url.pathname === `/api/artifacts/${fx.doc_artifact}`) return r.fulfill({ json: { id: fx.doc_artifact, content: fx.doc } }) + if (url.pathname === `/api/artifacts/${fxq.snapshots.Q3.artifact}`) { + hits.push('artifact:Q3') + // 工件接口给的是原值(遮罩只在证据接口里换):夹具里存的是换过的,这里换回「原值」 + const content = structuredClone(fxq.snapshots.Q3.content) + const j = content.columns.indexOf('phone') + content.rows.forEach((row, i) => { row[j] = `RAW-PHONE-${i}` }) + return r.fulfill({ json: { id: fxq.snapshots.Q3.artifact, content } }) + } return r.continue() }) return hits } -async function open(url, { w = 1280, h = 1000, reduced = false } = {}) { +async function open(url, { w = 1280, h = 1000, reduced = false, patchQuery = null } = {}) { const ctx = await browser.newContext({ viewport: { width: w, height: h }, ...(reduced ? { reducedMotion: 'reduce' } : {}) }) const page = await ctx.newPage() + // 找不到元素时 8 秒就报,不等默认的 30 秒:改坏验证时一节里十几处都找不到 + page.setDefaultTimeout(8000) const errors = [] page.on('pageerror', (e) => errors.push(e.message)) const hits = await routed(page) + // 预览页直接 import 第二期夹具(vite 把 JSON 当模块给):要一份改过的文档时把那个模块换掉 + if (patchQuery) { + await page.route((u) => new URL(u).pathname.endsWith('/run/__tests__/evidence-query.json'), (r) => + r.fulfill({ contentType: 'application/javascript', body: `export default ${JSON.stringify(patchQuery(structuredClone(fxq)))}` })) + } await page.goto(`${WEB}${url}`, { waitUntil: 'networkidle' }) await page.waitForSelector('[data-evidence-doc], [data-turn]', { timeout: 8000 }) return { ctx, page, errors, hits } @@ -578,6 +598,444 @@ await section('fields', '成果字段取文档:别的运行写的、没记正 await r.ctx.close() }) +// --------------------------------------------------------------------------- +// 第二期:查询步骤、输入来源、整表、口径卡来源(夹具 evidence-query.json) +// --------------------------------------------------------------------------- +const Q = '#evidence-query' +const QN = '#evidence-query-narrow' +/** 夹具里的片段 id:按文字认,免得重新生成夹具后编号挪了这里全错 */ +const qseg = (text, nth = 0) => Object.values(fxq.segments).filter((d) => d.segment.text === text)[nth]?.segment.id +const Q3 = fxq.snapshots.Q3.artifact +const qsteps = (text) => fxq.segments[qseg(text)].chain.filter((st) => st.step === 'query') +/** 第二期的组件预览:片段接口按 answer(segId, 夹具拷贝) 回(数字当状态码),其余照 routed */ +async function probeQ(answer, opts = {}) { + const r = await open('/ui-harness.html?evidence=1', opts) + await r.page.route((u) => new URL(u).pathname.includes(`/runs/${fxq.run_id}/evidence/segments/`), (route) => { + const id = decodeURIComponent(new URL(route.request().url()).pathname.split('/').pop()) + const body = answer(id, structuredClone(fxq.segments[id] ?? {})) + return typeof body === 'number' + ? route.fulfill({ status: body, json: { detail: '伪造的错误', code: 'evidence_report_not_found' } }) + : route.fulfill({ json: body }) + }) + return r +} +async function openQ(p, text, { box = Q, nth = 0, wait = '[data-ev-query]' } = {}) { + await p.locator(`${box} [data-seg="${qseg(text, nth)}"]`).click() + await p.waitForSelector(`[data-evidence-panel] ${wait}`, { timeout: 4000 }).catch(() => {}) + await p.waitForTimeout(120) +} +/** 令牌算出来的颜色:拿一个探针元素量,不在脚本里写死色值 */ +const tokenColor = (p, prop, value) => p.evaluate(([prop, value]) => { + const el = document.createElement('div') + el.style[prop] = value + document.body.appendChild(el) + const got = getComputedStyle(el)[prop] + el.remove() + return got +}, [prop, value]) + +await section('query', '查询步骤:SQL 带复制、被引用的行和格高亮、窗口说明、完整快照、遮罩', async () => { + await openQ(page, '1,288') + const part = panel(page).locator(`[data-ev-query="${Q3}"]`) + if (SHOTS) { + for (const theme of ['dark', 'light']) { + await page.evaluate((t) => document.documentElement.setAttribute('data-theme', t), theme) + await page.waitForTimeout(250) + await page.screenshot({ path: `${SHOTS}/evidence-query-step-${theme}.png` }) + } + await page.evaluate(() => document.documentElement.setAttribute('data-theme', 'dark')) + } + check('点开单元格:面板里有这次查询的步骤', await part.count() === 1, String(await part.count())) + check('标题写查询编号和工具', (await part.innerText()).includes('Q3') && (await part.innerText()).includes('db_query__shop')) + check('SQL 原样摆出来', (await part.locator('[data-ev-sql]').innerText()).startsWith('SELECT order_id, region, amount, phone')) + check('SQL 带复制按钮', await part.locator('button', { hasText: '复制 SQL' }).count() === 1) + const rows = part.locator('tbody tr[data-highlight="row"]') + check('被引用的行加 data-highlight="row",只有这一行', await rows.count() === 1 + && (await rows.first().innerText()).includes('10006'), await rows.first().innerText().catch(() => '')) + const cell = part.locator('td[data-highlight="cell"]') + check('被引用的格加 data-highlight="cell",在 amount 列', await cell.count() === 1 && (await cell.innerText()) === '1288' + && await cell.evaluate((td) => td.closest('table').querySelectorAll('th')[td.cellIndex]?.textContent === 'amount'), + await cell.innerText().catch(() => '')) + const [rowBg, cellLine] = await Promise.all([ + rows.first().evaluate((el) => getComputedStyle(el).backgroundColor), + cell.evaluate((el) => getComputedStyle(el).outlineColor), + ]) + check('高亮只用令牌:行底是 --st-done-soft', rowBg === await tokenColor(page, 'backgroundColor', 'var(--st-done-soft)'), rowBg) + check('高亮只用令牌:格的描边是 --st-done', cellLine === await tokenColor(page, 'outlineColor', 'var(--st-done)'), cellLine) + check('只显示窗口:被引用的第 6 行加前后各 2 行,共 5 行', await part.locator('tbody tr').count() === 5) + const win = await part.locator('[data-ev-window]').innerText().catch(() => '') + check('窗口外的行写明「仅显示被引用的行及前后各 2 行」和位置', win.includes('仅显示被引用的行及前后各 2 行') + && win.includes('第 4–8 行') && win.includes('共 12 行'), win) + const masked = await part.locator('td[data-masked]').allInnerTexts() + check('被遮罩的列(phone)每格写「已遮罩」', masked.length === 5 && masked.every((t) => t === '已遮罩'), masked.join('|')) + const note = await panel(page).locator('[data-ev-mask-note]').innerText().catch(() => '') + check('面板底部说明遮罩:列名 + 不是安全边界', note.includes('phone') && note.includes('遮罩只减少暴露,不是安全边界'), note) + check('遮罩说明在面板最后(封存那一行之后)', await panel(page).evaluate((p) => { + const note = p.querySelector('[data-ev-mask-note]') + const seal = p.querySelector('[data-ev-seal]') + return !!note && !!seal && !!(seal.compareDocumentPosition(note) & Node.DOCUMENT_POSITION_FOLLOWING) + })) + check('查询快照复验通过时不多说一句', await panel(page).locator('[data-ev-integrity]').count() === 0) + const aria = await page.locator(`${Q} [data-seg="${qseg('1,288')}"]`).getAttribute('aria-label') + check('单元格的 aria-label 写明查询、第几行、哪一列', aria === '1,288,有出处:查询 Q3 · 第 6 行 · amount', aria) + + const before = harness.hits.filter((h) => h === 'artifact:Q3').length + await part.locator('[data-ev-snapshot]').click() + await page.waitForSelector('[role="dialog"] [data-artifact] tbody tr', { timeout: 4000 }).catch(() => {}) + const full = await page.locator('[role="dialog"] [data-artifact] tbody tr').count() + check('「打开完整快照」复用工件查看,12 行全在', full === 12 && harness.hits.filter((h) => h === 'artifact:Q3').length === before + 1, `${full} 行`) + const fullText = await page.locator('[role="dialog"] [data-artifact]').innerText().catch(() => '') + check('……完整快照里遮罩列同样写「已遮罩」,工件接口给的原值不画出来', !fullText.includes('RAW-PHONE') + && await page.locator('[role="dialog"] [data-artifact] td[data-masked]').count() === 12, + fullText.includes('RAW-PHONE') ? '原值漏出来了' : String(await page.locator('[role="dialog"] [data-artifact] td[data-masked]').count())) + await page.keyboard.press('Escape') + await page.waitForTimeout(200) + check('在完整快照里按 Esc 只关快照,证据面板还开着', await page.locator('[role="dialog"]').count() === 0 && await panel(page).count() === 1) + + await openQ(page, '华东') + const v = panel(page).locator('[data-ev-query]') + check('值片段(华东)也给查询步骤,高亮第 1 行 region 格', (await v.locator('td[data-highlight="cell"]').innerText().catch(() => '')) === '华东' + && await v.locator('tbody tr').first().getAttribute('data-highlight') === 'row') + check('第 1 行:窗口是第 1–3 行(前面没有行可补),照实写', (await v.locator('[data-ev-window]').innerText().catch(() => '')) + .includes('第 1–3 行,共 4 行')) + check('没有遮罩列时不写遮罩说明', await panel(page).locator('[data-ev-mask-note]').count() === 0) + await page.keyboard.press('Escape') + await openQ(page, '45,678.5元') + const one = panel(page).locator('[data-ev-query]') + check('整份快照都在窗口里(1 行):不写窗口说明,完整快照照样能开', await one.count() === 1 + && await one.locator('[data-ev-window]').count() === 0 && await one.locator('[data-ev-snapshot]').count() === 1) + await page.keyboard.press('Escape') + + // 被引用的两格隔得远(第 6、12 行):窗口不连续,行号那一列看得出跳过了哪几行,只标那两格 + await openQ(page, '672.5元') + const gap = panel(page).locator(`[data-ev-query="${Q3}"]`) + const nums = await gap.locator('tbody tr td:first-child').allInnerTexts() + check('窗口不连续:最前面一列是快照里的行号(4–8、10–12)', nums.join(',') === '4,5,6,7,8,10,11,12', nums.join(',')) + check('……窗口说明写成「第 4–8、10–12 行,共 12 行」', (await gap.locator('[data-ev-window]').innerText().catch(() => '')) + .includes('第 4–8、10–12 行,共 12 行')) + const hit2 = await gap.locator('td[data-highlight="cell"]').allInnerTexts() + check('……两格都高亮(1288、615.5)', hit2.join(',') === '1288,615.5', hit2.join(',')) + await page.keyboard.press('Escape') + const exact = await probeQ((id, body) => (id === qseg('1,288') ? { ...body, chain: body.chain.map((st) => (st.step === 'query' + ? { ...st, highlight: { rows: [5, 6], cols: ['amount', 'region'], cells: [[5, 'amount'], [6, 'region']] } } : st)) } : body)) + await openQ(exact.page, '1,288') + const exactCells = await exact.page.locator('[data-evidence-panel] td[data-highlight="cell"]').allInnerTexts() + check('highlight 给了 cells:只标那几格,不按行 × 列全标', exactCells.join(',') === '1288,华北', exactCells.join(',')) + await exact.ctx.close() + const unsealed = await probeQ((id, body) => (id === qseg('1,288') ? { ...body, chain: body.chain.map((st) => (st.step === 'query' + ? { step: 'query', alias: st.alias, artifact: st.artifact, tool: st.tool, columns: [], rows: [], hash_ok: null, sealed: false, + highlight: st.highlight, note: '这份查询快照不在封存范围内的任何事件里,不能当证据展示' } : st)) } : body)) + await openQ(unsealed.page, '1,288') + const un = await unsealed.page.locator('[data-evidence-panel] [data-ev-query]').innerText().catch(() => '') + check('查询快照不在封存范围里:一行都不画,照接口原话说,并用失败色写「不在封存范围内」', un.includes('不能当证据展示') + && await unsealed.page.locator('[data-evidence-panel] [data-ev-query] [data-ev-integrity~="sealed"]').count() === 1 + && await unsealed.page.locator('[data-evidence-panel] [data-ev-query] tbody tr').count() === 0, + un.replace(/\s+/g, ' ').slice(0, 200)) + // 后端这时照样带着工件 id:按钮一点就绕过证据接口把整份快照打开,还挂着「哈希已校验」 + check('……不给「打开完整快照」(接口不给行,面板也不能开整份快照)', + await unsealed.page.locator('[data-evidence-panel] [data-ev-snapshot]').count() === 0) + await unsealed.ctx.close() + + // 接口把遮罩列的原值带回来了(老接口、改错了的后端):列在遮罩里就一律不画原值 + const leak = await probeQ((id, body) => (id === qseg('1,288') ? { ...body, chain: body.chain.map((st) => (st.step === 'query' + ? { ...st, rows: st.rows.map((r) => r.map((v, j) => (st.columns[j] === 'phone' ? `RAW-PHONE-${j}` : v))) } : st)) } : body)) + await openQ(leak.page, '1,288') + const leaked = await leak.page.locator('[data-evidence-panel]').innerText().catch(() => '') + check('遮罩列带回了原值也不画:表里一律写「已遮罩」', !leaked.includes('RAW-PHONE') + && await leak.page.locator('[data-evidence-panel] td[data-masked]').count() === 5, leaked.includes('RAW-PHONE') ? '原值漏出来了' : '') + await leak.ctx.close() + + const bad = await probeQ((id, body) => (id === qseg('1,288') + ? { ...body, chain: body.chain.map((st) => (st.step === 'query' ? { ...st, hash_ok: false } : st)) } : body)) + await openQ(bad.page, '1,288') + const said = await bad.page.locator('[data-evidence-panel] [data-ev-integrity]').innerText().catch(() => '') + const color = await bad.page.locator('[data-evidence-panel] [data-ev-integrity]').evaluate((el) => getComputedStyle(el).color).catch(() => '') + check('查询快照哈希对不上:用失败色写出来', said.includes('查询快照') && said.includes('哈希对不上') + && color === await tokenColor(bad.page, 'color', 'var(--st-failed)'), said) + check('……哈希对不上也不给「打开完整快照」', await bad.page.locator('[data-evidence-panel] [data-ev-snapshot]').count() === 0) + await bad.ctx.close() +}) + +await section('sources', '输入来源:与快照一致、模型报 X 快照是 Y、没查到记为空、代码节点、点标签跳到查询', async () => { + await openQ(page, '37.0元', { wait: '[data-ev-sources]' }) + const src = (path) => panel(page).locator(`[data-ev-source="${path}"]`) + const gmv = await src('vars.kpi.gmv').innerText().catch(() => '') + check('agent 字段 verified:写「与快照一致」', gmv.includes('与快照一致') + && await src('vars.kpi.gmv').getAttribute('data-ev-source-status') === 'verified', gmv) + const ord = await src('vars.kpi.order_cnt').innerText().catch(() => '') + check('mismatch:写「模型报 1,240,快照是 1,234,已按快照取值」', ord.includes('模型报 1,240,快照是 1,234,已按快照取值'), ord) + check('写明从哪个节点的哪个字段、哪一格来', gmv.includes('fetch') && gmv.includes('Q1') && gmv.includes('第 1 行'), gmv) + await page.keyboard.press('Escape') + + await openQ(page, '—', { wait: '[data-ev-sources]' }) + const refund = await src('vars.kpi.refund_cnt').innerText().catch(() => '') + check('from 为 null(missing):写「没查到,记为空,没有兜底成 0」', refund.includes('没查到,记为空,没有兜底成 0'), refund) + check('缺输入的指标照样给出输入来源,说得清缺的是哪一个', await panel(page).locator('[data-ev-missing]').count() === 1 + && await panel(page).locator('[data-ev-sources] [data-ev-source]').count() === 2) + await page.keyboard.press('Escape') + await openQ(page, '—', { nth: 1, wait: '[data-ev-sources]' }) + const nu = await src('vars.kpi.new_users').innerText().catch(() => '') + check('unresolved:同样写「没查到,记为空,没有兜底成 0」,并带原因', nu.includes('没查到,记为空,没有兜底成 0') + && nu.includes('没有列「new_users」'), nu) + await page.keyboard.press('Escape') + + await openQ(page, '44,000.0元', { wait: '[data-ev-sources]' }) + const compute = src('nodes.adjust.total') + const ct = await compute.innerText().catch(() => '') + check('来自 code 节点(计算):写警示语,说要进口径卡', ct.includes('代码节点') && ct.includes('口径卡') + && await compute.getAttribute('data-ev-source-role') === 'compute', ct) + const computeColor = await compute.evaluate((el) => getComputedStyle(el).borderColor).catch(() => '') + await page.keyboard.press('Escape') + await openQ(page, '7.12', { wait: '[data-ev-sources]' }) + const source = src('nodes.rate.value') + const st = await source.innerText().catch(() => '') + check('来自 code 节点(取数):写明标为取数、核对不到快照', st.includes('取数') && st.includes('核对不到') + && await source.getAttribute('data-ev-source-role') === 'source', st) + const sourceColor = await source.evaluate((el) => getComputedStyle(el).borderColor).catch(() => '') + check('计算角色比取数角色醒目:失败色描边', computeColor === await tokenColor(page, 'borderColor', 'var(--st-failed)') + && sourceColor !== computeColor, `${computeColor} / ${sourceColor}`) + await page.keyboard.press('Escape') + + await openQ(page, '8.7%', { wait: '[data-ev-sources]' }) + if (SHOTS) { + for (const theme of ['dark', 'light']) { + await page.evaluate((t) => document.documentElement.setAttribute('data-theme', t), theme) + await page.waitForTimeout(250) + await page.screenshot({ path: `${SHOTS}/evidence-sources-${theme}.png` }) + await panel(page).evaluate((p) => { p.querySelector('.overflow-y-auto').scrollTop = 99999 }) + await page.waitForTimeout(100) + await page.screenshot({ path: `${SHOTS}/evidence-sources-queries-${theme}.png` }) + await panel(page).evaluate((p) => { p.querySelector('.overflow-y-auto').scrollTop = 0 }) + } + await page.evaluate(() => document.documentElement.setAttribute('data-theme', 'dark')) + } + const prev = qsteps('8.7%').find((q) => q.alias === 'Q4') + const chip = panel(page).locator(`button[data-ev-input="cell(nodes.pull, 0, 'gmv')"]`) + check('代入式里的输入标签是按钮', await chip.count() === 1) + await chip.click() + await page.waitForTimeout(200) + const landed = await page.evaluate((a) => { + const part = document.querySelector(`[data-evidence-panel] [data-ev-query="${a}"]`) + const css = part ? getComputedStyle(part) : null + return { inside: !!part?.contains(document.activeElement), flash: part?.hasAttribute('data-flash') ?? false, + style: css?.outlineStyle, width: css?.outlineWidth, color: css?.outlineColor } + }, prev?.artifact) + // 只有属性没有样式等于没描:目标步骤已经在视野里时,点完什么都看不出来变了 + check('点输入标签:焦点跳到对应的查询步骤(Q4),并描一下(看得见的强调色描边)', landed.inside && landed.flash + && landed.style !== 'none' && parseFloat(landed.width) >= 1 && landed.color === await tokenColor(page, 'outlineColor', 'var(--accent)'), + JSON.stringify(landed)) + // 键盘:焦点落在查询步骤的标题上,得有看得见的焦点框(WCAG 2.4.7) + await page.waitForTimeout(1700) + await page.keyboard.press('Shift') + await chip.focus() + await page.keyboard.press('Enter') + await page.waitForTimeout(150) + const ring = await page.evaluate(() => { + const el = document.activeElement + const css = el ? getComputedStyle(el) : null + return { head: el?.hasAttribute('data-ev-query-head') ?? false, style: css?.outlineStyle, width: css?.outlineWidth } + }) + check('……键盘按回车跳过去:焦点落在查询步骤标题上,焦点框看得见', ring.head && ring.style !== 'none' && parseFloat(ring.width) >= 1, + JSON.stringify(ring)) + const go = panel(page).locator('[data-ev-source="vars.kpi.gmv"] button[data-ev-goto]') + check('输入来源那一行也能跳到查询', await go.count() === 1) + await go.click() + await page.waitForTimeout(200) + check('……跳到 Q1 那一步', await page.evaluate((a) => !!document.querySelector(`[data-evidence-panel] [data-ev-query="${a}"]`) + ?.contains(document.activeElement), qsteps('8.7%').find((q) => q.alias === 'Q1')?.artifact)) + await page.keyboard.press('Escape') + await page.waitForTimeout(150) + check('Esc 关掉面板,焦点回到原片段', await panel(page).count() === 0 && await active(page) === qseg('8.7%'), await active(page)) +}) + +await section('table', '整表:每格是可点的片段,方向键按行列走,值和数同一套线型', async () => { + const cells = page.locator(`${Q} table td [data-seg]`) + check('整表每个数据格都是可点的片段(3 行 × 3 列)', await cells.count() === 9, String(await cells.count())) + check('表头不是片段', await page.locator(`${Q} table th [data-seg]`).count() === 0) + const [val, num] = await Promise.all([qseg('华东', 1), qseg('18,230.5', 1)].map((id) => page.locator(`${Q} [data-seg="${id}"]`) + .evaluate((el) => { const cs = getComputedStyle(el); return `${cs.textDecorationLine}/${cs.textDecorationStyle}/${cs.textDecorationColor}` }))) + check('值片段(华东)和数字片段(18,230.5)同一套线型', val === num && val.includes('solid'), `${val} vs ${num}`) + await page.locator(`${Q} [data-seg="${qseg('18,230.5', 1)}"]`).focus() + const walk = [] + for (const key of ['ArrowDown', 'ArrowDown', 'ArrowUp', 'ArrowRight', 'ArrowDown', 'ArrowDown']) { + await page.keyboard.press(key) + walk.push(await active(page)) + } + const want = [qseg('12,004', 1), qseg('9,876'), qseg('12,004', 1), qseg('311'), qseg('298'), qseg('1,288')] + check('表格里 ↓/↑ 按列走到下一行 / 上一行,→ 走到右边一格,出了最后一行接着往下', walk.join(',') === want.join(','), + `${walk.join(',')} ≠ ${want.join(',')}`) + await page.locator(`${Q} [data-seg="${qseg('华东', 1)}"]`).focus() + await page.keyboard.press('ArrowUp') + check('第一行按 ↑ 回到表格前面那一句', await active(page) === qseg('华东', 0), await active(page)) + await page.locator(`${Q} [data-seg="${qseg('311')}"]`).focus() + await page.keyboard.press('Enter') + await page.waitForSelector('[data-evidence-panel] [data-ev-query]', { timeout: 4000 }).catch(() => {}) + const hl = await panel(page).locator('td[data-highlight="cell"]').innerText().catch(() => '') + check('回车打开这一格的查询步骤,高亮 311 这一格', hl === '311', hl) + await page.keyboard.press('Escape') + const stops = await page.locator(`${Q} [data-seg][tabindex="0"]`).count() + check('整份报告(含表格)仍只占一个 Tab 位', stops === 1, String(stops)) +}) + +await section('caliber', '口径卡来源和升版处置', async () => { + await openQ(page, '45,678.5元', { wait: '[data-ev-caliber-from]' }) + const from = await panel(page).locator('[data-ev-caliber-from]').innerText().catch(() => '') + check('指标步骤写「口径卡「周报口径」v3,来自「销售周报」v5」', from.includes('口径卡「周报口径」v3,来自「销售周报」v5'), from) + const up = await panel(page).locator('[data-ev-caliber-upgrade]').innerText().catch(() => '') + check('有升版处置时写「上游已有 v6,按『…』处置」', up.includes('上游已有 v6') && up.includes('并排双印新旧口径') && up.includes('处置'), up) + await page.keyboard.press('Escape') + // 方案第 5 节的写法(caliber_from)也认;没给工作流名、没给 policy_label 时不出 undefined + const bare = await probeQ((id, body) => ({ ...body, chain: (body.chain ?? []).map((st) => (st.step === 'metric' + ? { ...st, source: undefined, caliber_from: { workflow_id: 'wf-demo-weekly', workflow_version: 5 }, + caliber_upgrade: { policy: 'recompute', latest: 6 } } : st)) })) + await openQ(bare.page, '45,678.5元', { wait: '[data-ev-caliber-from]' }) + const t = await bare.page.locator('[data-evidence-panel] [data-ev-metric]').innerText() + check('没给工作流名:退回工作流 id,不出 undefined', t.includes('来自') && t.includes('wf-demo') && !/undefined|null/.test(t), t.replace(/\s+/g, ' ').slice(0, 160)) + check('没给 policy_label:按策略代码说人话', t.includes('用新口径回算历史'), t.replace(/\s+/g, ' ').slice(0, 200)) + await bare.ctx.close() + const none = await probeQ((id, body) => ({ ...body, chain: (body.chain ?? []).map((st) => (st.step === 'metric' + ? { ...st, source: null, caliber_from: undefined, caliber_upgrade: null } : st)) })) + await openQ(none.page, '45,678.5元', { wait: '[data-ev-expression]' }) + check('本地定义的口径卡、没有升版:两行都不出', await none.page.locator('[data-evidence-panel] [data-ev-caliber-from], [data-evidence-panel] [data-ev-caliber-upgrade]').count() === 0) + await none.ctx.close() +}) + +await section('q-narrow', '查询步骤在 360px:栏内展开、底部抽屉都不横向滚动;减少动效', async () => { + const overflow = () => page.evaluate((sel) => { + const el = document.querySelector(sel) + return { sw: el.scrollWidth, cw: el.clientWidth } + }, QN) + await openQ(page, '1,288', { box: QN }) + const inline = await page.locator(`${QN} [data-evidence-panel="inline"] [data-ev-query]`).count() + const o = await overflow() + check('窄栏里查询步骤在栏内展开', inline === 1) + check('窄栏里没有横向滚动(表格自己在框里滚)', o.sw <= o.cw, JSON.stringify(o)) + const hl = await page.locator(`${QN} td[data-highlight="cell"]`).evaluate((td) => { + const box = td.closest('.overflow-x-auto') + const a = td.getBoundingClientRect() + const b = box.getBoundingClientRect() + return a.left >= b.left - 1 && a.right <= b.right + 1 + }).catch(() => false) + check('被引用的格滚到看得见的位置', hl) + await page.locator(`${QN} [data-evidence-panel] button`, { hasText: '回到正文' }).click() + + const small = await open('/ui-harness.html?evidence=1', { w: 360, h: 780 }) + await small.page.locator(`${Q} [data-seg="${qseg('1,288')}"]`).click() + await small.page.waitForSelector('[data-evidence-panel] [data-ev-query]', { timeout: 4000 }).catch(() => {}) + const s = await small.page.evaluate(() => ({ sw: document.documentElement.scrollWidth, vw: innerWidth })) + check('360px 的屏幕:底部抽屉里有查询步骤', await small.page.locator('[data-evidence-panel="drawer"] [data-ev-query]').count() === 1) + check('360px 的屏幕:整页没有横向滚动', s.sw <= s.vw, JSON.stringify(s)) + if (SHOTS) { + for (const theme of ['dark', 'light']) { + await small.page.evaluate((t) => document.documentElement.setAttribute('data-theme', t), theme) + await small.page.waitForTimeout(250) + await small.page.screenshot({ path: `${SHOTS}/evidence-query-360-${theme}.png` }) + } + } + check('没有运行时报错(360px)', small.errors.length === 0, small.errors.join(' | ')) + await small.ctx.close() + + const r = await open('/ui-harness.html?evidence=1', { reduced: true }) + await r.page.locator(`${Q} [data-seg="${qseg('8.7%')}"]`).click() + await r.page.waitForSelector('[data-evidence-panel] [data-ev-sources]', { timeout: 4000 }).catch(() => {}) + await r.page.locator(`[data-evidence-panel] button[data-ev-input="cell(nodes.pull, 0, 'gmv')"]`).click().catch(() => {}) + await r.page.waitForTimeout(100) + const moving = await r.page.evaluate(() => [...(document.querySelector('[data-evidence-panel]')?.getAnimations({ subtree: true }) ?? [])] + .filter((a) => !(a instanceof CSSTransition && Number(a.effect?.getTiming().duration) <= 0.01)).length) + check('减少动效:面板和跳转都没有动画', moving === 0, String(moving)) + await r.ctx.close() +}) + +await section('q-tolerant', '接口缺字段、取不到:降级显示,不白屏', async () => { + const miss = await probeQ(() => 404) + await openQ(miss.page, '1,288', { wait: '[data-ev-query-missing]' }) + const t = await miss.page.locator('[data-evidence-panel]').innerText() + check('片段接口取不到:只显示文档里记的出处(Q3 · db_query__shop · 12 行),照实说取不到', t.includes('Q3') + && t.includes('db_query__shop') && t.includes('12 行') && t.includes('没取到'), t.replace(/\s+/g, ' ').slice(0, 200)) + check('……没有假装高亮了什么', await miss.page.locator('[data-evidence-panel] [data-highlight]').count() === 0) + check('……没有运行时报错', miss.errors.length === 0, miss.errors.join(' | ')) + await miss.ctx.close() + const thin = await probeQ((id, body) => ({ ...body, redacted: undefined, + chain: (body.chain ?? []).map((st) => (st.step === 'query' ? { step: 'query', alias: st.alias, columns: st.columns, rows: st.rows } : st)) })) + await openQ(thin.page, '1,288') + const part = thin.page.locator('[data-evidence-panel] [data-ev-query]') + check('查询步骤只有列和行(老形状):照样画表,不高亮、不写窗口位置', await part.locator('tbody tr').count() === 5 + && await part.locator('[data-highlight]').count() === 0 && !(await part.innerText()).includes('undefined')) + check('……没有工件 id 就不给「打开完整快照」', await part.locator('[data-ev-snapshot]').count() === 0) + const inRange = await probeQ((id, body) => ({ ...body, chain: (body.chain ?? []).map((st) => (st.step === 'query' + ? { ...st, highlight: { rows: [2], cols: ['amount'] } } : st)) })) + await openQ(inRange.page, '1,288') + check('highlight 的行号落在窗口里时按窗口内下标认(row_offset 之前的号)', (await inRange.page.locator('[data-evidence-panel] td[data-highlight="cell"]').innerText().catch(() => '')) === '1288') + check('没有 mask_note 就不出那一句', await inRange.page.locator('[data-evidence-panel] [data-ev-query-mask-note]').count() === 0) + check('没有运行时报错(缺字段)', thin.errors.length === 0 && inRange.errors.length === 0, [...thin.errors, ...inRange.errors].join(' | ')) + await thin.ctx.close() + await inRange.ctx.close() + + // 查询之后数据源改名或删掉了(E2W-6):后端按查询当时记下的遮罩处理,行照样给,另带一句 mask_note。 + // 这句写在窗口说明那一行、琥珀色:现在的数据源设置已经管不到这份快照了 + const MASK_NOTE = '数据源「shop」现在找不到了(改名或删掉了),按查询当时记下的遮罩处理' + const gone = await probeQ((id, body) => ({ ...body, chain: (body.chain ?? []).map((st) => (st.step === 'query' + ? { ...st, mask_note: MASK_NOTE } : st)) })) + await openQ(gone.page, '1,288') + const goneNote = gone.page.locator('[data-evidence-panel] [data-ev-query] [data-ev-query-mask-note]') + const [goneText, goneColor, amber, sameLine] = await Promise.all([ + goneNote.innerText().catch(() => ''), + goneNote.evaluate((el) => getComputedStyle(el).color).catch(() => ''), + tokenColor(gone.page, 'color', 'var(--st-waiting)'), + goneNote.evaluate((el) => el.parentElement === el.closest('[data-ev-query]')?.querySelector('[data-ev-window]')?.parentElement).catch(() => false), + ]) + check('数据源找不到了:mask_note 原话写出来', goneText === MASK_NOTE, goneText) + check('……琥珀色(--st-waiting),和窗口说明同一行', goneColor === amber && sameLine, `${goneColor} / ${amber} · 同一行 ${sameLine}`) + check('……行照样画、按记下的遮罩照样写「已遮罩」', await gone.page.locator('[data-evidence-panel] [data-ev-query] tbody tr').count() === 5 + && await gone.page.locator('[data-evidence-panel] [data-ev-query] td[data-masked]').count() === 5) + if (SHOTS) { + for (const theme of ['dark', 'light']) { + await gone.page.evaluate((t) => document.documentElement.setAttribute('data-theme', t), theme) + await gone.page.waitForTimeout(200) + await gone.page.locator('[data-evidence-panel]').screenshot({ path: `${SHOTS}/evidence-mask-note-${theme}.png` }).catch(() => {}) + } + } + check('没有运行时报错(mask_note)', gone.errors.length === 0, gone.errors.join(' | ')) + await gone.ctx.close() + + // 窗口被截到 50 行(window_truncated):被引用的另一格(第 41 行)不在窗口里,窗口里那一格照样标 + const cut = await probeQ((id, body) => ({ ...body, chain: (body.chain ?? []).map((st) => (st.step === 'query' + ? { ...st, window_truncated: true, total_rows: 60, + highlight: { rows: [5, 40], cols: ['amount'], cells: [[5, 'amount'], [40, 'amount']] } } : st)) })) + await openQ(cut.page, '1,288') + const cutCells = await cut.page.locator('[data-evidence-panel] td[data-highlight="cell"]').allInnerTexts() + check('窗口截断、有的被引用行不在窗口里:窗口里的那一格照样高亮,不是一格都不标', cutCells.join(',') === '1288' + && await cut.page.locator('[data-evidence-panel] tr[data-highlight="row"]').count() === 1, cutCells.join(',') || '一格都没标') + const cutRows = await probeQ((id, body) => ({ ...body, chain: (body.chain ?? []).map((st) => (st.step === 'query' + ? { ...st, window_truncated: true, highlight: { rows: [5, 40], cols: ['amount'] } } : st)) })) + await openQ(cutRows.page, '1,288') + check('……只给 rows × cols 时同样只标窗口里的那一行', (await cutRows.page.locator('[data-evidence-panel] td[data-highlight="cell"]').allInnerTexts()).join(',') === '1288') + await cut.ctx.close() + await cutRows.ctx.close() + + // 解析不了的单元格引用(行越界):后端不给链。上面已经说了原因,不能再摆一块「查询的行这一次没取到」 + const REASON = 'Q3 要第 99 行,但这份查询结果只有 12 行(从 0 数)' + const broken = (seg) => ({ ...seg, text: '⟦?v:Q3.r99.amount⟧', state: 'none', issue: 'unresolved_ref', ref: 'v:Q3.r99.amount', + cite: { ref: 'Q3.r99.amount', alias: 'Q3', kind: 'cell', role: 'value', status: 'unresolved', reason: REASON } }) + const target = qseg('1,288') + const bad = await probeQ((id, body) => (id === target + ? { ...body, segment: broken(body.segment), chain: [], note: `引用解析不了:${REASON}` } : body), { + patchQuery: (f) => { + for (const b of f.doc.blocks) for (const u of b.units ?? []) u.segments = (u.segments ?? []).map((sg) => (sg.id === target ? broken(sg) : sg)) + return f + }, + }) + await bad.page.locator(`${Q} [data-seg="${target}"]`).click() + await bad.page.waitForSelector('[data-evidence-panel] [data-ev-reason]', { timeout: 4000 }).catch(() => {}) + await bad.page.waitForTimeout(150) + const said = await bad.page.locator('[data-evidence-panel]').innerText().catch(() => '') + check('解析不了的单元格引用:说清原因(行越界)', said.includes(REASON), said.replace(/\s+/g, ' ').slice(0, 200)) + check('……不再摆「查询 Q3 · 这一次没取到」,那像是接口出了错', await bad.page.locator('[data-evidence-panel] [data-ev-query-missing]').count() === 0 + && !said.includes('没取到'), said.replace(/\s+/g, ' ').slice(0, 200)) + check('没有运行时报错(截断、解析不了)', cut.errors.length === 0 && bad.errors.length === 0, [...cut.errors, ...bad.errors].join(' | ')) + await bad.ctx.close() +}) + await section('pinned', '没有 _evidence 的旧回答:和 Markdown.tsx 改动前逐字一样', async () => { // markdown-pinned.json 里的 html 是改动前(HEAD b418c0d)的 Markdown.tsx 渲染的。重新生成:把 // `git show <旧提交>:frontend/src/run/Markdown.tsx` 临时放进 src/run/,另起一个页面逐个渲染 @@ -678,6 +1136,179 @@ await section('studio', '报告撰写节点:画布认得、检查器能配(R await ctx.close() }) +await section('config', '配置项:agent 的 cite_fields、code 的证据角色、口径卡钉住上游(caliber_from + upgrade_policy)', async () => { + // 假的工作流和上游,读写都在浏览器层拦下:GET 回假的,写一律 409,不落库 + const node = (id, type, x, label, config = {}) => ({ id, type, position: { x, y: 80 }, data: { label, config } }) + const graph = { + nodes: [ + node('in', 'input', 0, '入口', { fields: [{ name: 'week', required: true }] }), + node('fetch', 'agent', 240, '查数', { prompt: '查本周销售额', tools: ['db_query__shop'] }), + node('calc', 'code', 480, '换算', { language: 'python', code: 'print(1)' }), + node('caliber', 'metrics', 720, '周报口径', { caliber: '周报口径', caliber_version: 'v2', + metrics: [{ id: 'gmv', name: '销售额', unit: '元', expression: 'vars.kpi.gmv' }] }), + node('sub', 'subgraph', 960, '方法卡', { workflow_id: 'fx-weekly', input: {} }), + ], + edges: [['in', 'fetch'], ['fetch', 'calc'], ['calc', 'caliber'], ['caliber', 'sub']].map(([source, target]) => ({ id: `${source}-${target}`, source, target })), + } + const stamp = { tags: [], is_template: false, status: 'draft', published_version: null, run_count: 0, + created_at: '2026-09-26T00:00:00Z', updated_at: '2026-09-26T00:00:00Z', description: '' } + const wf = { id: 'fx-config', name: '配置项检查', graph, version: 1, ...stamp } + const upstream = { id: 'fx-weekly', name: '销售周报', graph: { nodes: [], edges: [] }, version: 6, ...stamp, status: 'published', published_version: 6 } + const pinnedGraph = { nodes: [ + node('caliber', 'metrics', 0, '周报口径', { caliber: '周报口径', caliber_version: 'v3', + metrics: [{ id: 'gmv', name: '销售额', expression: 'vars.kpi.gmv' }, { id: 'aov', name: '客单价', expression: 'vars.kpi.gmv / vars.kpi.order_cnt' }] }), + node('write', 'report', 240, '写周报', {}), + ], edges: [] } + const versions = [5, 6].map((v) => ({ id: `v${v}`, version: v, note: v === 6 ? '改了客单价口径' : '', published: v === 6 })) + const ctx = await browser.newContext({ viewport: { width: 1440, height: 900 } }) + const p = await ctx.newPage() + p.setDefaultTimeout(8000) + const errors = [] + p.on('pageerror', (e) => errors.push(e.message)) + const json = (route, body, status = 200) => route.fulfill({ status, contentType: 'application/json', body: JSON.stringify(body) }) + await p.route(/\/api\/workflows(\?.*)?$/, (r) => (r.request().method() === 'GET' ? json(r, [wf, upstream]) : json(r, { detail: '检查脚本不写库' }, 409))) + await p.route(/\/api\/workflows\/fx-config(\/.*)?(\?.*)?$/, (r) => + (r.request().method() === 'GET' && !new URL(r.request().url()).pathname.endsWith('/versions') ? json(r, wf) : json(r, []))) + await p.route(/\/api\/workflows\/fx-weekly\/versions(\/\d+)?$/, (r) => { + const m = new URL(r.request().url()).pathname.match(/\/versions\/(\d+)$/) + return m ? json(r, { ...versions.find((v) => v.version === Number(m[1])), workflow_id: 'fx-weekly', graph: pinnedGraph }) : json(r, versions) + }) + await p.route(/\/api\/conversations(\/.*)?(\?.*)?$/, (r) => + (r.request().method() === 'GET' ? json(r, []) : json(r, { id: 'fx-conv', kind: 'canvas', title: '', turns: [] }))) + await p.route(/\/api\/runs(\/.*)?(\?.*)?$/, (r) => (r.request().method() === 'GET' ? r.continue() : json(r, { detail: '不发起运行' }, 409))) + await p.goto(`${WEB}/studio/fx-config`, { waitUntil: 'networkidle' }) + await p.waitForFunction(() => window.__studio?.getState().workflow?.id === 'fx-config', null, { timeout: 15000 }) + await p.waitForTimeout(500) + const cfg = (id) => p.evaluate((id) => window.__studio.getState().nodes.find((n) => n.id === id)?.data.config, id) + const advanced = async () => { + const t = p.getByRole('button', { name: /高级选项/ }) + if ((await t.getAttribute('aria-expanded')) !== 'true') await t.click() + } + + await p.evaluate(() => window.__studio.getState().select('fetch')) + await advanced() + const cite = p.locator('[data-field="cite_fields"]') + check('agent 有「按出处核对字段」开关,没写 Schema 时用不了并说清差什么', await cite.locator('input[type="checkbox"]').isDisabled() + && (await cite.innerText()).includes('先在上面写「结构化输出 Schema」'), (await cite.innerText()).replace(/\s+/g, ' ').slice(0, 120)) + check('……说明会多一次抽取调用', (await cite.innerText()).includes('多一次抽取调用')) + check('output_schema 的说明写明要开 cite_fields 才生效', (await p.locator('[data-field="output_schema"]').innerText()).includes('按出处核对字段')) + await p.evaluate(() => { + const s = window.__studio.getState() + const n = s.nodes.find((x) => x.id === 'fetch') + s.updateNode('fetch', { config: { ...n.data.config, output_schema: { type: 'object', properties: { gmv: { type: 'number' } } } } }) + }) + await p.waitForTimeout(150) + check('写了 Schema:开关可用', !(await cite.locator('input[type="checkbox"]').isDisabled()) && await cite.locator('[id$="-off"]').count() === 0) + await cite.locator('input[type="checkbox"]').check() + check('……打开后写进 config.cite_fields', (await cfg('fetch'))?.cite_fields === true) + if (SHOTS) { + for (const theme of ['dark', 'light']) { + await p.evaluate((t) => document.documentElement.setAttribute('data-theme', t), theme) + await p.waitForTimeout(200) + await p.screenshot({ path: `${SHOTS}/evidence-config-agent-${theme}.png` }) + } + } + // 开着开关又把 Schema 清掉:不能锁死在「开」上,得还能关 + await p.evaluate(() => { + const s = window.__studio.getState() + const { output_schema: _drop, ...rest } = s.nodes.find((x) => x.id === 'fetch').data.config + s.updateNode('fetch', { config: rest }) + }) + await p.waitForTimeout(150) + check('开着又清掉 Schema:开关不锁死,还能关', !(await cite.locator('input[type="checkbox"]').isDisabled()) + && await cite.getAttribute('data-disabled') === null && await cite.locator('[id$="-off"]').count() === 0) + await cite.locator('input[type="checkbox"]').uncheck({ timeout: 2000 }).catch(() => {}) + check('……关掉后 config.cite_fields 不再为真;关掉之后才锁上', !(await cfg('fetch'))?.cite_fields + && await cite.locator('input[type="checkbox"]').isDisabled()) + + await p.evaluate(() => window.__studio.getState().select('calc')) + await advanced() + const role = p.locator('[data-field="evidence_role"]') + const opts = await role.locator('option').allInnerTexts() + check('code 有「证据角色」下拉:计算(默认)/ 取数', opts.join('|') === '计算(默认)|取数', opts.join('|')) + check('……说明报告不能直接引用代码节点的产出', (await role.innerText()).includes('报告不能直接引用代码节点的产出')) + await role.locator('select').selectOption('source') + check('……选「取数」写进 config.evidence_role', (await cfg('calc'))?.evidence_role === 'source') + + await p.evaluate(() => window.__studio.getState().select('caliber')) + const from = p.locator('[data-field="caliber_from"]') + check('口径卡默认「在这里定义」,本地的名称、版本、指标都在', await from.locator('[data-caliber-from="local"]').count() === 1 + && await p.locator('[data-field="metrics"]').count() === 1 && await p.locator('[data-field="upgrade_policy"]').count() === 0) + await from.getByRole('radio', { name: '钉住别的工作流里的口径卡' }).click() + await p.waitForTimeout(150) + check('切到钉住:本地定义收起(后端也说它们不生效),升版处置出来', await p.locator('[data-field="metrics"]').count() === 0 + && await p.locator('[data-field="caliber"]').count() === 0 && await p.locator('[data-field="upgrade_policy"]').count() === 1) + const wfOpts = await from.locator('select').first().locator('option').allInnerTexts() + check('工作流下拉复用目录,不列自己', wfOpts.some((o) => o.includes('销售周报')) && !wfOpts.some((o) => o.includes('配置项检查')), wfOpts.join('|')) + await from.locator('select').first().selectOption('fx-weekly') + await p.waitForSelector('[data-field="caliber_from"] select[id$="-version"] option[value="5"]', { timeout: 4000 }).catch(() => {}) + await from.locator('select[id$="-version"]').selectOption('5') + await p.waitForSelector('[data-field="caliber_from"] [data-caliber-preview]', { timeout: 4000 }).catch(() => {}) + const c = await cfg('caliber') + check('选了工作流和版本:那一版只有一张口径卡时直接钉上,写成 {workflow_id, workflow_version(数), node_id}', + JSON.stringify(c?.caliber_from) === JSON.stringify({ workflow_id: 'fx-weekly', workflow_version: 5, node_id: 'caliber' }), JSON.stringify(c?.caliber_from)) + check('……本地的 metrics、caliber 不留在配置里', !('metrics' in (c ?? {})) && !('caliber' in (c ?? {})), JSON.stringify(Object.keys(c ?? {}))) + const preview = await from.locator('[data-caliber-preview]').innerText().catch(() => '') + check('……预览钉住的那张卡:口径、版本、几个指标', preview.includes('周报口径') && preview.includes('v3') && preview.includes('2 个指标') + && preview.includes('aov'), preview.replace(/\s+/g, ' ')) + const newer = await from.locator('[data-caliber-newer]').innerText().catch(() => '') + check('上游已有更新的版本:提醒正式运行前要声明升版处置', newer.includes('上游已有 v6') && newer.includes('升版处置'), newer) + const pol = await p.locator('[data-field="upgrade_policy"] option').allInnerTexts() + check('升版处置三选一,和子工作流同一套说法', pol.length === 4 && pol[0].includes('挡住正式运行') && pol.some((o) => o.includes('并排双印新旧口径')), pol.join('|')) + await p.locator('[data-field="upgrade_policy"] select').selectOption('dual') + check('……写进 config.upgrade_policy', (await cfg('caliber'))?.upgrade_policy === 'dual') + const card = await p.locator('.react-flow__node[data-id="caliber"]').innerText().catch(() => '') + check('画布卡片写「钉住上游口径卡 v5」,不再写「0 个指标」', card.includes('钉住上游口径卡 v5') && !card.includes('0 个指标'), card.replace(/\s+/g, ' ').slice(0, 120)) + if (SHOTS) { + for (const theme of ['dark', 'light']) { + await p.evaluate((t) => document.documentElement.setAttribute('data-theme', t), theme) + await p.waitForTimeout(200) + await p.screenshot({ path: `${SHOTS}/evidence-config-caliber-${theme}.png` }) + } + } + await from.getByRole('radio', { name: '在这里定义' }).click() + await p.waitForTimeout(150) + const back = await cfg('caliber') + check('切回「在这里定义」:去掉 caliber_from 和 upgrade_policy,给回一条空指标', !('caliber_from' in back) && !('upgrade_policy' in back) + && Array.isArray(back.metrics) && back.metrics.length === 1 && await p.locator('[data-field="metrics"]').count() === 1, JSON.stringify(back)) + + // 子工作流钉住版本后的升版处置(E2W-7):和口径卡的是同一个下拉、同一套文案 + await p.evaluate(() => window.__studio.getState().select('sub')) + await p.waitForTimeout(150) + check('子工作流没钉版本(跟随最新):不出升版处置', await p.locator('[data-field="upgrade_policy"]').count() === 0) + const subVersion = p.locator('[data-field="workflow_id"] select[id$="-version"]') + await p.waitForSelector('[data-field="workflow_id"] select[id$="-version"] option[value="5"]', { timeout: 4000 }).catch(() => {}) + await subVersion.selectOption('5') + await p.waitForSelector('[data-field="upgrade_policy"]', { timeout: 3000 }).catch(() => {}) + const subPol = await p.locator('[data-field="upgrade_policy"] option').allInnerTexts() + check('钉了 v5:升版处置出来,选项和口径卡的那个一字不差', subPol.length === 4 && JSON.stringify(subPol) === JSON.stringify(pol), subPol.join('|')) + check('……紧跟在「工作流」后面', await p.evaluate(() => { + const fields = [...document.querySelectorAll('[data-field]')].map((el) => el.getAttribute('data-field')) + return fields[fields.indexOf('workflow_id') + 1] === 'upgrade_policy' + })) + const subHelp = await p.locator('[data-field="upgrade_policy"]').innerText() + check('……说明换成子工作流这一侧:和钉住别处的口径卡同一套规则', subHelp.includes('和钉住别处的口径卡是同一套规则') + && !subHelp.includes('和子工作流的升版处置'), subHelp.replace(/\s+/g, ' ').slice(0, 120)) + const subNewer = await p.locator('[data-field="workflow_id"] [data-subgraph-newer]').innerText().catch(() => '') + check('上游已有 v6:提醒要声明升版处置,和口径卡同一句', subNewer === newer, subNewer) + await p.locator('[data-field="upgrade_policy"] select').selectOption('recompute') + check('……写进子工作流的 config.upgrade_policy', (await cfg('sub'))?.upgrade_policy === 'recompute') + if (SHOTS) { + for (const theme of ['dark', 'light']) { + await p.evaluate((t) => document.documentElement.setAttribute('data-theme', t), theme) + await p.waitForTimeout(200) + await p.screenshot({ path: `${SHOTS}/evidence-config-subgraph-${theme}.png` }) + } + } + await subVersion.selectOption('') + await p.waitForTimeout(150) + const unpinned = await cfg('sub') + check('改回跟随最新:upgrade_policy 一起拿掉、下拉收起', !('upgrade_policy' in unpinned) && !('workflow_version' in unpinned) + && await p.locator('[data-field="upgrade_policy"]').count() === 0, JSON.stringify(unpinned)) + check('没有运行时报错(配置项)', errors.length === 0, errors.join(' | ')) + await ctx.close() +}) + if (SHOTS) { for (const theme of ['dark', 'light']) { await page.evaluate((t) => document.documentElement.setAttribute('data-theme', t), theme) diff --git a/scripts/check-manage.mjs b/scripts/check-manage.mjs index e179b799..db4425ee 100644 --- a/scripts/check-manage.mjs +++ b/scripts/check-manage.mjs @@ -915,6 +915,110 @@ await section('数据 · 数据库', async () => { await close() }) +await section('数据 · 遮罩列(证据面板的 mask_columns)', async () => { + // 假库:两张表,phone 已经遮上了。挑列从已探查的结构里读;写请求全部在浏览器里接住 + const base = { + id: 'check-manage-mask', name: 'zz_mask', kind: 'postgres', host: '10.0.0.7', port: null, database: 'shop', + username: 'reader', options: { schema: 'sales', mask_columns: ['phone'] }, readonly: true, description: '检查脚本的假库', + enabled: true, password_masked: '', has_password: false, table_count: 2, schema_synced_at: ago(3600_000), cached_schema: 'sales', + schema_error: '', available_schemas: [], tools: ['db_query__zz_mask'], + last_checked_at: null, last_check_ok: null, last_latency_ms: null, last_error: null, + } + const upload = { + ...base, id: 'check-manage-upmask', name: 'zz_upload_mask', kind: 'sqlite', host: null, username: null, + database: '/tmp/agentlab/uploads/tables/zz_upload_mask.db', options: { query_timeout_s: '30' }, table_count: 1, + tools: ['db_query__zz_upload_mask'], + } + let cur = base + let up = upload + const columns = { + 'sales.orders': ['order_id', 'amount', 'phone'], + 'sales.customers': ['id', 'customer_id', 'email', 'phone'], + orders_upload: ['order_id', 'phone'], + } + const tableJson = (t) => ({ table: t, found: true, kind: 'table', comment: null, detail: '', + columns: (columns[t] ?? []).map((name) => ({ name, type: 'TEXT' })) }) + const { page, sent, errors, natives, close } = await open('/data/databases', { + handlers: [ + [/^GET \/datasources$/, (route) => json([...sources, cur, up])(route)], + [/^GET \/datasources\/check-manage-mask\/schema$/, (route, { url }) => json(url.searchParams.get('table') + ? tableJson(url.searchParams.get('table')) : { tables: ['sales.orders', 'sales.customers'], summary: '', synced_at: ago(0) })(route)], + [/^GET \/datasources\/check-manage-upmask\/schema$/, (route, { url }) => json(url.searchParams.get('table') + ? tableJson(url.searchParams.get('table')) : { tables: ['orders_upload'], summary: '', synced_at: ago(0) })(route)], + [/^POST \/datasources\/check-manage-mask\/test$/, delayed(200, { ok: true, elapsed_ms: 42, url: 'x' })], + [/^PATCH \/datasources\/check-manage-mask$/, (route, { body }) => { cur = { ...cur, ...body }; return json(cur)(route) }], + [/^PATCH \/datasources\/check-manage-upmask$/, (route, { body }) => { up = { ...up, ...body }; return json(up)(route) }], + ], + }) + const card = page.locator(`[data-source="${base.name}"]`) + check('卡片上写着遮罩了几列', (await card.locator('[data-mask-columns]').innerText().catch(() => '')).includes('遮罩 1 列') + && await card.locator('[data-mask-columns]').getAttribute('data-mask-columns') === 'phone') + await card.getByRole('button', { name: /测连接/ }).click() + await until(() => card.locator('[data-health]').first().innerText().then((t) => /已连通/.test(t)), 5000) + await card.getByRole('button', { name: '编辑' }).click() + const ed = dialog(page) + const field = ed.locator('[data-field="mask_columns"]') + check('编辑框里有「遮罩的列」,已遮的 phone 是一个标签', await field.count() === 1 + && await field.locator('[data-mask-chip="phone"]').count() === 1) + const boundary = await field.locator('[data-mask-boundary]').innerText().catch(() => '') + check('写明遮罩只减少暴露、不是安全边界', boundary.includes('遮罩只减少暴露,不是安全边界'), boundary) + check('高级连接参数里不再另摆一个遮罩的文本框(只有这一处)', await ed.locator('#ds-adv-mask_columns').count() === 0) + const before = sent.filter((r) => r.key === 'GET /datasources/check-manage-mask/schema').length + await ed.locator('#ds-mask-columns').focus() + const listed = await until(async () => { + const v = await ed.locator('#ds-mask-columns-list option').evaluateAll((els) => els.map((e) => e.value)) + return v.length ? v : null + }, 5000) + const reads = sent.filter((r) => r.key === 'GET /datasources/check-manage-mask/schema').length - before + check('聚焦输入框才去读已探查的结构,候选是各表的列(已遮的 phone 不再列)', !!listed && listed.includes('email') + && listed.includes('amount') && !listed.includes('phone') && reads === 3, `${JSON.stringify(listed)} · ${reads} 个请求`) + const hint = await field.innerText() + check('……说清是从已探查的几张表里挑的', hint.includes('从已探查的 2 张表里挑'), hint.replace(/\s+/g, ' ').slice(0, 160)) + await ed.locator('#ds-mask-columns').fill('email') + await ed.locator('#ds-mask-columns').press('Enter') + await ed.locator('#ds-mask-columns').pressSequentially('id_card') + check('逐字敲 id_card 途中碰上列名 id 不会提前加成标签', await field.locator('[data-mask-chip="id"]').count() === 0 + && (await ed.locator('#ds-mask-columns').inputValue()) === 'id_card') + await ed.locator('#ds-mask-columns').press('Enter') + await ed.locator('#ds-mask-columns').fill('EMAIL') + await ed.locator('#ds-mask-columns').press('Enter') + await field.getByRole('button', { name: '不再遮罩 phone' }).click() + const chips = await field.locator('[data-mask-chip]').evaluateAll((els) => els.map((e) => e.getAttribute('data-mask-chip'))) + check('回车加标签、× 去掉、大小写不同的同名列不重复加', chips.join(',') === 'email,id_card', chips.join(',')) + if (SHOTS) await shot(page, 'datasource-mask-columns') + await ed.getByRole('button', { name: '保存' }).click() + await page.waitForTimeout(500) + const saved = sent.filter((r) => r.key === 'PATCH /datasources/check-manage-mask').at(-1) + check('保存:options.mask_columns 是列名列表,schema 原样带上', JSON.stringify(saved?.body?.options?.mask_columns) === '["email","id_card"]' + && saved?.body?.options?.schema === 'sales', JSON.stringify(saved?.body?.options ?? {})) + check('……改遮罩列不算改连接:刚测的结果还作数', /已连通/.test(await card.locator('[data-health]').first().innerText())) + check('……卡片跟着变成遮罩 2 列', (await card.locator('[data-mask-columns]').innerText().catch(() => '')).includes('遮罩 2 列')) + await card.getByRole('button', { name: '编辑' }).click() + await dialog(page).locator('[data-field="mask_columns"]').getByRole('button', { name: '不再遮罩 email' }).click() + await dialog(page).locator('[data-field="mask_columns"]').getByRole('button', { name: '不再遮罩 id_card' }).click() + await dialog(page).getByRole('button', { name: '保存' }).click() + await page.waitForTimeout(400) + const cleared = sent.filter((r) => r.key === 'PATCH /datasources/check-manage-mask').at(-1) + check('全部去掉:options 里不留 mask_columns 这个键', !!cleared && !('mask_columns' in (cleared.body?.options ?? {})) + && await card.locator('[data-mask-columns]').count() === 0, JSON.stringify(cleared?.body?.options ?? {})) + + await goto(page, '/data/tables') + const uc = page.locator(`[data-source="${upload.name}"]`) + await uc.locator('[data-edit-mask]').click() + const ud = dialog(page) + await ud.locator('#ds-mask-columns').fill('phone') + await ud.locator('#ds-mask-columns').press('Enter') + await ud.getByRole('button', { name: '保存' }).click() + await page.waitForTimeout(400) + const upSaved = sent.filter((r) => r.key === 'PATCH /datasources/check-manage-upmask').at(-1) + check('传上来的表没有编辑框:卡片上「设遮罩列」单独改,其余 options 原样带上', + JSON.stringify(upSaved?.body) === JSON.stringify({ options: { query_timeout_s: '30', mask_columns: ['phone'] } }), JSON.stringify(upSaved?.body ?? {})) + check('……卡片写着遮罩 1 列', (await uc.locator('[data-mask-columns]').innerText().catch(() => '')).includes('遮罩 1 列')) + check('没有原生对话框', natives.length === 0, natives.join(' | ')) + check('没有运行时报错', errors.length === 0, errors[0] ?? '') + await close() +}) + await section('数据 · 表格', async () => { const fake = { source: { diff --git a/scripts/check-publish.mjs b/scripts/check-publish.mjs new file mode 100644 index 00000000..c0ce2162 --- /dev/null +++ b/scripts/check-publish.mjs @@ -0,0 +1,825 @@ +// 发布前检查与自动修复的检查:发布弹窗打开就列问题、逐条修(auto / choice / assist)、 +// 先预览再「应用并重新检查」、问题面板里的「发布前检查」、老后端(两个接口 404)照旧。 +// +// 不写库:工作流、保存、发布、publish-check、autofix 全在浏览器里用 page.route 伪造。 +// 伪造的检查接口按请求里的图现算(下面的 lint),所以修完、存完、再查,结论会真的变; +// 保存的载荷、autofix 的请求体都拦下来核对。其余写请求一律 409 并记账,检查结束时必须是空的。 +// +// 跑之前前端得起着(./scripts/dev.sh),默认连 5273。对别的实例(比如一份沙箱拷贝)跑时 +// 带上地址:AGENTLAB_WEB=http://localhost:<前端端口> node scripts/check-publish.mjs +// PUBLISH_ONLY=choice,老后端 只跑段名里含这些字的段 +// PUBLISH_SHOTS=<目录> 几处界面各截一张亮、暗(含 360px) +import { mkdirSync } from 'node:fs' +import { chromium } from '../frontend/node_modules/playwright-core/index.mjs' + +const WEB = process.env.AGENTLAB_WEB ?? 'http://localhost:5273' +const CHROME = process.env.CHROME_PATH ?? '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome' +const ONLY = (process.env.PUBLISH_ONLY ?? '').split(',').filter(Boolean) +const SHOTS = process.env.PUBLISH_SHOTS ?? '' +if (SHOTS) mkdirSync(SHOTS, { recursive: true }) + +let failed = 0 +const check = (name, cond, detail = '') => { + const d = String(detail ?? '').replace(/\s+/g, ' ').slice(0, 160) + console.log(` ${cond ? '✓' : '✗'} ${name}${d ? ` — ${d}` : ''}`) + if (!cond) failed++ +} +const opened = new Set() +async function section(name, fn) { + if (ONLY.length && !ONLY.some((k) => name.includes(k))) return + console.log(`=== ${name} ===`) + try { + await fn() + } catch (e) { + check(`${name} 中途出错`, false, String(e?.message ?? e).split('\n')[0]) + } finally { + for (const c of opened) await c.close().catch(() => {}) + opened.clear() + } +} + +// ---------------------------------------------------------------- 夹具(通用示例名) + +const node = (id, type, x, label, config = {}) => ({ id, type, position: { x, y: 120 }, data: { label, config } }) +const chain = (...ids) => ids.slice(1).map((t, i) => ({ id: `e_${ids[i]}_${t}`, source: ids[i], target: t, sourceHandle: null, label: '' })) + +/** 受管模板的样子:审批全放行、子工作流没钉版本、契约缺 metrics_from 和 required、有协作团队、契约不 strict */ +const HARD = { + nodes: [ + node('in', 'input', 0, '入口', { fields: [{ name: 'week', required: true }] }), + node('fetch', 'agent', 240, '取数', { prompt: '查 orders 本周的 gmv', tools: ['db_query__shop'], approval: 'never' }), + node('sub', 'subgraph', 480, '方法卡', { workflow_id: 'pf-lib', input: {} }), + node('caliber', 'metrics', 720, '周报口径', { caliber: '周报口径', caliber_version: 'v1', + metrics: [{ id: 'gmv', name: '销售额', expression: 'vars.kpi.gmv' }, { id: 'orders', name: '订单数', expression: 'vars.kpi.orders' }] }), + node('caliber2', 'metrics', 960, '补充口径', { caliber: '补充口径', caliber_version: 'v1', + metrics: [{ id: 'aov', name: '客单价', expression: 'vars.kpi.gmv / vars.kpi.orders' }] }), + node('team', 'supervisor', 1200, '复核团队', { goal: '复核', max_rounds: 4, agents: [{ name: 'checker', tools: [] }] }), + node('done', 'output', 1440, '成果', { fields: [{ name: 'report', value: '{{ vars.report }}' }], + contract: { narrative: '{{ vars.report }}', strict: false } }), + ], + edges: chain('in', 'fetch', 'sub', 'caliber', 'caliber2', 'team', 'done'), +} +/** 只有一处能自动修的错(审批)加一条能自动修的提示(strict):一键修完就能发 */ +const EASY = { + nodes: [ + node('in', 'input', 0, '入口', { fields: [{ name: 'week', required: true }] }), + node('fetch', 'agent', 240, '取数', { prompt: '查 orders 本周的 gmv', tools: ['db_query__shop'], approval: 'never' }), + node('caliber', 'metrics', 480, '周报口径', { caliber: '周报口径', caliber_version: 'v1', + metrics: [{ id: 'gmv', name: '销售额', expression: 'vars.kpi.gmv' }] }), + node('done', 'output', 720, '成果', { fields: [{ name: 'report', value: '{{ vars.report }}' }], + contract: { metrics_from: ['caliber'], narrative: '{{ vars.report }}', required: ['gmv'], strict: false } }), + ], + edges: chain('in', 'fetch', 'caliber', 'done'), +} + +/** 受管、唯一的出口没有契约:图级问题(没有节点),修复却落在那个出口上——生成契约骨架,required 要人选 */ +const BARE = { + nodes: [ + node('in', 'input', 0, '入口', { fields: [{ name: 'week', required: true }] }), + node('caliber', 'metrics', 240, '周报口径', { caliber: '周报口径', caliber_version: 'v1', + metrics: [{ id: 'gmv', name: '销售额', expression: 'vars.kpi.gmv' }, { id: 'orders', name: '订单数', expression: 'vars.kpi.orders' }] }), + node('write', 'report', 480, '报告撰写', { instructions: '写周报' }), + node('done', 'output', 720, '成果', { fields: [{ name: 'report', value: '{{ nodes.write.text }}' }] }), + ], + edges: chain('in', 'caliber', 'write', 'done'), +} +/** + * 同一个修复挂在好几条问题上:两个 agent 都没写审批、跟随全图默认的「全部自动放行」(问题各在节点上, + * 修复只有一条、落在全图上);契约缺 metrics_from,validate 和门禁各报一条(同 code、同节点、同一个修复) + */ +const DUP = { + nodes: [ + node('in', 'input', 0, '入口', { fields: [{ name: 'week', required: true }] }), + node('ask1', 'agent', 240, '查数员', { prompt: '查 orders 本周的 gmv', tools: ['db_query__shop'] }), + node('ask2', 'agent', 480, '复核员', { prompt: '复核 orders 本周的 gmv', tools: ['db_query__shop'] }), + node('caliber', 'metrics', 720, '周报口径', { caliber: '周报口径', caliber_version: 'v1', + metrics: [{ id: 'gmv', name: '销售额', expression: 'vars.kpi.gmv' }] }), + node('done', 'output', 960, '成果', { fields: [{ name: 'report', value: '{{ vars.report }}' }], + contract: { narrative: '{{ vars.report }}', required: ['gmv'], strict: true } }), + ], + edges: chain('in', 'ask1', 'ask2', 'caliber', 'done'), +} + +const wf = (id, name, graph, extra = {}) => ({ + id, name, description: '检查脚本伪造的工作流', graph, tags: [], version: 3, is_template: false, + status: 'draft', published_version: null, published_by: null, run_count: 0, + created_at: '2026-09-20T02:00:00Z', updated_at: '2026-09-26T02:00:00Z', ...extra, +}) +const FAKES = { + 'pf-hard': wf('pf-hard', '__publish_check_hard__', HARD, { status: 'governed', published_version: 2 }), + 'pf-easy': wf('pf-easy', '__publish_check_easy__', EASY), + 'pf-lib': wf('pf-lib', '__publish_check_lib__', { nodes: [], edges: [] }, { status: 'published', published_version: 4 }), + 'pf-bare': wf('pf-bare', '__publish_check_bare__', BARE, { status: 'governed', published_version: 2 }), + 'pf-dup': wf('pf-dup', '__publish_check_dup__', DUP, { status: 'governed', published_version: 2 }), + // 带全图默认的图:画布只管节点和连线,保存、检查、修复都得把 defaults 原样带着 + 'pf-defs': wf('pf-defs', '__publish_check_defaults__', { ...EASY, defaults: { approval: 'always', model: 'demo-model' } }), +} + +const clone = (x) => JSON.parse(JSON.stringify(x)) +const who = (n) => `「${n.data.label}」` + +/** + * 伪造的门禁:按请求里的图现算,形状照 PF-SPEC(issues 带 code 和 fix,fixes 列修法)。 + * 已发布档只给提示,受管档有错。修法的 id 是 code:节点(图级的是 code:graph)。 + * followsNever:当作这张图的全图默认是「全部自动放行」(画布上不带 defaults,按工作流认) + */ +function lint(graph, level, { followsNever = false } = {}) { + const hard = level === 'governed' + const issues = [] + const fixes = [] + const add = (n, code, message, fix, isError = true) => { + const id = `${code}:${n.id}` + issues.push({ level: isError && hard ? 'error' : 'warning', node_id: n.id, code, message, fix: fix ? id : null, field: null }) + if (fix) fixes.push({ id, code, node_id: n.id, ...fix }) + } + const cards = graph.nodes.filter((n) => n.type === 'metrics') + const outputs = graph.nodes.filter((n) => n.type === 'output') + // 图级:唯一的出口没有契约。问题不挂节点,修复落在那个出口上(和后端 autofix._target 一样) + if (hard && outputs.length === 1 && !outputs[0].data.config?.contract) { + const id = `governed.no_contract:${outputs[0].id}` + issues.push({ level: 'error', node_id: null, code: 'governed.no_contract', fix: id, field: null, + message: '受管模板至少要有一个「成果 / 出具」节点声明出具契约' }) + fixes.push({ id, code: 'governed.no_contract', node_id: outputs[0].id, kind: 'choice', multiple: true, + label: `给${who(outputs[0])}生成出具契约,再选出必需的指标`, + options: cards.flatMap((m) => m.data.config.metrics.map((x) => ({ value: x.id, label: `${x.id} · ${x.name}` }))) }) + } + for (const n of graph.nodes) { + const c = n.data.config ?? {} + if (followsNever && n.type === 'agent' && !c.approval && (c.tools ?? []).length) { + const id = 'governed.default_approval_never:graph' + issues.push({ level: hard ? 'error' : 'warning', node_id: n.id, code: 'governed.default_approval_never', fix: id, field: 'approval', + message: `${who(n)}(Agent)没写自己的审批策略,跟随全图默认的「全部自动放行」` }) + if (!fixes.some((f) => f.id === id)) { + fixes.push({ id, code: 'governed.default_approval_never', node_id: null, kind: 'auto', label: '把全图默认的审批策略改成「仅危险工具需要审批」', + preview: { field: 'defaults.approval', before: 'never', after: 'dangerous' } }) + } + } + if (n.type === 'agent' && c.approval === 'never' && (c.tools ?? []).length) { + add(n, 'governed.agent_approval_never', `${who(n)}(Agent)的审批策略是「全部自动放行」,受管模板要求危险工具至少人工审批`, + { kind: 'auto', label: '把审批策略改成「仅危险工具需要审批」', preview: { field: 'approval', before: 'never', after: 'dangerous' } }) + } + if (n.type === 'subgraph' && !c.workflow_version) { + add(n, 'governed.subgraph_unpinned', `${who(n)}(子工作流)没有钉住版本,口径会随上游最新版漂移`, + { kind: 'auto', label: '钉到它最新的已发布版本 v4', preview: { field: 'workflow_version', before: null, after: 4 } }) + } + if (n.type === 'supervisor') { + add(n, 'governed.supervisor', `${who(n)}(多 Agent 协作):受管模板不允许全动态规划的节点`, + { kind: 'assist', label: '换成固定编排是结构性的改动,交给 Copilot' }) + } + if (n.type === 'output' && c.contract) { + const k = c.contract + if (!(k.metrics_from ?? []).length) { + // 真后端 validate 和门禁各报一条(同 code、同节点,说法不同),指向同一个修复;validate 的排在最前 + if (followsNever) { + issues.unshift({ level: 'error', node_id: n.id, code: 'contract.metrics_from_missing', fix: `contract.metrics_from_missing:${n.id}`, + field: 'contract.metrics_from', message: '出具契约缺 metrics_from(指标来自哪个「口径卡」节点)' }) + } + add(n, 'contract.metrics_from_missing', '出具契约没有声明 metrics_from(指标来自哪个「口径卡」节点)', { + kind: 'choice', label: '指标来自哪张口径卡', default: cards[0]?.id, + options: cards.map((m) => ({ value: m.id, label: m.data.label, hint: `${m.data.config.metrics.length} 个指标` })), + }) + } + if (hard && !(k.required ?? []).length) { + const from = (k.metrics_from ?? []).length ? cards.filter((m) => k.metrics_from.includes(m.id)) : cards + add(n, 'contract.required_missing', '受管模板的出具契约必须声明 required(必需指标)', { + kind: 'choice', multiple: true, label: '哪些指标缺了就不该出具', + options: from.flatMap((m) => m.data.config.metrics.map((x) => ({ value: x.id, label: `${x.id} · ${x.name}` }))), + }) + } + if (hard && !k.strict) { + add(n, 'contract.strict_off', '受管模板建议把出具契约设为 strict:非 strict 下未回指的数字只降档不拦截', + { kind: 'auto', label: '把出具契约设为 strict', preview: { field: 'contract.strict', before: false, after: true } }, false) + } + } + } + return { level, ok: !issues.some((i) => i.level === 'error'), issues, fixes } +} + +/** 伪造的 autofix:在副本上照选中的修法改,逐项记 changes;钉不上的(pf-lib 没发过版时)进 rejected */ +function autofix(body, { unpublished = false, followsNever = false } = {}) { + const graph = clone(body.graph) + const before = lint(graph, body.level, { followsNever }) + const changes = [] + const rejected = [] + const applied = [] + const byId = (id) => graph.nodes.find((n) => n.id === id) + for (const id of body.apply ?? []) { + const fix = before.fixes.find((f) => f.id === id) + if (!fix) { rejected.push({ fix_id: id, reason: '这条问题已经不在了' }); continue } + if (fix.code === 'governed.default_approval_never') { + // 图级:改的是全图默认,不落在任何节点上 + graph.defaults = { ...(graph.defaults ?? {}), approval: 'dangerous' } + changes.push({ fix_id: id, node_id: null, node_title: '', field: 'defaults.approval', before: 'never', after: 'dangerous', label: fix.label }) + applied.push(id) + continue + } + const n = byId(fix.node_id) + const c = n.data.config + const change = (field, from, to) => changes.push({ fix_id: id, node_id: n.id, node_title: n.data.label, field, before: from, after: to, label: fix.label }) + if (fix.code === 'governed.agent_approval_never') { change('approval', c.approval, 'dangerous'); c.approval = 'dangerous' } + else if (fix.code === 'governed.subgraph_unpinned') { + if (unpublished) { rejected.push({ fix_id: id, reason: '嵌的工作流还没有发布过版本,钉不上:先去发布它,或者交给人选' }); continue } + change('workflow_version', c.workflow_version ?? null, 4); c.workflow_version = 4 + } else if (fix.code === 'contract.strict_off') { change('contract.strict', !!c.contract.strict, true); c.contract.strict = true } + else if (fix.code === 'contract.metrics_from_missing') { + const v = body.choices?.[id] + if (v === undefined) { rejected.push({ fix_id: id, reason: '要人选:没给选择' }); continue } + change('contract.metrics_from', c.contract.metrics_from ?? null, [v]); c.contract.metrics_from = [v] + } else if (fix.code === 'governed.no_contract') { + const v = body.choices?.[id] + if (!Array.isArray(v) || !v.length) { rejected.push({ fix_id: id, reason: '要人选:没给选择' }); continue } + const contract = { report_from: 'write', metrics_from: ['caliber'], strict: true, required: v } + change('contract', null, contract); c.contract = contract + } else if (fix.code === 'contract.required_missing') { + const v = body.choices?.[id] + if (!Array.isArray(v) || !v.length) { rejected.push({ fix_id: id, reason: '要人选:没给选择' }); continue } + change('contract.required', c.contract.required ?? null, v); c.contract.required = v + } else { rejected.push({ fix_id: id, reason: '这一条不能直接修' }); continue } + applied.push(id) + } + let assist = null + if (body.assist) { + const team = byId('team') + if (team) { + changes.push({ fix_id: 'assist', node_id: 'team', node_title: team.data.label, field: 'max_rounds', before: team.data.config.max_rounds, after: 2, + label: 'Copilot:先把协作轮数收紧' }) + team.data.config.max_rounds = 2 + } + assist = { ok: true, summary: '把复核团队的轮数收紧到 2 轮;换成固定编排要删节点,按规矩没动它', + questions: ['复核团队要换成哪一个固定编排的 Agent?它现在只有一个成员 checker,保留它的职责吗?'] } + } + const remaining = lint(graph, body.level, { followsNever }) + return { graph, changes, applied, rejected, remaining: remaining.issues, assist, ok: remaining.ok, ops: [] } +} + +// ---------------------------------------------------------------- 浏览器 + +const browser = await chromium.launch({ executablePath: CHROME }) +const writes = [] + +/** + * backend:'new'(两个接口都有)| 'old'(两个接口 404)| 'nofix'(有检查、没有 autofix) + * save:'ok' | 'fail'(保存接口 500)| 'slow'(1.5 秒后才回,看保存进行中的样子) + */ +async function open({ id = 'pf-hard', width = 1440, height = 900, backend = 'new', save = 'ok', unpublished = false } = {}) { + const followsNever = id === 'pf-dup' + const ctx = await browser.newContext({ viewport: { width, height } }) + opened.add(ctx) + await ctx.addInitScript(() => { try { localStorage.setItem('agentlab_actor', '检查脚本') } catch { /* noop */ } }) + const page = await ctx.newPage() + page.setDefaultTimeout(8000) + const errors = [] + page.on('pageerror', (e) => errors.push(e.message)) + const json = (route, body, status = 200) => route.fulfill({ status, contentType: 'application/json', body: JSON.stringify(body) }) + const state = { checks: [], autofixes: [], patches: [], publishes: [], version: FAKES[id].version, graph: clone(FAKES[id].graph), save, backend } + + await page.route(/\/api\//, (route) => { + const req = route.request() + if (req.method() === 'GET') return route.continue() + writes.push(`${req.method()} ${new URL(req.url()).pathname}`) + return json(route, { detail: '检查脚本不写库' }, 409) + }) + await page.route(/\/api\/workflows\/(validate|variables)$/, (route) => route.continue()) + await page.route(/\/api\/workflows(\?.*)?$/, async (route) => { + if (route.request().method() !== 'GET') return route.fallback() + const real = await route.fetch().then((r) => r.json()).catch(() => []) + return json(route, [...Object.values(FAKES), ...(Array.isArray(real) ? real : [])]) + }) + await page.route(/\/api\/workflows\/pf-[a-z]+(\/.*)?(\?.*)?$/, async (route) => { + const req = route.request() + const [, , , wid, sub, v] = new URL(req.url()).pathname.split('/') + const method = req.method() + const base = FAKES[wid] + if (sub === 'versions' && !v) return json(route, [4, 3, 2].map((n) => ({ id: `v${n}`, version: n, note: '', created_at: '2026-09-26T02:00:00Z', published: n === 4 }))) + if (sub === 'versions' && v) return json(route, { id: `v${v}`, version: Number(v), workflow_id: wid, graph: base.graph, graph_hash: 'x', input_fields: [] }) + if (method === 'GET' && !sub) return json(route, wid === id ? { ...base, graph: state.graph, version: state.version } : base) + if (method === 'PATCH' && !sub && wid === id) { + const body = req.postDataJSON() + state.patches.push(body) + if (state.save === 'fail') return json(route, { detail: '数据库锁住了,稍后再试' }, 500) + if (state.save === 'slow') await new Promise((r) => setTimeout(r, 1500)) + state.version += 1 + state.graph = body.graph + return json(route, { ...base, graph: body.graph, version: state.version, status: 'draft' }) + } + if (method === 'POST' && sub === 'publish-check') { + const body = req.postDataJSON() + // 开发模式的 StrictMode 会把挂载时的 effect 跑两遍(第一遍的请求随即被取消): + // 300ms 内一模一样的两次只记一次,数的是「查了几回」 + const key = JSON.stringify(body) + const last = state.checks.at(-1) + if (!(last && last.key === key && Date.now() - last.at < 300)) state.checks.push({ ...body, key, at: Date.now() }) + if (state.backend === 'old') return json(route, { detail: 'Not Found' }, 404) + return json(route, lint(body.graph ?? state.graph, body.level, { followsNever })) + } + if (method === 'POST' && sub === 'autofix') { + const body = req.postDataJSON() + state.autofixes.push(body) + if (state.backend !== 'new') return json(route, { detail: 'Not Found' }, 404) + return json(route, autofix(body, { unpublished, followsNever })) + } + if (method === 'POST' && sub === 'publish') { + const body = req.postDataJSON() + state.publishes.push(body) + const r = lint(state.graph, body.level, { followsNever }) + // 真后端的 /publish 回包:问题带 code、不带 fix(fix 只有 publish-check 给);老后端连 code 也没有 + const issues = state.backend === 'old' ? r.issues.map(({ code: _c, fix: _f, ...rest }) => rest) + : r.issues.map(({ fix: _f, ...rest }) => rest) + return json(route, r.ok ? { ok: true, level: body.level, version: state.version, issues } : { ok: false, level: body.level, version: state.version, issues }) + } + return route.fallback() + }) + await page.route(/\/api\/conversations(\/.*)?(\?.*)?$/, (route) => { + const req = route.request() + if (req.method() === 'GET' && new URL(req.url()).pathname.endsWith('/conversations')) return json(route, []) + return json(route, { id: 'pf-conv', title: '', kind: 'canvas', archived: false, turn_count: 0, last_question: '', turns: [] }) + }) + + await page.goto(`${WEB}/studio/${id}`, { waitUntil: 'networkidle' }) + await page.waitForFunction((wid) => window.__studio?.getState().workflow?.id === wid, id, { timeout: 15000 }) + await page.waitForTimeout(400) + return { ctx, page, errors, state } +} + +const dialog = (page) => page.locator('[role="dialog"]') +async function openDialog(page) { + await page.getByRole('button', { name: '发布', exact: true }).click() + await dialog(page).waitFor() +} +const waitFor = async (page, fn, ms = 5000) => { + for (let t = 0; t < ms; t += 100) { + if (await fn()) return true + await page.waitForTimeout(100) + } + return false +} +const submit = (page) => dialog(page).locator('[data-publish-submit]') +const cfgOf = (graph, id) => graph?.nodes?.find((n) => n.id === id)?.data?.config +const shoot = async (page, name, target) => { + if (!SHOTS) return + for (const theme of ['light', 'dark']) { + await page.evaluate((t) => document.documentElement.setAttribute('data-theme', t), theme) + await page.waitForTimeout(250) + await (target ?? page).screenshot({ path: `${SHOTS}/${name}-${theme}.png` }).catch(() => {}) + } + await page.evaluate(() => document.documentElement.removeAttribute('data-theme')) +} + +// ================================================================ +await section('打开弹窗就列出问题,每条按修法给控件', async () => { + const { page, state, errors } = await open() + await openDialog(page) + await waitFor(page, async () => (await dialog(page).locator('[data-preflight-issue]').count()) > 0) + check('一打开就调 publish-check(没点发布)', state.checks.length === 1 && state.publishes.length === 0, JSON.stringify(state.checks.map((c) => c.level))) + check('……按工作流现在的等级(受管)查,带着画布上的图', state.checks[0]?.level === 'governed' && state.checks[0]?.graph?.nodes?.length === HARD.nodes.length) + const rows = await dialog(page).locator('[data-preflight-issue]').count() + check('问题先列出来,一条一行', rows === 6, `${rows} 行`) + const summary = await dialog(page).locator('[data-preflight-summary]').innerText().catch(() => '') + check('结论一句:会被拦下 5 处,另有 1 条提示', summary.includes('会被门禁拦下 5 处') && summary.includes('1 条提示'), summary) + check('auto 类有「修复」', await dialog(page).locator('[data-fix-kind="auto"] [data-fix-action="auto"]').count() === 3) + check('choice 类是选择控件:单选用 radio(两张口径卡)、多选用 checkbox(三个指标)', await dialog(page).locator('[data-fix-choice="contract.metrics_from_missing:done"] input[type="radio"]').count() === 2 + && await dialog(page).locator('[data-fix-choice="contract.required_missing:done"] input[type="checkbox"]').count() === 3) + check('assist 类有「交给 Copilot」', (await dialog(page).locator('[data-fix-kind="assist"] [data-fix-action="assist"]').innerText()).includes('交给 Copilot')) + const all = await dialog(page).locator('[data-fix-all]').innerText().catch(() => '') + check('顶部「一键修复可自动修的 3 处」', all.includes('一键修复可自动修的 3 处'), all) + check('多选不给默认全选:一个都没勾', await dialog(page).locator('[data-fix-choice="contract.required_missing:done"] input:checked').count() === 0) + check('单选的建议值只标「建议」,不替人选', await dialog(page).locator('[data-fix-choice="contract.metrics_from_missing:done"] input:checked').count() === 0 + && (await dialog(page).locator('[data-fix-choice="contract.metrics_from_missing:done"]').innerText()).includes('建议')) + check('发布按钮照旧能点(不修也能发,门禁照旧拦)', await submit(page).isEnabled()) + await shoot(page, 'publish-dialog-issues', dialog(page)) + check('没有运行时报错', errors.length === 0, errors.join(' | ')) +}) + +await section('auto:预览逐项写「节点 · 字段:原值 → 新值」,应用并重新检查', async () => { + const { page, state } = await open({ id: 'pf-hard', unpublished: true }) + await openDialog(page) + await dialog(page).locator('[data-fix-all]').waitFor() + await dialog(page).locator('[data-fix-all]').click() + await dialog(page).locator('[data-fix-preview="ready"]').waitFor() + const req = state.autofixes[0] + check('一键修复请求:apply 是三处 auto 的 id,不带 assist', JSON.stringify([...(req?.apply ?? [])].sort()) === JSON.stringify( + ['contract.strict_off:done', 'governed.agent_approval_never:fetch', 'governed.subgraph_unpinned:sub']) && !req?.assist, JSON.stringify(req?.apply)) + const lines = await dialog(page).locator('[data-fix-change]').allInnerTexts() + const approval = lines.find((l) => l.includes('取数')) ?? '' + check('预览:「取数」· 审批策略:全部自动放行 → 仅危险工具需要审批', /「取数」\s*·\s*审批策略:\s*全部自动放行\s*仅危险工具需要审批/.test(approval.replace(/\n.*$/s, '')), approval) + const strict = lines.find((l) => l.includes('严格模式')) ?? '' + check('预览:契约里的键用界面叫法(出具契约 · 严格模式:否 → 是)', /出具契约 · 严格模式:\s*否\s*是/.test(strict), strict) + const rejected = await dialog(page).locator('[data-fix-rejected]').innerText().catch(() => '') + check('没采用的写出原因', rejected.includes('钉不上') && rejected.includes('钉到它最新的已发布版本'), rejected) + check('预览期间发布按钮不可用(先应用或放弃)', await submit(page).isDisabled()) + check('预览不改画布、不保存', state.patches.length === 0 + && await page.evaluate(() => window.__studio.getState().nodes.find((n) => n.id === 'fetch').data.config.approval) === 'never') + await shoot(page, 'publish-dialog-preview', dialog(page)) + await dialog(page).locator('[data-fix-apply]').click() + await waitFor(page, async () => state.patches.length === 1 && state.checks.length >= 2 && await dialog(page).locator('[data-fix-preview]').count() === 0) + const saved = state.patches[0]?.graph + check('应用 = 走保存接口存草稿:载荷里是修好的图', cfgOf(saved, 'fetch')?.approval === 'dangerous' && cfgOf(saved, 'done')?.contract?.strict === true + && !cfgOf(saved, 'sub')?.workflow_version, JSON.stringify({ a: cfgOf(saved, 'fetch')?.approval, s: cfgOf(saved, 'done')?.contract?.strict })) + check('……没采用的那处不在载荷里;节点和连线一个没少', saved?.nodes?.length === HARD.nodes.length && saved?.edges?.length === HARD.edges.length) + check('……写了版本说明', /^发布前修复:/.test(state.patches[0]?.note ?? ''), state.patches[0]?.note) + check('存完再查一遍 publish-check', state.checks.length === 2 && cfgOf(state.checks[1]?.graph, 'fetch')?.approval === 'dangerous') + const st = await page.evaluate(() => { const s = window.__studio.getState(); return { dirty: s.dirty, v: s.workflow.version, past: s.past.length, label: s.past.at(-1)?.label } }) + check('画布落上修复、存完不再「未保存」,撤销栈里有这一步', !st.dirty && st.v === 4 && st.past >= 1 && /发布前修复/.test(st.label ?? ''), JSON.stringify(st)) + const rows = await dialog(page).locator('[data-preflight-issue]').count() + check('重新检查后清单跟着变(剩 4 处错,strict 的提示没了)', rows === 4 + && await dialog(page).locator('[data-preflight-issue="contract.strict_off"]').count() === 0, `${rows} 行`) + check('整个过程没有发布请求', state.publishes.length === 0) +}) + +await section('auto:修完没有错了,发布按钮才可用;发布只在人点之后发生', async () => { + const { page, state } = await open({ id: 'pf-easy' }) + await openDialog(page) + await dialog(page).locator('[data-fix-all]').waitFor() + check('草稿工作流默认按「已发布」查', state.checks[0]?.level === 'published') + await dialog(page).locator('[role="radio"]:has-text("受管")').click() + await waitFor(page, async () => state.checks.length === 2) + check('换成受管就按受管重查', state.checks[1]?.level === 'governed') + await dialog(page).locator('[data-fix-all]').waitFor() + await dialog(page).locator('[data-fix-all]').click() + await dialog(page).locator('[data-fix-preview="ready"]').waitFor() + const after = await dialog(page).locator('[data-fix-after-check]').innerText().catch(() => '') + check('预览写明应用后门禁不再拦', after.includes('不再拦'), after) + check('预览期间发布不可用', await submit(page).isDisabled()) + await dialog(page).locator('[data-fix-apply]').click() + await waitFor(page, async () => state.checks.length === 3 && await dialog(page).locator('[data-fix-preview]').count() === 0) + const summary = await dialog(page).locator('[data-preflight-summary]').innerText().catch(() => '') + check('重新检查通过', summary.includes('发布前检查通过'), summary) + check('发布按钮变为可用', await submit(page).isEnabled()) + check('到这一步还没有任何发布请求', state.publishes.length === 0) + await submit(page).click() + await waitFor(page, async () => state.publishes.length === 1) + check('人点了「发布为受管」才发出一次发布', state.publishes.length === 1 && state.publishes[0].level === 'governed', JSON.stringify(state.publishes)) +}) + +await section('choice:没选之前不能应用;选了之后载荷里是选的值', async () => { + const { page, state } = await open() + await openDialog(page) + const req = dialog(page).locator('[data-fix-choice="contract.required_missing:done"]') + await req.waitFor() + check('没选时「预览」不可用,并说先选', await req.locator('[data-fix-action="choice"]').isDisabled() && (await req.innerText()).includes('先选好再预览')) + await req.locator('label:has-text("gmv") input').check() + await req.locator('label:has-text("orders") input').check() + check('选了之后可以预览', await req.locator('[data-fix-action="choice"]').isEnabled()) + await req.locator('[data-fix-action="choice"]').click() + await dialog(page).locator('[data-fix-preview="ready"]').waitFor() + const body = state.autofixes.at(-1) + check('请求:apply 只有这一项,choices 是选的两个', JSON.stringify(body?.apply) === JSON.stringify(['contract.required_missing:done']) + && JSON.stringify(body?.choices?.['contract.required_missing:done']) === JSON.stringify(['gmv', 'orders']), JSON.stringify(body)) + const change = await dialog(page).locator('[data-fix-change]').first().innerText() + check('预览:出具契约 · 必需指标:(空)→ gmv、orders', /出具契约 · 必需指标:\s*(空)\s*gmv、orders/.test(change), change) + await dialog(page).locator('[data-fix-apply]').click() + await waitFor(page, async () => state.patches.length === 1) + check('保存载荷里 required 是选的值', JSON.stringify(cfgOf(state.patches[0]?.graph, 'done')?.contract?.required) === JSON.stringify(['gmv', 'orders']), + JSON.stringify(cfgOf(state.patches[0]?.graph, 'done')?.contract)) + + // 单选:选「补充口径」 + const from = dialog(page).locator('[data-fix-choice="contract.metrics_from_missing:done"]') + await waitFor(page, async () => (await from.count()) === 1 && await from.locator('[data-fix-action="choice"]').isVisible()) + await from.locator('label:has-text("补充口径") input').check() + await from.locator('[data-fix-action="choice"]').click() + await dialog(page).locator('[data-fix-preview="ready"]').waitFor() + check('单选:choices 里是选中的那一个值', state.autofixes.at(-1)?.choices?.['contract.metrics_from_missing:done'] === 'caliber2', JSON.stringify(state.autofixes.at(-1)?.choices)) + // 改动的值是节点 id(caliber2):预览里写节点名,和选的时候看到的是同一个名字 + const fromLine = await dialog(page).locator('[data-fix-change="contract.metrics_from_missing:done"]').first().innerText().catch(() => '') + check('预览写节点名、不写 id:出具契约 · 指标来自:(空)→「补充口径」', /出具契约 · 指标来自:\s*(空)\s*「补充口径」/.test(fromLine) + && !fromLine.includes('caliber2'), fromLine) +}) + +await section('assist:Copilot 的改动先预览、它的问题原样摆出来,不确认就不保存', async () => { + const { page, state } = await open() + await openDialog(page) + await dialog(page).locator('[data-fix-action="assist"]').waitFor() + await dialog(page).locator('[data-fix-action="assist"]').click() + await dialog(page).locator('[data-fix-preview="ready"]').waitFor() + const body = state.autofixes.at(-1) + check('请求带 assist: true', body?.assist === true, JSON.stringify(body)) + const change = await dialog(page).locator('[data-fix-change]').first().innerText().catch(() => '') + check('Copilot 的改动逐项预览', change.includes('复核团队') && /4\s*2/.test(change), change) + const q = await dialog(page).locator('[data-fix-questions]').innerText().catch(() => '') + check('它提的问题原样显示', q.includes('复核团队要换成哪一个固定编排的 Agent?它现在只有一个成员 checker,保留它的职责吗?'), q) + check('它的总结也摆出来', (await dialog(page).locator('[data-fix-assist]').innerText()).includes('按规矩没动它')) + await shoot(page, 'publish-dialog-assist', dialog(page)) + await dialog(page).getByRole('button', { name: '放弃', exact: true }).click() + await page.waitForTimeout(300) + check('放弃:不保存、画布不动、预览收起', state.patches.length === 0 && await dialog(page).locator('[data-fix-preview]').count() === 0 + && await page.evaluate(() => window.__studio.getState().nodes.find((n) => n.id === 'team').data.config.max_rounds) === 4) + check('放弃后发布按钮恢复可用', await submit(page).isEnabled()) + check('没有发布请求', state.publishes.length === 0) +}) + +await section('保存失败、接口不存在、画布改过:都有明确状态', async () => { + const bad = await open({ id: 'pf-easy', save: 'fail' }) + await openDialog(bad.page) + await dialog(bad.page).locator('[data-fix-all]').click() + await dialog(bad.page).locator('[data-fix-apply]').click() + await dialog(bad.page).locator('[data-fix-save-failed]').waitFor() + const msg = await dialog(bad.page).locator('[data-fix-save-failed]').innerText() + check('保存失败:说清没存上、修复已在画布上', msg.includes('草稿没保存上') && msg.includes('数据库锁住了') && msg.includes('保存成功之前不能发布'), msg) + check('……发布按钮不可用(不能把没修的那一版发出去)', await submit(bad.page).isDisabled()) + check('……不重查', bad.state.checks.length === 1) + bad.state.save = 'ok' + await dialog(bad.page).getByRole('button', { name: '重试保存' }).click() + await waitFor(bad.page, async () => bad.state.checks.length === 2) + check('重试保存成功后重新检查、发布可用', bad.state.patches.length === 2 && await submit(bad.page).isEnabled()) + + const nofix = await open({ backend: 'nofix' }) + await openDialog(nofix.page) + await dialog(nofix.page).locator('[data-fix-all]').click() + await dialog(nofix.page).locator('[data-fix-preview="unsupported"]').waitFor() + check('autofix 不存在:说这个后端还不支持自动修复', (await dialog(nofix.page).locator('[data-fix-preview]').innerText()).includes('还不支持自动修复')) + check('……发布按钮照旧可用', await submit(nofix.page).isEnabled()) + + const moved = await open({ id: 'pf-easy' }) + await openDialog(moved.page) + await dialog(moved.page).locator('[data-fix-all]').click() + await dialog(moved.page).locator('[data-fix-apply]').waitFor() + await moved.page.evaluate(() => { const s = window.__studio.getState(); s.updateNode('fetch', { label: '取数2' }) }) + await dialog(moved.page).locator('[data-fix-apply]').click() + await moved.page.waitForTimeout(300) + check('预览之后画布内容又改过:不套用、说要重查', moved.state.patches.length === 0 + && (await dialog(moved.page).locator('[data-fix-preview]').innerText()).includes('对不上了')) +}) + +await section('正式运行编辑锁定时不给修复按钮', async () => { + const { page } = await open() + await page.evaluate(() => { + const s = window.__studio.getState() + window.__studio.setState({ runPhase: 'running', trace: { ...s.trace, runClass: 'formal' } }) + }) + await openDialog(page) + await dialog(page).locator('[data-preflight-issue]').first().waitFor() + check('问题照样列出来', await dialog(page).locator('[data-preflight-issue]').count() === 6) + check('没有任何修复控件', await dialog(page).locator('[data-fix-action], [data-fix-all], [data-fix-choice]').count() === 0) + check('写明为什么现在不能修', (await dialog(page).locator('[data-preflight-locked]').innerText()).includes('正式运行进行中')) +}) + +await section('老后端(两个接口 404):界面和现在一样', async () => { + const { page, state, errors } = await open({ backend: 'old' }) + await openDialog(page) + await waitFor(page, async () => state.checks.length === 1) + await page.waitForTimeout(300) + check('打开时不列问题、不给修复控件', await dialog(page).locator('[data-preflight-issue], [data-fix-action], [data-fix-all]').count() === 0) + check('只一句安静的说明:点发布时门禁照旧检查', (await dialog(page).locator('[data-preflight-unsupported]').innerText().catch(() => '')).includes('门禁照旧检查')) + check('发布按钮可用', await submit(page).isEnabled()) + await submit(page).click() + await waitFor(page, async () => (await dialog(page).locator('[data-preflight-issue]').count()) > 0) + const summary = await dialog(page).locator('[data-preflight-summary]').innerText().catch(() => '') + check('点了发布被拦:照旧列出门禁拦下的问题', summary.includes('门禁拦下了 5 处') && await dialog(page).locator('[data-preflight-issue]').count() === 6, summary) + check('……仍然没有修复控件', await dialog(page).locator('[data-fix-action], [data-fix-all]').count() === 0) + await dialog(page).locator('[data-preflight-issue]').filter({ hasText: '取数' }).locator('button').first().click() + await page.waitForTimeout(300) + check('点一条关掉弹窗、定位到节点(原有行为)', await dialog(page).count() === 0 + && await page.evaluate(() => window.__studio.getState().selectedId) === 'fetch') + check('没有运行时报错', errors.length === 0, errors.join(' | ')) +}) + +await section('问题面板的发布前检查:选档、同一套修法、点一条定位', async () => { + const { page, state } = await open({ id: 'pf-easy' }) + await page.locator('.react-flow__pane').click({ position: { x: 30, y: 30 } }) + await page.keyboard.press('Alt+KeyP') + await page.locator('#dock-problems').waitFor() + await page.locator('[data-problems-mode="publish"]').click() + await page.locator('[data-preflight="panel"]').waitFor().catch(() => {}) + await waitFor(page, async () => state.checks.length === 1) + check('切到「发布前检查」就按当前等级查一遍(草稿 → 已发布)', await page.locator('[data-preflight="panel"]').count() === 1 + && state.checks[0]?.level === 'published', JSON.stringify(state.checks.map((c) => c.level))) + await page.locator('[data-preflight="panel"] [role="radio"]:has-text("受管")').click() + await waitFor(page, async () => state.checks.length === 2) + check('选「受管」重查', state.checks[1]?.level === 'governed') + await page.locator('[data-preflight="panel"] [data-fix-all="2"]').waitFor() + check('同一套修复控件', await page.locator('[data-preflight="panel"] [data-fix-action="auto"]').count() === 2) + await shoot(page, 'problems-preflight', page.locator('section[aria-label="问题"]')) + await page.locator('[data-preflight="panel"] [data-preflight-issue]').filter({ hasText: '取数' }).locator('button').first().click() + await page.waitForTimeout(300) + check('点一条定位到节点', await page.evaluate(() => window.__studio.getState().selectedId) === 'fetch') + // 画布改过:结论可能过时 + await page.evaluate(() => { const s = window.__studio.getState(); s.updateNode('caliber', { label: '周报口径2' }) }) + await page.waitForSelector('[data-preflight="panel"] [data-preflight-stale]', { timeout: 6000 }).catch(() => {}) + check('查完画布又改过:提示结果可能过时', await page.locator('[data-preflight="panel"] [data-preflight-stale]').count() === 1) + await page.locator('[data-preflight="panel"]').getByRole('button', { name: '重新检查' }).click() + await waitFor(page, async () => state.checks.length === 3) + await page.locator('[data-preflight="panel"] [data-fix-all]').click() + await page.locator('[data-preflight="panel"] [data-fix-apply]').click() + // 重查的请求发出去之后,回包画上去之前清单还是上一份:等预览收起(它在重查回来之后才收) + await waitFor(page, async () => state.patches.length === 1 && state.checks.length === 4 + && await page.locator('[data-preflight="panel"] [data-fix-preview]').count() === 0) + check('面板里应用:存草稿(带上画布上别的未保存改动)再重查', cfgOf(state.patches[0]?.graph, 'fetch')?.approval === 'dangerous' + && state.patches[0]?.graph?.nodes?.find((n) => n.id === 'caliber')?.data?.label === '周报口径2') + const summary = await page.locator('[data-preflight="panel"] [data-preflight-summary]').innerText().catch(() => '') + check('……重查通过', summary.includes('发布前检查通过'), summary) + check('面板里也不发布', state.publishes.length === 0) + await page.locator('[data-problems-mode="lint"]').click() + check('切回「校验」照旧是原来的问题清单', await page.locator('[data-preflight="panel"]').count() === 0) +}) + +await section('同一个修复只画一次:validate 和门禁各报一条、几条问题共用一个图级修复', async () => { + const { page, state, errors } = await open({ id: 'pf-dup' }) + await openDialog(page) + await waitFor(page, async () => (await dialog(page).locator('[data-preflight-issue]').count()) > 0) + const rows = await dialog(page).locator('[data-preflight-issue]').count() + check('同 code、同节点的两条合成一行', rows === 3 + && await dialog(page).locator('[data-preflight-issue="contract.metrics_from_missing"]').count() === 1, `${rows} 行`) + const summary = await dialog(page).locator('[data-preflight-summary]').innerText().catch(() => '') + check('……结论按一处算:会被门禁拦下 3 处', summary.includes('会被门禁拦下 3 处'), summary) + const choice = dialog(page).locator('[data-fix-choice="contract.metrics_from_missing:done"]') + check('契约那一处只有一组选项、一个「预览」', await choice.count() === 1 && await choice.locator('[data-fix-action]').count() === 1) + const follow = dialog(page).locator('[data-preflight-issue="governed.default_approval_never"]') + check('两个节点共用的图级修复只画一次,挂在第一条上', await follow.count() === 2 + && await follow.nth(0).locator('[data-fix-action="auto"]').count() === 1 && await follow.nth(1).locator('[data-fix-action]').count() === 0 + && await dialog(page).locator('[data-fix-action="auto"]').count() === 1) + check('一键修复算一处', (await dialog(page).locator('[data-fix-all]').getAttribute('data-fix-all').catch(() => '')) === '1') + await dialog(page).locator('[data-fix-all]').click() + await dialog(page).locator('[data-fix-preview="ready"]').waitFor() + const line = await dialog(page).locator('[data-fix-change]').first().innerText().catch(() => '') + check('图级改动的预览:「整张工作流」 · 全图默认 · 审批策略:全部自动放行 → 仅危险工具需要审批', + /「整张工作流」\s*·\s*全图默认 · 审批策略:\s*全部自动放行\s*仅危险工具需要审批/.test(line), line) + await dialog(page).getByRole('button', { name: '放弃', exact: true }).click() + // 真点了发布被拦:/publish 的回包只有 code 没有 fix。图级修复按 code 认回来,照样只画一次 + await submit(page).click() + await waitFor(page, async () => state.publishes.length === 1 + && (await dialog(page).locator('[data-preflight-summary]').innerText().catch(() => '')).includes('门禁拦下了')) + const gateSummary = await dialog(page).locator('[data-preflight-summary]').innerText().catch(() => '') + check('被拦之后:照样合成一行(门禁拦下了 3 处)', gateSummary.includes('门禁拦下了 3 处') + && await dialog(page).locator('[data-preflight-issue]').count() === 3, gateSummary) + check('……图级修复(回包里没有 fix)照样挂在第一条上,只画一次', await follow.nth(0).locator('[data-fix-action="auto"]').count() === 1 + && await dialog(page).locator('[data-fix-action="auto"]').count() === 1) + check('……契约那一处照样只有一组选项', await choice.count() === 1) + check('没有运行时报错', errors.length === 0, errors.join(' | ')) +}) + +await section('图级问题:契约骨架按键写节点名;发布被拦之后修复控件还在', async () => { + const { page, state, errors } = await open({ id: 'pf-bare' }) + await openDialog(page) + const row = dialog(page).locator('[data-preflight-issue="governed.no_contract"]') + await row.waitFor() + check('打开时:「没有带契约的出口」旁边有生成契约骨架的选项', await row.locator('[data-fix-choice="governed.no_contract:done"]').count() === 1) + await row.locator('label:has-text("gmv") input').check() + await row.locator('[data-fix-action="choice"]').click() + await dialog(page).locator('[data-fix-preview="ready"]').waitFor() + const change = await dialog(page).locator('[data-fix-change]').first().innerText().catch(() => '') + const after = await dialog(page).locator('[data-fix-change] [data-fix-after]').first().innerText().catch(() => '') + const lines = after.split('\n').map((l) => l.trim()).filter(Boolean) + check('契约骨架一键一行,键名用界面叫法、节点写名字', /「成果」\s*·\s*出具契约:\s*(空)/.test(change) + && JSON.stringify(lines) === JSON.stringify(['指标来自:「周报口径」', '报告来自:「报告撰写」', '必需指标:gmv', '严格模式:是']), lines.join(' | ')) + check('……不露节点 id、英文键名、JSON', !/caliber|write|metrics_from|report_from|required|strict|[{}"]/.test(after), after) + await dialog(page).getByRole('button', { name: '放弃', exact: true }).click() + await submit(page).click() + await waitFor(page, async () => state.publishes.length === 1 + && (await dialog(page).locator('[data-preflight-summary]').innerText().catch(() => '')).includes('门禁拦下了')) + const gate = dialog(page).locator('[data-preflight-issue="governed.no_contract"]') + check('发布被拦(回包只有 code 没有 fix):图级问题旁边照样有生成契约骨架的选项', state.publishes.length === 1 + && await gate.locator('[data-fix-choice="governed.no_contract:done"]').count() === 1) + check('没有运行时报错', errors.length === 0, errors.join(' | ')) +}) + +await section('保存进行中不能换等级;存失败之后换了等级也还能重试保存', async () => { + const slow = await open({ id: 'pf-easy', save: 'slow' }) + await openDialog(slow.page) + await dialog(slow.page).locator('[data-fix-all]').click() + await dialog(slow.page).locator('[data-fix-apply]').click() + await waitFor(slow.page, async () => slow.state.patches.length === 1) + const radios = dialog(slow.page).locator('[role="radio"]') + check('弹窗:保存进行中两个等级都不可点', await radios.nth(0).isDisabled() && await radios.nth(1).isDisabled()) + await radios.nth(1).click({ force: true, timeout: 1000 }).catch(() => {}) + await slow.page.waitForTimeout(200) + check('……点了也不换档、不按别的档重查', slow.state.checks.every((c) => c.level === 'published') + && await radios.nth(0).getAttribute('aria-checked') === 'true') + await waitFor(slow.page, async () => slow.state.checks.length === 2 && await radios.nth(1).isEnabled(), 6000) + check('……存完、重查完,等级又能选', await radios.nth(1).isEnabled()) + + // 问题面板:同一条 + const pane = await open({ id: 'pf-easy', save: 'slow' }) + await pane.page.locator('.react-flow__pane').click({ position: { x: 30, y: 30 } }) + await pane.page.keyboard.press('Alt+KeyP') + await pane.page.locator('[data-problems-mode="publish"]').click() + const panel = pane.page.locator('[data-preflight="panel"]') + await panel.locator('[data-fix-all]').click() + await panel.locator('[data-fix-apply]').click() + await waitFor(pane.page, async () => pane.state.patches.length === 1) + const levels = panel.locator('[role="radio"]') + check('问题面板:保存进行中两个等级都不可点', await levels.nth(0).isDisabled() && await levels.nth(1).isDisabled()) + await levels.nth(1).click({ force: true, timeout: 1000 }).catch(() => {}) + await pane.page.waitForTimeout(200) + check('……点了也不换档', pane.state.checks.every((c) => c.level === 'published') && await levels.nth(0).getAttribute('aria-checked') === 'true') + + const bad = await open({ id: 'pf-easy', save: 'fail' }) + await openDialog(bad.page) + await dialog(bad.page).locator('[data-fix-all]').click() + await dialog(bad.page).locator('[data-fix-apply]').click() + await dialog(bad.page).locator('[data-fix-save-failed]').waitFor() + await dialog(bad.page).locator('[role="radio"]:has-text("受管")').click() + await waitFor(bad.page, async () => bad.state.checks.length === 2) + await bad.page.waitForTimeout(250) + check('存失败之后换了等级:说明和「重试保存」都还在', await dialog(bad.page).locator('[data-fix-save-failed]').count() === 1 + && (await dialog(bad.page).locator('[data-fix-save-failed]').innerText().catch(() => '')).includes('草稿没保存上') + && await dialog(bad.page).getByRole('button', { name: '重试保存' }).count() === 1) + check('……发布照旧不可用,说的是修复还没保存上', await submit(bad.page).isDisabled() + && await submit(bad.page).getAttribute('title') === '修复还没保存上', await submit(bad.page).getAttribute('title')) + bad.state.save = 'ok' + await dialog(bad.page).getByRole('button', { name: '重试保存' }).click() + await waitFor(bad.page, async () => bad.state.checks.length === 3 && await submit(bad.page).isEnabled()) + check('……重试保存:存上、按换过的等级重查、发布可用', bad.state.patches.length === 2 && bad.state.checks[2]?.level === 'governed' + && await submit(bad.page).isEnabled() && await dialog(bad.page).locator('[data-fix-save-failed]').count() === 0) +}) + +await section('保存和页面自己的保存同一套规矩:回滚说明保留、画布锁着不存', async () => { + const { page, state } = await open({ id: 'pf-easy' }) + // 刚从版本历史恢复了旧版本:页面自己的保存会把「回滚到 v2」写进版本说明 + await page.evaluate(() => window.__studio.setState({ pendingNote: '回滚到 v2' })) + await openDialog(page) + await dialog(page).locator('[data-fix-all]').click() + await dialog(page).locator('[data-fix-apply]').click() + await waitFor(page, async () => state.patches.length === 1) + check('版本说明保留「回滚到 v2」,后面接着写发布前修复', /^回滚到 v2;发布前修复:/.test(state.patches[0]?.note ?? ''), state.patches[0]?.note) + + const bad = await open({ id: 'pf-easy', save: 'fail' }) + await openDialog(bad.page) + await dialog(bad.page).locator('[data-fix-all]').click() + await dialog(bad.page).locator('[data-fix-apply]').click() + await dialog(bad.page).locator('[data-fix-save-failed]').waitFor() + // 存失败之后正式运行开始了:画布锁着,这时重试保存不能 PATCH + await bad.page.evaluate(() => { + const s = window.__studio.getState() + window.__studio.setState({ runPhase: 'running', trace: { ...s.trace, runClass: 'formal' } }) + }) + bad.state.save = 'ok' + await dialog(bad.page).getByRole('button', { name: '重试保存' }).click() + await bad.page.waitForTimeout(400) + check('画布锁着(正式运行进行中)时重试保存:不存', bad.state.patches.length === 1, `${bad.state.patches.length} 次保存`) + const why = await dialog(bad.page).locator('[data-fix-save-failed]').innerText().catch(() => '') + check('……说清为什么没存,「重试保存」还在', why.includes('正式运行进行中') + && await dialog(bad.page).getByRole('button', { name: '重试保存' }).count() === 1, why) + check('……发布照旧不可用', await submit(bad.page).isDisabled()) + await bad.page.evaluate(() => window.__studio.setState({ runPhase: 'idle' })) + await dialog(bad.page).getByRole('button', { name: '重试保存' }).click() + await waitFor(bad.page, async () => bad.state.patches.length === 2 && bad.state.checks.length === 2) + check('锁解开之后重试保存:存上、重查', bad.state.patches.length === 2 && bad.state.checks.length === 2) +}) + +await section('全图默认不会被画布弄丢:保存、发布前检查、修复都带着 defaults', async () => { + const { ctx, page, errors, state } = await open({ id: 'pf-defs' }) + const first = await page.evaluate(() => { + const s = window.__studio.getState() + const n = s.nodes.find((x) => x.data.nodeType !== 'input') ?? s.nodes[0] + s.updateNode(n.id, { label: `${n.data.label}·改` }) + return n.id + }) + await page.evaluate(() => window.__studio.getState().save('检查脚本:改个名字')) + await page.waitForTimeout(300) + const saved = state.patches.at(-1)?.graph + check('改了一个节点再保存:全图默认原样带着(以前整个被抹掉,审批 always 悄悄放宽)', + saved?.defaults?.approval === 'always' && saved?.defaults?.model === 'demo-model', + JSON.stringify(saved?.defaults ?? null)) + await openDialog(page) + const sent = state.checks.at(-1)?.graph + check('发布前检查发出去的图带着全图默认', sent?.defaults?.approval === 'always', JSON.stringify(sent?.defaults ?? null)) + await page.keyboard.press('Escape') + const dirty = await page.evaluate(() => { + const s = window.__studio.getState() + const g = { nodes: s.nodes.map((n) => ({ id: n.id, type: n.data.nodeType, position: n.position, data: { label: n.data.label, config: n.data.config } })), + edges: s.edges.map((e) => ({ id: e.id, source: e.source, target: e.target, sourceHandle: e.sourceHandle ?? null, label: '' })), + defaults: { approval: 'dangerous', model: 'demo-model' } } + s.applyFixes(g, '发布前修复:全图默认') + return window.__studio.getState().dirty + }) + check('修复只改了全图默认:画布照样算有改动', dirty === true) + await page.evaluate(() => window.__studio.getState().save('检查脚本:修复')) + await page.waitForTimeout(300) + const fixed = state.patches.at(-1)?.graph + check('修复改的全图默认存了进去', fixed?.defaults?.approval === 'dangerous' && fixed?.defaults?.model === 'demo-model', + JSON.stringify(fixed?.defaults ?? null)) + check('没有运行时报错', errors.length === 0, errors[0] ?? '') + await ctx.close() + void first +}) + +await section('360px 下不横向滚动', async () => { + const { page } = await open({ width: 360, height: 780 }) + // 窄屏上「发布」收在「更多操作」里 + await page.getByRole('button', { name: '更多操作' }).click() + await page.locator('[role="menu"][aria-label="更多操作"] [role^="menuitem"]', { hasText: '发布' }).click() + await dialog(page).waitFor() + await dialog(page).locator('[data-fix-all]').waitFor() + await dialog(page).locator('[data-fix-all]').click() + await dialog(page).locator('[data-fix-preview="ready"]').waitFor() + // 量弹窗本身:编排页的工具栏在 360px 下本来就比视口宽(那是页面自己的事),弹窗是 fixed 的一层, + // 要守的是它里面的东西——问题、选项、预览——一样都不伸出视口、不出横向滚动条 + const m = await page.evaluate(() => { + const d = document.querySelector('[role="dialog"]') + const r = d.getBoundingClientRect() + const wide = [...d.querySelectorAll('*')].filter((el) => { + const b = el.getBoundingClientRect() + return b.width > 0 && (b.right > window.innerWidth + 0.5 || b.left < -0.5) + }).map((el) => el.tagName + (el.className ? `.${String(el.className).split(' ')[0]}` : '')) + const scrollers = [...d.querySelectorAll('*')].filter((el) => el.scrollWidth > el.clientWidth + 1 + && !['visible', 'hidden', 'clip'].includes(getComputedStyle(el).overflowX)).length + return { left: r.left, right: r.right, vw: window.innerWidth, wide: wide.slice(0, 4), scrollers, own: d.scrollWidth - d.clientWidth } + }) + check('360px:弹窗在视口里、里面没有元素伸出去、没有横向滚动条', m.left >= 0 && m.right <= m.vw && !m.wide.length && m.scrollers === 0 && m.own <= 0, JSON.stringify(m)) + await shoot(page, 'publish-dialog-360', page) +}) + +check('检查没有写库(兜底拦下的写请求)', writes.length === 0, writes.slice(0, 4).join('、')) +console.log(failed ? `\n${failed} 项未通过` : '\n全部通过') +await browser.close() +process.exit(failed ? 1 : 0) diff --git a/scripts/check-runs.mjs b/scripts/check-runs.mjs index bc7b5f29..5d5fb462 100644 --- a/scripts/check-runs.mjs +++ b/scripts/check-runs.mjs @@ -498,6 +498,28 @@ await section('失败:指到节点、原因、下一步', async () => { check('跑的是这次运行时的图快照', Array.isArray(startBody?.graph?.nodes) && startBody.graph.nodes.length > 0 && !startBody.workflow_id) fakes.delete('POST /api/runs') + // 补上之后发起被拒:绑定的工具在本机不存在(422 run_tool_missing)。和画布上发起被拒同一套, + // 报错里给直达入口(数据源工具去数据页),不是只有一句 toast + await page.goto(`${WEB}/runs/${missingInput.id}`, { waitUntil: 'networkidle' }) + await page.locator('[data-run-banner=failed] [data-action=rerun]').waitFor() + fakes.set('POST /api/runs', () => ({ status: 422, json: { + detail: '绑定的工具在本机不存在:「查数」(调用工具)绑的 db_query__nope。去数据页接入,或在节点里重新选', code: 'run_tool_missing' } })) + await page.locator('[data-run-banner=failed] [data-action=rerun]').click() + const refused = page.getByRole('dialog') + await refused.waitFor() + await refused.locator('input, textarea').first().fill('检查用的主题') + await refused.getByRole('button', { name: '重新运行' }).click() + const toData = page.getByRole('button', { name: '去数据页接入' }) + await toData.waitFor({ timeout: 5000 }).catch(() => {}) + check('补上重新运行被拒(工具不在本机):报错里有「去数据页接入」', await toData.count() === 1) + const said = await page.getByText('绑定的工具「db_query__nope」在本机不存在').first().innerText().catch(() => '') + check('……点名那个工具,说清这次运行没有发起', said.includes('db_query__nope') && said.includes('没有发起'), said.slice(0, 120)) + check('……人还在这条运行上', new URL(page.url()).pathname === `/runs/${missingInput.id}`, page.url()) + await toData.click().catch(() => {}) + await page.waitForURL((u) => u.pathname.startsWith('/data'), { timeout: 5000 }).catch(() => {}) + check('……点了去数据页', new URL(page.url()).pathname.startsWith('/data'), page.url()) + fakes.delete('POST /api/runs') + // 正式运行缺输入:只能跑「当前」发布的版本。之后又发布过新版的话,带着原来 // 的版本号会被后端 409(「vN 不是当前发布版本」),所以不带,并在弹窗里说清跑哪一版 const published = (await getJson('/workflows')).find((w) => (w.published_version ?? 0) > 1) diff --git a/scripts/check-studio.mjs b/scripts/check-studio.mjs index cd53edc3..887726d0 100644 --- a/scripts/check-studio.mjs +++ b/scripts/check-studio.mjs @@ -245,6 +245,9 @@ async function open({ width = 1440, height = 900, path = '/studio/st-main', pref } return route.fallback() }) + // 发布前检查和自动修复是只读的 POST:这里按老后端(没有这两个接口)答,发布弹窗照原样。 + // 这两个接口本身的行为在 check-publish 里查 + await page.route(/\/api\/workflows\/st-[a-z]+\/(publish-check|autofix)$/, (route) => json(route, { detail: 'Not Found' }, 404)) await page.route(/\/api\/conversations(\/.*)?(\?.*)?$/, (route) => { const req = route.request() const url = new URL(req.url()) @@ -2024,6 +2027,99 @@ await section('删掉眼前这张', async () => { await solo.ctx.close() }) +// ================================================================ E2W +await section('发起就被拒(工具不存在)给修复入口;整形节点解析上游失败,入口指到上游(E2W-4/5)', async () => { + const { ctx, page, errors } = await open() + // lib/explain 的纯函数:按页面自己加载的那一份取 + const x = await page.evaluate(async () => { + const m = await window.__appImport('/src/lib/explain.ts') + const e = (msg, code) => Object.assign(new Error(msg), code ? { code } : {}) + return { + data: m.explainRunError('绑定的工具在本机不存在:「查数」(调用工具)绑的 db_query__nope;「团队」的成员「研究员」绑的 db_schema__nope。去数据页接入,或在节点里重新选'), + custom: m.explainRunError('绑定的工具在本机不存在:「查数」(Agent)绑的 lookup_order。去工具页接入,或在节点里重新选'), + mcp: m.explainRunError('绑定的工具在本机不存在:「查数」(Agent)绑的 mcp:shop/query。去工具页接入,或在节点里重新选'), + coded: m.explainStartError(e('发起不了:这几个工具不在了', 'run_tool_missing')), + other: m.explainStartError(e('那张工作流不在了', 'workflow_gone')), + up: m.explainRunError('上游「快速回答」输出的不是合法 JSON(第 12 列附近),常见原因是字符串里有没转义的英文引号;让模型交结构化数据请用 output_schema + cite_fields'), + pointed: m.explainRunError('模板渲染出来的不是合法 JSON(第 1 行第 20 列附近)。模板里 {{ vars.result }} 是模型写的文字,放进 JSON 要写成 {{ vars.result | json }}(外面不要再加引号)'), + empty: m.explainRunError('模板渲染出来的不是合法 JSON(第 1 行第 20 列附近)。模板里 {{ vars.x }} 取出来是空的,这里缺一个值:检查路径写对没有、前面的节点有没有产出'), + old: m.explainRunError('模板渲染出来的不是合法 JSON(第 1 行第 20 列附近)。检查模板里的引号、逗号,字符串值要用 | json 过滤器输出'), + } + }) + check('工具不存在:数据源工具(db_query__ / db_schema__)指向数据页,不给接着跑', + x.data.fixTo === '/data' && x.data.fixLabel === '去数据页接入' && x.data.continuable === false, JSON.stringify(x.data).slice(0, 160)) + check('……自定义工具指向工具页的自定义工具,一个工具时标题点名', x.custom.fixTo === '/tools/custom' && x.custom.fixLabel === '去工具页接入' + && x.custom.continuable === false && x.custom.title === '绑定的工具「lookup_order」在本机不存在', `${x.custom.fixTo} ${x.custom.title}`) + check('……MCP 工具指向工具页的 MCP 接入', x.mcp.fixTo === '/tools/mcp', x.mcp.fixTo) + check('发起报错认机读码 run_tool_missing(话改了也认),别的码不认', x.coded?.continuable === false && x.coded?.fixTo === '/tools' + && x.other === null, JSON.stringify({ coded: x.coded?.fixTo, other: x.other })) + check('上游写坏了 JSON:入口指到上游「快速回答」(fixNode),不指整形节点', x.up.fixNode === '快速回答' && x.up.fix === 'canvas' && !x.up.continuable, x.up.fixNode) + check('模板里 {{ 开头(后端给了准确改法):不再追加「改用 output_schema」', !x.pointed.action.includes('output_schema') + && x.pointed.action.includes('| json') && !x.empty.action.includes('output_schema'), `${x.pointed.action} | ${x.empty.action}`) + check('……老的笼统说法照旧追加那一句', x.old.action.includes('output_schema + cite_fields'), x.old.action) + + // 画布上发起:POST /runs 回 422 run_tool_missing,报错里给「去数据页接入」,点了就去 + const DETAIL = '绑定的工具在本机不存在:「查询销量」(调用工具)绑的 db_query__nope。去数据页接入,或在节点里重新选' + await page.route(/\/api\/runs$/, (route) => (route.request().method() === 'POST' + ? route.fulfill({ status: 422, contentType: 'application/json', body: JSON.stringify({ detail: DETAIL, code: 'run_tool_missing' }) }) + : route.fallback())) + // 这张图有一处「还没选工具」会把运行按钮置灰:等校验落定后清掉,只看发起被拒这一步 + await waitAnalysis(page) + await S(page, () => window.__studio.setState({ issues: [] })) + await page.waitForTimeout(150) + await page.locator('[data-run-control] button[aria-label="运行"]').click() + await page.locator('[role="dialog"][aria-label="探索运行"]').locator('textarea, input').first().fill('x') + await page.locator('[role="dialog"][aria-label="探索运行"] button.btn-primary').click() + const fixBtn = page.getByRole('button', { name: '去数据页接入' }) + await fixBtn.waitFor({ timeout: 4000 }).catch(() => {}) + check('发起被拒:报错里有「去数据页接入」', await fixBtn.count() === 1) + const toastText = await page.locator('text=绑定的工具「db_query__nope」在本机不存在').first().innerText().catch(() => '') + check('……标题点名那个工具,原因里说这次运行没有发起', toastText.includes('db_query__nope') && toastText.includes('没有发起'), toastText.slice(0, 120)) + check('……没有「接着跑」', await page.getByRole('button', { name: '接着跑' }).count() === 0) + if (SHOTS) { + for (const theme of ['dark', 'light']) { + await page.evaluate((t) => document.documentElement.setAttribute('data-theme', t), theme) + await page.waitForTimeout(200) + await page.screenshot({ path: `${SHOTS}/studio-run-tool-missing-${theme}.png`, clip: { x: 300, y: 40, width: 900, height: 240 } }) + } + } + await fixBtn.click().catch(() => {}) + await page.waitForTimeout(400) + check('……点了去数据页', new URL(page.url()).pathname.startsWith('/data'), page.url()) + await ctx.close() + + // 整形节点解析上游的文字失败:轮次顶上的入口打开上游「快速回答」的设置,不是「汇总」 + const run = await open() + const UP = '上游「快速回答」输出的不是合法 JSON(第 12 列附近),常见原因是字符串里有没转义的英文引号;让模型交结构化数据请用 output_schema + cite_fields' + await S(run.page, (msg) => { + const st = window.__studio + const t = Date.now() / 1000 - 5 + st.setState({ run: { id: 'st-run-json', workflow_id: 'st-main', workflow_name: '__studio_check__', status: 'queued', + input: {}, output: {}, error: null, usage: {}, run_class: 'exploratory', version: null } }) + // 真实的 node.started 带着节点名和类型:流里的步骤按名字认上游 + const list = [['run.started', null, {}], ['node.started', 'answer', { label: '快速回答', node_type: 'llm' }], + ['node.finished', 'answer', { duration_ms: 3 }], ['node.started', 'sum', { label: '汇总', node_type: 'transform' }], + ['node.failed', 'sum', { error: msg, duration_ms: 2 }], ['run.failed', null, { error: msg, node_id: 'sum' }]] + list.forEach((e, i) => st.getState().applyEvent({ seq: i + 1, type: e[0], node_id: e[1], data: e[2], ts: t + i * 0.2 })) + }, UP) + const turnFix = run.page.locator('[data-turn-error] [data-fix="canvas"]') + await turnFix.waitFor({ timeout: 4000 }).catch(() => {}) + if (SHOTS) { + for (const theme of ['dark', 'light']) { + await run.page.evaluate((t) => document.documentElement.setAttribute('data-theme', t), theme) + await run.page.waitForTimeout(200) + await run.page.locator('[data-turn-error]').first().screenshot({ path: `${SHOTS}/studio-upstream-json-fix-${theme}.png` }).catch(() => {}) + } + } + const label = await turnFix.innerText().catch(() => '') + check('轮次报错的入口:打开上游「快速回答」的设置', label.includes('打开「快速回答」的设置'), label) + await turnFix.click().catch(() => {}) + await run.page.waitForTimeout(300) + check('……点了选中上游节点(不是整形节点)', await S(run.page, () => window.__studio.getState().selectedId) === 'answer') + check('没有运行时报错', errors.length === 0 && run.errors.length === 0, [...errors, ...run.errors].slice(0, 2).join(' | ')) + await run.ctx.close() +}) + check('整个检查没有弹出原生 confirm / prompt', dialogs === 0, `${dialogs} 次`) check('检查没有写库(兜底拦下的写请求)', writes.length === 0, writes.slice(0, 4).join('、'))