From 5f616616250151d9edc058b2eebbacb1d3ac4ecb Mon Sep 17 00:00:00 2001 From: ThearchyHelios Date: Wed, 21 Oct 2026 17:53:56 +0800 Subject: [PATCH 01/12] =?UTF-8?q?=E5=88=86=E6=9E=90=E5=B7=A5=E5=85=B7?= =?UTF-8?q?=E7=9A=84=20tool.end=20=E5=8F=A6=E5=B8=A6=E4=B8=80=E5=B0=8F?= =?UTF-8?q?=E6=AE=B5=E5=88=A4=E5=AE=9A=EF=BC=88tools/analysis.event=5Fview?= =?UTF-8?q?=EF=BC=9A=E7=BC=96=E5=8F=B7=E3=80=81=E7=AE=97=E5=AD=90=E3=80=81?= =?UTF-8?q?=E6=A8=A1=E5=BC=8F=E3=80=81=E7=8A=B6=E6=80=81=E3=80=81=E5=88=A4?= =?UTF-8?q?=E5=AE=9A=E7=A0=81=E3=80=81=E6=96=B9=E5=90=91=E3=80=81=E5=88=A4?= =?UTF-8?q?=E5=AE=9A=E8=AF=AD=E3=80=81=E7=AE=97=E4=B8=8D=E4=BA=86=E7=9A=84?= =?UTF-8?q?=E5=8E=9F=E5=9B=A0=E7=A0=81=EF=BC=89=EF=BC=9Apreview=20?= =?UTF-8?q?=E5=8F=AA=E6=98=AF=E7=BB=93=E8=AE=BA=20JSON=20=E7=9A=84?= =?UTF-8?q?=E5=89=8D=E4=B8=80=E6=88=AA=EF=BC=88=E6=88=AA=E5=88=B0=202000?= =?UTF-8?q?=20=E4=B8=AA=E5=AD=97=E7=AC=A6=EF=BC=89=EF=BC=8C=E5=8F=AF?= =?UTF-8?q?=E5=8A=A0=E6=8B=86=E8=A7=A3=E3=80=81=E5=8F=AF=E6=AF=94=E6=80=A7?= =?UTF-8?q?=E6=A3=80=E6=9F=A5=E7=9A=84=E5=88=A4=E5=AE=9A=E8=AF=AD=E9=83=BD?= =?UTF-8?q?=E5=9C=A8=E6=88=AA=E6=8E=89=E7=9A=84=E9=82=A3=E6=AE=B5=E9=87=8C?= =?UTF-8?q?=EF=BC=8C=E9=97=AE=E6=95=B0=E6=8D=AE=E6=97=B6=E9=97=B4=E7=BA=BF?= =?UTF-8?q?=E7=85=A7=E8=BF=99=E4=B8=80=E6=AE=B5=E6=98=BE=E7=A4=BA=EF=BC=8C?= =?UTF-8?q?=E4=B8=8D=E4=BB=8E=20preview=20=E9=87=8C=E6=8B=BC=E3=80=82?= =?UTF-8?q?=E7=BC=96=E5=8F=B7=E5=92=8C=E4=BA=A4=E5=9B=9E=E6=A8=A1=E5=9E=8B?= =?UTF-8?q?=E7=9A=84=E3=80=8C=E3=80=90=E5=88=86=E6=9E=90=20An=E3=80=91?= =?UTF-8?q?=E3=80=8D=E5=90=8C=E4=B8=80=E4=B8=AA=EF=BC=88AnalysisScope.next?= =?UTF-8?q?=5Falias=EF=BC=8C=E8=AE=B0=E5=8F=B0=E8=B4=A6=E6=97=B6=20add=5Fr?= =?UTF-8?q?esult=20=E7=B4=A7=E6=8E=A5=E7=9D=80=E6=8C=89=E5=AE=83=E7=BC=96?= =?UTF-8?q?=EF=BC=89=EF=BC=8C=E5=86=99=E9=94=99=E7=9A=84=E8=B0=83=E7=94=A8?= =?UTF-8?q?=E4=B8=8D=E5=B8=A6=E8=BF=99=E4=B8=80=E6=AE=B5=E3=80=81=E4=B8=8D?= =?UTF-8?q?=E5=8D=A0=E7=BC=96=E5=8F=B7=EF=BC=9B=E5=8D=8F=E4=BD=9C=E6=88=90?= =?UTF-8?q?=E5=91=98=E6=8C=89=E8=87=AA=E5=B7=B1=E7=9A=84=E5=88=86=E6=9E=90?= =?UTF-8?q?=E5=AE=B9=E5=99=A8=E7=BC=96=E5=8F=B7=EF=BC=9BAgent=20=E5=86=99?= =?UTF-8?q?=E7=9A=84=E5=81=87=E8=AE=BE=E4=B8=8D=E5=B8=A6=E3=80=82=E6=B5=8B?= =?UTF-8?q?=E8=AF=95=EF=BC=9A=E7=BC=96=E5=8F=B7=E4=B8=8E=E4=BA=A4=E5=9B=9E?= =?UTF-8?q?=E6=A8=A1=E5=9E=8B=E7=9A=84=E4=B8=80=E8=87=B4=E3=80=81=E5=88=A4?= =?UTF-8?q?=E5=AE=9A=E8=AF=AD=E5=8F=AA=E5=9C=A8=E8=BF=99=E4=B8=80=E6=AE=B5?= =?UTF-8?q?=E9=87=8C=E3=80=81=E7=AE=97=E4=B8=8D=E4=BA=86=E7=9A=84=E5=B8=A6?= =?UTF-8?q?=E5=8E=9F=E5=9B=A0=E7=A0=81=E3=80=81=E6=88=90=E5=91=98=E8=87=AA?= =?UTF-8?q?=E5=B7=B1=E7=BC=96=E5=8F=B7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/app/engine/nodes/llm.py | 9 ++- backend/app/engine/nodes/multi.py | 5 +- backend/app/tools/analysis.py | 19 ++++- backend/tests/test_analysis_tool_events.py | 88 ++++++++++++++++++++++ 4 files changed, 116 insertions(+), 5 deletions(-) create mode 100644 backend/tests/test_analysis_tool_events.py diff --git a/backend/app/engine/nodes/llm.py b/backend/app/engine/nodes/llm.py index 7dff9e3f..2b90393e 100644 --- a/backend/app/engine/nodes/llm.py +++ b/backend/app/engine/nodes/llm.py @@ -55,6 +55,7 @@ from app.tools.analysis import ( SCOPE_KEY as ANALYSIS_SCOPE_KEY, AnalysisScope, call_error_text, + event_view, input_snapshot, is_analysis_tool, payload_of, @@ -550,8 +551,11 @@ async def run_agent(state: GraphState, ctx: NodeContext) -> dict[str, Any]: query = query_entry_fields(content) if evidence_on and ok and is_platform_query(name) else None if query and not await snapshot_owned(query["artifact"], ctx.run.run_id, name): query = None - # 分析工具交回的结论工件:同查询快照,记进 tool.end(封存范围内),证据接口、时间线从这里认这一步的结论 + # 分析工具交回的结论工件:同查询快照,记进 tool.end(封存范围内),证据接口、时间线从这里认这一步的结论。另带一小段 + # 判定(event_view:编号、算子、判定码、判定语……):preview 只是结论的前一截,判定语多半被截掉。编号和记台账时 + # 交回模型的「【分析 An】」是同一个(_record_call 紧接着按同一个容器编),不记台账的执行没有编号 analysis = payload_of(content) if ok and is_analysis_tool(name) else None + alias = scope.next_alias() if analysis and scope is not None and evidence_on else None ctx.emit( EventType.TOOL_END if ok else EventType.TOOL_ERROR, tool=name, @@ -560,7 +564,8 @@ async def run_agent(state: GraphState, ctx: NodeContext) -> dict[str, Any]: preview=content[:2000], artifact=snapshot_id, **({"query_artifact": query["artifact"]} if query else {}), - **({"analysis_artifact": analysis["artifact"]} if analysis else {}), + **({"analysis_artifact": analysis["artifact"], "analysis": event_view(analysis["result"], alias)} + if analysis else {}), # 查询当时数据源的表结构快照:和查询快照一样落在封存范围内,证据接口从这里认它 **({"schema_artifact": query["schema_artifact"]} if query and query.get("schema_artifact") else {}), # 这次查询的 SQL 检查结果单独带上:preview 截到前 2000 个字符,checks 通常在截掉的那段里 diff --git a/backend/app/engine/nodes/multi.py b/backend/app/engine/nodes/multi.py index da37a869..11892067 100644 --- a/backend/app/engine/nodes/multi.py +++ b/backend/app/engine/nodes/multi.py @@ -38,6 +38,7 @@ from app.tools.analysis import ( SCOPE_KEY as ANALYSIS_SCOPE_KEY, AnalysisScope, call_error_text, + event_view, input_snapshot, is_analysis_tool, payload_of, @@ -461,7 +462,9 @@ async def run_supervisor(state: GraphState, ctx: NodeContext) -> dict[str, Any]: agent=name, call_id=cid, preview=content[:1500], duration_ms=int((time.perf_counter() - started) * 1000), **failure, **({"checks": checks} if checks else {}), - **({"analysis_artifact": analysis["artifact"]} if analysis else {})) + # 判定的那一小段同 Agent 节点(event_view);编号是成员自己的分析容器里的,紧接着 _numbered 按它编 + **({"analysis_artifact": analysis["artifact"], + "analysis": event_view(analysis["result"], scope.next_alias())} if analysis else {})) if analysis is not None and analysis["result"].get("status") == "refused": ctx.emit(EventType.LOG, level="warn", code=ANALYSIS_REFUSED, agent=name, call_id=cid, tool=tname, reason_code=analysis["result"].get("reason_code"), diff --git a/backend/app/tools/analysis.py b/backend/app/tools/analysis.py index fc122fdc..b70e0c99 100644 --- a/backend/app/tools/analysis.py +++ b/backend/app/tools/analysis.py @@ -86,9 +86,13 @@ class AnalysisScope: def add_query(self, alias: str, snapshot: InputSnapshot) -> None: self.queries[alias] = snapshot + def next_alias(self) -> str: + """下一份结论在本节点的编号:工具执行完、记台账之前,tool.end 先按它写上这份结论的编号(event_view)。""" + return f"{contract.CATALOG_PREFIX}{len(self.results) + 1}" + def add_result(self, result: AnalysisResult, artifact: str) -> str: """记一份结论,返回它在本节点的编号(A1、A2……,按记下的先后)。""" - alias = f"{contract.CATALOG_PREFIX}{len(self.results) + 1}" + alias = self.next_alias() self.results[alias] = result self.artifacts[alias] = artifact return alias @@ -363,6 +367,17 @@ def reply_text(alias: str, result: AnalysisResult) -> str: return REPLY_HEAD.format(alias=alias) + "\n" + json.dumps(body, ensure_ascii=False, default=str) +def event_view(result: AnalysisResult, alias: str | None = None) -> dict[str, Any]: + """分析工具的 tool.end 上带的那一小段(tool.end 的 analysis):编号、算子、模式、状态、判定码、方向、判定语、算不了的原因码。 + preview 只是结论 JSON 的前一截(截到 2000 个字符),判定语多半在截掉的那段里;时间线照这一段显示,不从 preview 里拼。 + 编号是这份结论在本节点的编号(「【分析 An】」那个,AnalysisScope.next_alias),不记台账的执行里没有编号,为 None。 + 判定语是系统渲染的(遮罩列不当维度、度量,里面没有遮罩值);Agent 写的假设不带。""" + verdict = result.get("verdict") or {} + return {"alias": alias, "op": result.get("op"), "mode": result.get("mode"), "status": result.get("status"), + "verdict": verdict.get("code"), "direction": verdict.get("direction"), "phrase": result.get("phrase") or "", + "reason_code": result.get("reason_code")} + + def hypothesis_of(value: Any) -> str | None: """agent(或模板作者)写的「这次在验证什么」:去掉首尾空白、截到 HYPOTHESIS_MAX 字;空的为 None。""" text = str(value or "").strip() @@ -371,6 +386,6 @@ def hypothesis_of(value: Any) -> str | None: __all__ = [ "ANALYSIS_TOOLS", "CALL_ERROR_HEAD", "REFUSED_CODE", "REPLY_HEAD", "SCOPE_KEY", "AnalysisScope", "call_error_text", "context_of", - "hypothesis_of", "input_snapshot", "is_analysis_tool", "masked_of", "payload_of", "prepare", "reply_text", + "event_view", "hypothesis_of", "input_snapshot", "is_analysis_tool", "masked_of", "payload_of", "prepare", "reply_text", "run_snapshot", "scope_of", "settings_of", "store", "tool_context", ] diff --git a/backend/tests/test_analysis_tool_events.py b/backend/tests/test_analysis_tool_events.py new file mode 100644 index 00000000..f41a1237 --- /dev/null +++ b/backend/tests/test_analysis_tool_events.py @@ -0,0 +1,88 @@ +"""分析工具的 tool.end 带的那一小段判定(app/tools/analysis.event_view):时间线照它显示算子、判定语和编号,不从 preview 里拼。 + +preview 只是结论 JSON 的前一截(截到 2000 个字符),可加拆解、可比性检查的判定语都在截掉的那段里;tool.end 的 analysis 另带 +编号、算子、模式、状态、判定码、方向、判定语、算不了的原因码。编号和交回模型的「【分析 An】」是同一个;写错的调用(tool.error) +不带这一段、不占编号。接真算子(不用替身),库是虚构的零售库 shop,模型换成脚本(tests/test_analysis_tools.Script)。 +""" +from __future__ import annotations + +import json + +from langchain_core.messages import ToolMessage + +from app.analysis import contract +from app.core import artifact_store +from app.engine.runner import run_manager +from app.tools.analysis import event_view +from tests.test_analysis_tools import ( # noqa: F401 - 夹具按名字引入 + DECOMPOSE, DECOMPOSE_ARGS, SQL, TOOL, Script, agent_graph, engine_up, events, node, run_graph, shop, wait, +) + +BASELINE = contract.TOOL_NAMES["baseline"] +COMPARABLE = contract.TOOL_NAMES["comparable"] +WRONG = {**DECOMPOSE_ARGS, "measure": "sales"} +#: 整体的周序列(Q2):波动基线、两周的可比性读它 +TOTALS = "SELECT week, SUM(amount) AS gmv FROM orders GROUP BY week ORDER BY week" +WEEKLY = {"query": "Q2", "period": "week", "measure": "gmv", "label": "销售额"} +TWO_WEEKS = {"query": "Q2", "period": "week", "base": "2026-W41", "target": "2026-W42", "measure": "gmv"} + + +def _replies(script: Script) -> list[str]: + return [m.content for m in script.seen[-1] if isinstance(m, ToolMessage)] + + +def test_event_view_has_the_judgment_without_the_hypothesis(): + result = {"op": "decompose", "mode": "additive", "status": "ok", "reason_code": None, "phrase": "销售额下降 194", + "verdict": {"code": "driver", "subjects": [["华东"]], "direction": "down"}, "hypothesis": "华东拖累", + "table": {"columns": ["region"], "rows": [["华东"]]}} + assert event_view(result, "A2") == {"alias": "A2", "op": "decompose", "mode": "additive", "status": "ok", + "verdict": "driver", "direction": "down", "phrase": "销售额下降 194", + "reason_code": None} + refused = {"op": "baseline", "mode": None, "status": "refused", "reason_code": "history_short", + "phrase": "波动基线没有做成:只有 1 期历史,至少要 8 期", "verdict": None} + assert event_view(refused) == {"alias": None, "op": "baseline", "mode": None, "status": "refused", "verdict": None, + "direction": None, "phrase": "波动基线没有做成:只有 1 期历史,至少要 8 期", + "reason_code": "history_short"} + + +async def test_tool_end_carries_the_phrase_and_the_same_number_the_model_got(monkeypatch, shop): + script = Script(monkeypatch, [(TOOL, {"sql": SQL}), (TOOL, {"sql": TOTALS}), (DECOMPOSE, WRONG), + (DECOMPOSE, DECOMPOSE_ARGS), (BASELINE, WEEKLY), (COMPARABLE, TWO_WEEKS)]) + row = await run_graph(agent_graph(tools=(TOOL, DECOMPOSE, BASELINE, COMPARABLE))) + ends = [e for e in await events(row.id, "tool.end", "bot") if e.get("analysis_artifact")] + assert [e["analysis"]["alias"] for e in ends] == ["A1", "A2", "A3"] + # 交回模型的编号同一套:写错的那次不占编号 + replies = [r.split("\n", 1)[0] for r in _replies(script) if r.startswith("【分析")] + assert replies == ["【分析 A1】", "【分析 A2】", "【分析 A3】"] + for end in ends: + result = artifact_store.load(end["analysis_artifact"]) + assert end["analysis"] == event_view(result, end["analysis"]["alias"]) + decompose, baseline, comparable = (e["analysis"] for e in ends) + assert (decompose["op"], decompose["mode"], decompose["status"], decompose["verdict"]) == ( + "decompose", "additive", "ok", "driver") + assert decompose["phrase"].startswith("销售额下降") and decompose["phrase"] not in ends[0]["preview"], \ + "可加拆解的判定语在 preview 截掉的那段里,只能从这一段读" + assert (baseline["status"], baseline["reason_code"]) == ("refused", "history_short") + assert baseline["phrase"].startswith(contract.OP_LABELS["baseline"] + "没有做成") + assert comparable["op"] == "comparable" and comparable["phrase"].startswith("两期") + # 写错的调用:tool.error 带调用错误的代码,不带判定 + [error] = [e for e in await events(row.id, "tool.error", "bot")] + assert error["call_error"] == "unknown_column" and "analysis" not in error + + +async def test_a_team_member_numbers_its_own_analyses(monkeypatch, shop): + Script(monkeypatch, [(TOOL, {"sql": SQL}), (DECOMPOSE, DECOMPOSE_ARGS)], + route={"assignments": [{"agent": "分析员", "instruction": "拆一下销售额的变化"}], "done": False}) + nodes = [node("start", "input"), + node("team", "supervisor", goal="销售额为什么下降", approval="never", max_rounds=2, agents=[ + {"name": "分析员", "system": "你负责拆解", "tools": [TOOL, DECOMPOSE], "max_steps": 4}]), + node("out", "output", fields=[{"name": "a", "value": "{{ nodes.team.text }}"}])] + run = await run_manager.start(graph={"nodes": nodes, "edges": [{"source": "start", "target": "team"}, + {"source": "team", "target": "out"}]}, + input_payload={}) + row = await wait(run.id) + assert row.status == "succeeded", row.error + [end] = [e for e in await events(row.id, "tool.end", "team") if e.get("analysis_artifact")] + assert end["agent"] == "分析员" + assert (end["analysis"]["alias"], end["analysis"]["op"], end["analysis"]["verdict"]) == ("A1", "decompose", "driver") + assert json.dumps(end["analysis"], ensure_ascii=False).count("下降是不是集中在某个地区") == 0, "Agent 写的假设不带" From 998ab23bae81ddd02aad3e70bf4d40ddb243abb5 Mon Sep 17 00:00:00 2001 From: ThearchyHelios Date: Wed, 21 Oct 2026 18:02:34 +0800 Subject: [PATCH 02/12] =?UTF-8?q?=E6=8A=A5=E5=91=8A=E6=9C=AB=E5=B0=BE?= =?UTF-8?q?=E3=80=8C=E5=88=86=E6=9E=90=E8=8C=83=E5=9B=B4=E3=80=8D=E9=87=8C?= =?UTF-8?q?=E7=9A=84=E7=BC=96=E5=8F=B7=E7=82=B9=E5=BE=97=E5=BC=80=EF=BC=9A?= =?UTF-8?q?=E8=AF=81=E6=8D=AE=E6=8E=A5=E5=8F=A3=E5=8A=A0=20GET=20/runs/{id?= =?UTF-8?q?}/evidence/analyses/{alias}=EF=BC=88=3Freport=3D=20=E5=90=8C?= =?UTF-8?q?=E7=89=87=E6=AE=B5=E6=8E=A5=E5=8F=A3=EF=BC=89=EF=BC=8C=E4=BA=A4?= =?UTF-8?q?=E5=9B=9E=E9=82=A3=E4=BB=BD=E5=88=86=E6=9E=90=E7=BB=93=E8=AE=BA?= =?UTF-8?q?=E7=9A=84=E5=87=BA=E5=A4=84=E9=93=BE=EF=BC=88=E5=88=A4=E5=AE=9A?= =?UTF-8?q?=20=E2=86=92=20=E6=96=B9=E6=B3=95=20=E2=86=92=20=E8=AE=A1?= =?UTF-8?q?=E7=AE=97=20=E2=86=92=20=E8=BE=93=E5=85=A5=20=E2=86=92=20?= =?UTF-8?q?=E5=A4=8D=E7=AE=97=20=E2=86=92=20=E5=B0=81=E5=AD=98=EF=BC=8C?= =?UTF-8?q?=E5=92=8C=E7=82=B9=E5=BC=80=E6=AD=A3=E6=96=87=E9=87=8C=E5=AE=83?= =?UTF-8?q?=E7=9A=84=E5=88=A4=E5=AE=9A=E8=AF=AD=E5=90=8C=E4=B8=80=E6=9D=A1?= =?UTF-8?q?=EF=BC=8C=E6=8C=89=E5=AF=BC=E5=87=BA=E9=A1=B5=E5=B7=B2=E6=9C=89?= =?UTF-8?q?=E7=9A=84=20=5Fscope=5Fchain=EF=BC=89=EF=BC=8C=E6=AD=A3?= =?UTF-8?q?=E6=96=87=E6=B2=A1=E5=BC=95=E7=94=A8=E7=9A=84=E7=BB=93=E8=AE=BA?= =?UTF-8?q?=EF=BC=88=E7=AE=97=E4=B8=8D=E4=BA=86=E7=9A=84=E3=80=81=E5=8F=AF?= =?UTF-8?q?=E6=AF=94=E6=80=A7=E6=A3=80=E6=9F=A5=EF=BC=89=E4=B9=9F=E7=82=B9?= =?UTF-8?q?=E5=BE=97=E5=BC=80=EF=BC=9B=E5=8F=AA=E8=AE=A4=E8=BF=99=E4=BB=BD?= =?UTF-8?q?=E6=8A=A5=E5=91=8A=E8=AF=81=E6=8D=AE=E7=9B=AE=E5=BD=95=E9=87=8C?= =?UTF-8?q?=E7=9A=84=E5=88=86=E6=9E=90=E7=BB=93=E8=AE=BA=EF=BC=8C=E6=B2=A1?= =?UTF-8?q?=E6=9C=89=E7=9A=84=20404=EF=BC=88evidence=5Fanalysis=5Fnot=5Ffo?= =?UTF-8?q?und=EF=BC=89=E3=80=82=E9=80=89=E6=8A=A5=E5=91=8A=E3=80=81?= =?UTF-8?q?=E6=96=87=E6=A1=A3=E5=AE=8C=E5=A5=BD=E4=B8=8E=E5=90=A6=E7=9A=84?= =?UTF-8?q?=E5=87=A0=E7=A7=8D=E9=94=99=E8=AF=AF=E6=8C=AA=E8=BF=9B=20=5Fope?= =?UTF-8?q?n=5Freport=EF=BC=8C=E7=89=87=E6=AE=B5=E6=8E=A5=E5=8F=A3?= =?UTF-8?q?=E5=92=8C=E5=AE=83=E5=85=B1=E7=94=A8=E5=90=8C=E4=B8=80=E5=A5=97?= =?UTF-8?q?=E9=94=99=E8=AF=AF=E7=A0=81=E3=80=82=E6=B5=8B=E8=AF=95=EF=BC=9A?= =?UTF-8?q?=E5=90=8C=E4=B8=80=E6=9D=A1=E9=93=BE=E3=80=81=E7=AE=97=E4=B8=8D?= =?UTF-8?q?=E4=BA=86=E7=9A=84=E7=85=A7=E5=AE=9E=E7=BB=99=E5=8E=9F=E5=9B=A0?= =?UTF-8?q?=E3=80=81=E7=9B=AE=E5=BD=95=E9=87=8C=E6=B2=A1=E6=9C=89=E7=9A=84?= =?UTF-8?q?=E5=92=8C=E4=B8=8D=E6=98=AF=E5=88=86=E6=9E=90=E7=BB=93=E8=AE=BA?= =?UTF-8?q?=E7=9A=84=20404=E3=80=81=E6=96=87=E6=A1=A3=E8=A2=AB=E6=94=B9?= =?UTF-8?q?=E8=BF=87=20409?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/app/api/evidence.py | 36 +++++++- backend/tests/test_analysis_scope_api.py | 101 +++++++++++++++++++++++ 2 files changed, 133 insertions(+), 4 deletions(-) create mode 100644 backend/tests/test_analysis_scope_api.py diff --git a/backend/app/api/evidence.py b/backend/app/api/evidence.py index 741dab46..5deb102d 100644 --- a/backend/app/api/evidence.py +++ b/backend/app/api/evidence.py @@ -909,9 +909,30 @@ async def evidence_metric(run_id: str, node: str, metric: str) -> dict[str, Any] "redacted": {"columns": masked, "note": MASK_NOTE} if masked else {"columns": []}} -def _open_segment(sealed: _Sealed, wanted: str | None, segment_id: str - ) -> tuple[_Report, tuple[dict[str, Any], dict[str, Any], dict[str, Any]]]: - """选报告、确认文档完好、找到片段:(报告, (块, 句子, 片段))。片段接口和推断来源接口共用同一套错误码。""" +ANALYSIS_NOT_FOUND = "evidence_analysis_not_found" + + +@router.get("/{run_id}/evidence/analyses/{alias}") +async def evidence_analysis(run_id: str, alias: str, report: str | None = None) -> dict[str, Any]: + """点开报告末尾系统写的「分析范围」里的一个编号(A1…):那份分析结论的出处链(判定 → 方法 → 计算 → 输入 → 复算 → + 封存),和点开正文里它的判定语是同一条(_scope_chain,导出页的分析范围也按它)。正文没引用的结论(排除的、算不了的)也 + 点得开。只认这份报告证据目录里的分析结论;选哪份报告、文档完不完好同片段接口(?report=<节点 id>)。""" + sealed = await _sealed(run_id) + chosen = _open_report(sealed, report) + doc = chosen.doc or {} + chain = await _scope_chain(alias, doc, sealed, chosen.node_id) + if chain is None: + raise CodedHTTPException(404, "这份报告的证据目录里没有这份分析结论", ANALYSIS_NOT_FOUND) + covered = sealed.trusted and all(step.get("sealed") for step in chain if "sealed" in step) + masked = _chain_masked(chain) + return {"run_id": run_id, "report": {"node_id": chosen.node_id, "doc_artifact": chosen.doc_artifact}, "alias": alias, + "rendered": next((step.get("rendered") for step in chain if step.get("step") == LEDGER_KIND), None), + "chain": chain, "seal": {"sealed": sealed.sealed, "ok": sealed.seal["ok"], "covered": covered}, + "redacted": {"columns": masked, "note": MASK_NOTE} if masked else {"columns": []}} + + +def _open_report(sealed: _Sealed, wanted: str | None) -> _Report: + """选报告、确认文档完好。片段接口、推断来源接口、分析范围里的编号共用同一套错误码。""" reports = _named_reports(sealed, wanted) if not reports: raise CodedHTTPException(404, "本次运行没有封存的报告文档,没有可查看的片段", REPORT_NOT_FOUND) @@ -920,7 +941,14 @@ def _open_segment(sealed: _Sealed, wanted: str | None, segment_id: str raise CodedHTTPException(409, "报告文档与哈希不一致,疑似被修改,不能作为证据展示", DOC_TAMPERED) if chosen.doc is None: raise CodedHTTPException(404, "无法读取报告文档", DOC_MISSING) - found = find_segment(chosen.doc, segment_id) + return chosen + + +def _open_segment(sealed: _Sealed, wanted: str | None, segment_id: str + ) -> tuple[_Report, tuple[dict[str, Any], dict[str, Any], dict[str, Any]]]: + """选报告、确认文档完好、找到片段:(报告, (块, 句子, 片段))。片段接口和推断来源接口共用同一套错误码。""" + chosen = _open_report(sealed, wanted) + found = find_segment(chosen.doc or {}, segment_id) if found is None: raise CodedHTTPException(404, "报告中没有这个片段", SEGMENT_NOT_FOUND) return chosen, found diff --git a/backend/tests/test_analysis_scope_api.py b/backend/tests/test_analysis_scope_api.py new file mode 100644 index 00000000..9727f874 --- /dev/null +++ b/backend/tests/test_analysis_scope_api.py @@ -0,0 +1,101 @@ +"""报告末尾「分析范围」里的编号点得开(GET /runs/{id}/evidence/analyses/{alias}):那份分析结论的出处链和点开正文里它的判定语 +是同一条(api/evidence._scope_chain),正文没引用的结论(算不了的、可比性检查)也点得开;证据目录里没有的编号 404。 + +真运行、真算子:照 tests/test_analysis_e2e_claims.py 的虚构零售库 shop(数据目录标了地区、渠道是属性),Agent 查两次库、 +做一次变化拆解、一次波动基线(两周历史不够,算不了)、一次可比性检查;写作模型只引用拆解的判定语。模型都换成脚本。 +""" +from __future__ import annotations + +from httpx import ASGITransport, AsyncClient +from langchain_core.messages import AIMessage, ToolMessage + +from app.analysis import contract +from app.core import artifact_store +from app.engine.evidence import iter_segments +from app.main import app +from tests.test_analysis_e2e_claims import ( # noqa: F401 - 夹具按名字引入 + ARGS, BY_REGION, DECOMPOSE, TOOL, TOTALS, _finish, _graph, _report, engine_up, shop, +) + +BASELINE = contract.TOOL_NAMES["baseline"] +COMPARABLE = contract.TOOL_NAMES["comparable"] +TEXT = "## 本周销售\n\n[[a:A1]]。[[see:A1]]" +CHAIN = ["analysis", "analysis_method", "analysis_calc", "analysis_input", "analysis_recompute", "analysis_seal"] + + +def _script(monkeypatch): + from app.providers import mock_model + + calls = [(TOOL, {"sql": TOTALS}), (TOOL, {"sql": BY_REGION}), (DECOMPOSE, ARGS), + (BASELINE, {"query": "Q1", "period": "week", "measure": "gmv", "label": "销售额"}), + (COMPARABLE, {"query": "Q2", "period": "week", "base": "2026-W41", "target": "2026-W42", "measure": "gmv"})] + + def decide(model, messages): + if not model.tools: + return AIMessage(content=TEXT) + done = sum(isinstance(m, ToolMessage) for m in messages) + if done < len(calls): + name, args = calls[done] + return AIMessage(content="", tool_calls=[{"name": name, "args": args, "id": f"call_{done}"}]) + return AIMessage(content="销售额下降,主要来自华东。") + + monkeypatch.setattr(mock_model.MockChatModel, "_decide", decide) + + +def _graph_with_tools(): + graph = _graph() + bot = next(n for n in graph["nodes"] if n["id"] == "bot") + bot["data"]["config"]["tools"] = [TOOL, DECOMPOSE, BASELINE, COMPARABLE] + return graph + + +def _steps(chain): + """同一步连着几条(几份输入、输入后面接的查询步骤)算一步。""" + steps = [s["step"] for s in chain] + return [s for i, s in enumerate(steps) if s.startswith("analysis") and steps[i - 1:i] != [s]] + + +async def test_scope_aliases_open_the_same_chain_as_the_phrase_in_the_text(monkeypatch): + _script(monkeypatch) + row = await _finish(_graph_with_tools()) + assert row.status == "succeeded", row.error + _, doc, _ = await _report(row) + scope = doc["analysis_scope"] + assert [i["alias"] for i in scope["decomposed"]] == ["A1"] + assert [i["alias"] for i in scope["refused"]] == ["A2"] and [i["alias"] for i in scope["phenomena"]] == ["A3"] + phrase = next(s for s in iter_segments(doc) if s.get("ref") == "a:A1") + + async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client: + cited = (await client.get(f"/api/runs/{row.id}/evidence/segments/{phrase['id']}")).json() + body = (await client.get(f"/api/runs/{row.id}/evidence/analyses/A1")).json() + # 和点开正文里的判定语同一条链(同一份结论、同样复算一致),只是没有这一处引用的那几样 + assert _steps(body["chain"]) == CHAIN + assert [s["step"] for s in body["chain"]] == [s["step"] for s in cited["chain"]] + assert body["rendered"] == phrase["text"] == body["chain"][0]["phrase"] + assert body["seal"]["covered"] is True and body["report"]["node_id"] == "write" + + # 正文没引用的也点得开:算不了的波动基线照实给原因,可比性检查照样复算 + refused = (await client.get(f"/api/runs/{row.id}/evidence/analyses/A2", params={"report": "write"})).json() + judged = refused["chain"][0] + assert (judged["op"], judged["status"], judged["reason_code"]) == ("baseline", "refused", "history_short") + assert judged["phrase"] == scope["refused"][0]["phrase"] + assert next(s for s in refused["chain"] if s["step"] == "analysis_recompute")["status"] == "same" + comparable = (await client.get(f"/api/runs/{row.id}/evidence/analyses/A3")).json() + assert comparable["chain"][0]["method_label"] == contract.method_label("comparable") + + # 目录里没有的编号、不是分析结论的编号(Q1):404,说清是哪种 + for alias in ("A9", "Q1"): + missing = await client.get(f"/api/runs/{row.id}/evidence/analyses/{alias}") + assert missing.status_code == 404 and missing.json()["code"] == "evidence_analysis_not_found" + + +async def test_a_tampered_report_does_not_open_its_scope(monkeypatch): + _script(monkeypatch) + row = await _finish(_graph_with_tools()) + checked, _, _ = await _report(row) + # 报告文档被改过(哈希对不上):和片段接口同一个错误码,不给出处 + path = artifact_store._path_of(checked["doc_artifact"]) + path.write_bytes(path.read_bytes().replace("本周销售".encode(), "本周营业".encode(), 1)) + async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client: + res = await client.get(f"/api/runs/{row.id}/evidence/analyses/A1") + assert res.status_code == 409 and res.json()["code"] == "evidence_doc_tampered" From 78800511e0d0e3fca249b7efd9f18b2b5c9f056e Mon Sep 17 00:00:00 2001 From: ThearchyHelios Date: Wed, 21 Oct 2026 18:17:56 +0800 Subject: [PATCH 03/12] =?UTF-8?q?=E5=89=8D=E7=AB=AF=EF=BC=9A=E5=88=86?= =?UTF-8?q?=E6=9E=90=E5=8D=A1=E8=8A=82=E7=82=B9=EF=BC=88=E5=88=86=E6=9E=90?= =?UTF-8?q?=E8=83=BD=E5=8A=9B=E4=B8=80=E8=BD=AE=EF=BC=89=E3=80=82=E8=8A=82?= =?UTF-8?q?=E7=82=B9=E5=BA=93=E3=80=8C=E6=8A=8A=E5=85=B3=E3=80=8D=E4=B8=80?= =?UTF-8?q?=E7=B1=BB=E5=8A=A0=E3=80=8C=E5=88=86=E6=9E=90=E5=8D=A1=E3=80=8D?= =?UTF-8?q?=EF=BC=88=E7=B1=BB=E5=9E=8B=E8=89=B2=20--nt-analysis=EF=BC=8C?= =?UTF-8?q?=E4=BA=AE=E6=9A=97=E4=B8=A4=E5=A5=97=EF=BC=89=EF=BC=8C=E5=8D=A1?= =?UTF-8?q?=E7=89=87=E6=91=98=E8=A6=81=E5=86=99=E5=81=9A=E5=93=AA=E5=87=A0?= =?UTF-8?q?=E7=A7=8D=E5=88=86=E6=9E=90=EF=BC=88=E3=80=8C=E6=B3=A2=E5=8A=A8?= =?UTF-8?q?=E5=9F=BA=E7=BA=BF=E3=80=81=E5=8F=98=E5=8C=96=E6=8B=86=E8=A7=A3?= =?UTF-8?q?=EF=BC=88=E5=8F=AF=E5=8A=A0=EF=BC=89=E3=80=8D=EF=BC=8C=E5=8F=AB?= =?UTF-8?q?=E6=B3=95=E7=85=A7=E5=A5=91=E7=BA=A6=EF=BC=89=EF=BC=9B=E6=A3=80?= =?UTF-8?q?=E6=9F=A5=E5=99=A8=E7=9A=84=E5=88=86=E6=9E=90=E9=A1=B9=E7=BC=96?= =?UTF-8?q?=E8=BE=91=E5=99=A8=EF=BC=9A=E6=AF=8F=E9=A1=B9=E6=A0=87=E8=AF=86?= =?UTF-8?q?=E3=80=81=E5=88=86=E6=9E=90=E6=96=B9=E6=B3=95=EF=BC=88=E4=BA=94?= =?UTF-8?q?=E4=B8=AA=E7=AE=97=E5=AD=90=EF=BC=89=E3=80=81=E6=A8=A1=E5=BC=8F?= =?UTF-8?q?=EF=BC=88=E6=9C=89=E6=A8=A1=E5=BC=8F=E7=9A=84=E6=89=8D=E7=BB=99?= =?UTF-8?q?=EF=BC=89=E3=80=81=E8=BE=93=E5=85=A5=E5=8F=AA=E5=88=97=E4=B8=8A?= =?UTF-8?q?=E6=B8=B8=E7=9A=84=E6=9F=A5=E8=AF=A2=E8=8A=82=E7=82=B9=EF=BC=88?= =?UTF-8?q?=E8=B0=83=E7=94=A8=E6=95=B0=E6=8D=AE=E5=BA=93=E6=9F=A5=E8=AF=A2?= =?UTF-8?q?=E5=B7=A5=E5=85=B7=E7=9A=84=E3=80=8C=E8=B0=83=E7=94=A8=E5=B7=A5?= =?UTF-8?q?=E5=85=B7=E3=80=8D=E8=8A=82=E7=82=B9=E3=80=81=E5=90=88=E5=B9=B6?= =?UTF-8?q?=E6=9F=A5=E8=AF=A2=EF=BC=8C=E5=88=A0=E6=8E=89=E6=88=96=E6=8C=AA?= =?UTF-8?q?=E5=88=B0=E4=B8=8B=E6=B8=B8=E7=9A=84=E7=85=A7=E5=AE=9E=E5=88=97?= =?UTF-8?q?=E5=87=BA=EF=BC=89=EF=BC=8C=E6=8C=89=E6=96=B9=E6=B3=95=E6=91=86?= =?UTF-8?q?=E5=8F=82=E6=95=B0=E6=A0=BC=EF=BC=88analysisItems.paramSpecs?= =?UTF-8?q?=EF=BC=9A=E7=BB=B4=E5=BA=A6=E5=88=97=E3=80=81=E6=9C=9F=E6=AC=A1?= =?UTF-8?q?=E3=80=81=E5=9F=BA=E6=9C=9F=E3=80=81=E6=9C=AC=E6=9C=9F=E3=80=81?= =?UTF-8?q?=E5=BA=A6=E9=87=8F=E5=88=97=E6=88=96=E5=88=86=E5=AD=90=E5=88=86?= =?UTF-8?q?=E6=AF=8D=E3=80=81=E4=B9=98=E7=A7=AF=E5=92=8C=E5=9B=A0=E5=AD=90?= =?UTF-8?q?=E3=80=81=E6=AF=94=E8=BE=83=E6=96=B9=E5=BC=8F=E3=80=81=E7=B2=92?= =?UTF-8?q?=E5=BA=A6=E3=80=81=E5=AF=B9=E7=85=A7=E7=BB=84=E3=80=81=E5=87=A0?= =?UTF-8?q?=E5=AF=B9=E2=80=A6=E2=80=A6=EF=BC=8C=E6=9C=9F=E6=AC=A1=E5=8F=AF?= =?UTF-8?q?=E4=BB=A5=E5=86=99=20{{=20}}=EF=BC=89=EF=BC=8C=E5=8F=AF?= =?UTF-8?q?=E5=8A=A0=E3=80=81=E6=AF=94=E7=8E=87=E7=9A=84=E5=8F=98=E5=8C=96?= =?UTF-8?q?=E6=8B=86=E8=A7=A3=E5=8F=A6=E6=9C=89=E6=80=BB=E6=95=B0=E6=A0=B8?= =?UTF-8?q?=E5=AF=B9=E4=B8=A4=E6=A0=BC=E5=92=8C=E4=B8=8B=E9=92=BB=EF=BC=88?= =?UTF-8?q?=E4=B8=8A=E4=B8=80=E5=B1=82=E5=8F=AA=E5=88=97=E6=8E=92=E5=9C=A8?= =?UTF-8?q?=E5=89=8D=E9=9D=A2=E7=9A=84=E5=8F=98=E5=8C=96=E6=8B=86=E8=A7=A3?= =?UTF-8?q?=EF=BC=89=EF=BC=8C=E5=8F=98=E5=8C=96=E6=8B=86=E8=A7=A3=E3=80=81?= =?UTF-8?q?=E5=8F=AF=E6=AF=94=E6=80=A7=E6=A3=80=E6=9F=A5=E5=8F=AF=E4=BB=A5?= =?UTF-8?q?=E5=86=99=E6=88=90=E5=9F=BA=E6=9C=9F=E3=80=81=E6=9C=AC=E6=9C=9F?= =?UTF-8?q?=E4=B8=A4=E4=BB=BD=E6=9F=A5=E8=AF=A2=EF=BC=8C=E6=8D=A2=E6=96=B9?= =?UTF-8?q?=E6=B3=95=E3=80=81=E6=A8=A1=E5=BC=8F=E6=97=B6=E5=8F=AA=E7=95=99?= =?UTF-8?q?=E6=96=B0=E6=96=B9=E6=B3=95=E7=94=A8=E5=BE=97=E5=88=B0=E7=9A=84?= =?UTF-8?q?=E5=8F=82=E6=95=B0=EF=BC=9B=E5=9C=A8=E9=AA=8C=E8=AF=81=E4=BB=80?= =?UTF-8?q?=E4=B9=88=E4=B8=80=E5=8F=A5=E8=AF=9D=EF=BC=9B=E9=98=88=E5=80=BC?= =?UTF-8?q?=E8=A6=86=E7=9B=96=E5=8F=AA=E5=88=97=E5=86=99=E4=BA=86=E7=9A=84?= =?UTF-8?q?=E5=87=A0=E9=A1=B9=E3=80=81=E4=BB=8E=E6=B2=A1=E5=86=99=E7=9A=84?= =?UTF-8?q?=E9=87=8C=E9=80=89=EF=BC=8C=E8=8C=83=E5=9B=B4=E5=BD=93=E5=9C=BA?= =?UTF-8?q?=E6=8C=89=E5=A5=91=E7=BA=A6=E8=AF=B4=EF=BC=88=E4=B8=A4=E4=B8=A4?= =?UTF-8?q?=E4=B9=8B=E9=97=B4=E7=9A=84=E5=A4=A7=E5=B0=8F=E4=BA=A4=E7=BB=99?= =?UTF-8?q?=E6=9C=8D=E5=8A=A1=E7=AB=AF=EF=BC=89=EF=BC=8C=E5=85=A8=E5=88=A0?= =?UTF-8?q?=E6=8E=89=E4=B8=8D=E7=95=99=E7=A9=BA=E5=AF=B9=E8=B1=A1=E3=80=82?= =?UTF-8?q?=E6=9C=8D=E5=8A=A1=E7=AB=AF=E6=8C=89=20items[i].<=E9=94=AE>=20?= =?UTF-8?q?=E6=8A=A5=E7=9A=84=E9=97=AE=E9=A2=98=E8=90=BD=E5=9C=A8=E9=82=A3?= =?UTF-8?q?=E4=B8=80=E6=A0=BC=E4=B8=8B=E9=9D=A2=EF=BC=8C=E5=AE=9A=E4=BD=8D?= =?UTF-8?q?=E9=80=80=E4=B8=8D=E5=88=B0=E7=9A=84=E5=AD=90=E9=94=AE=EF=BC=88?= =?UTF-8?q?params.parent.key=EF=BC=89=E9=80=90=E6=AE=B5=E5=BE=80=E4=B8=8A?= =?UTF-8?q?=E9=80=80=EF=BC=9B=E6=9F=A5=E8=AF=A2=E8=8A=82=E7=82=B9=E8=BF=9E?= =?UTF-8?q?=E5=88=B0=E5=88=86=E6=9E=90=E5=8D=A1=E6=97=B6=E8=BF=98=E6=B2=A1?= =?UTF-8?q?=E9=80=89=E8=BE=93=E5=85=A5=E7=9A=84=E7=AC=AC=E4=B8=80=E9=A1=B9?= =?UTF-8?q?=E9=80=89=E4=B8=8A=E5=AE=83=EF=BC=88=E5=90=8C=E5=90=88=E5=B9=B6?= =?UTF-8?q?=E6=9F=A5=E8=AF=A2=EF=BC=8C=E5=92=8C=E8=BF=9E=E7=BA=BF=E5=90=8C?= =?UTF-8?q?=E4=B8=80=E6=AD=A5=E6=92=A4=E9=94=80=EF=BC=89=E3=80=82=E6=8A=A5?= =?UTF-8?q?=E5=91=8A=E8=8A=82=E7=82=B9=E5=8A=A0=E3=80=8C=E5=88=86=E6=9E=90?= =?UTF-8?q?=E8=AF=B4=E6=B3=95=E6=A0=B8=E5=AF=B9=E3=80=8D=EF=BC=88=E7=BC=BA?= =?UTF-8?q?=E7=9C=81=E9=87=8D=E5=86=99=E4=B8=80=E6=AC=A1=E5=90=8E=E6=A0=87?= =?UTF-8?q?=E5=87=BA=EF=BC=89=E3=80=81=E3=80=8C=E5=88=86=E6=9E=90=E8=8C=83?= =?UTF-8?q?=E5=9B=B4=E3=80=8D=EF=BC=88=E9=AB=98=E7=BA=A7=EF=BC=8C=E7=BC=BA?= =?UTF-8?q?=E7=9C=81=E6=9C=89=E5=88=86=E6=9E=90=E7=BB=93=E8=AE=BA=E6=97=B6?= =?UTF-8?q?=E9=99=84=E4=B8=8A=EF=BC=89=EF=BC=8C=E9=80=89=E9=A1=B9=E4=BB=A5?= =?UTF-8?q?=20engine/labels.py=20=E7=9A=84=E7=9F=AD=E8=AF=B4=E6=B3=95?= =?UTF-8?q?=E5=BC=80=E5=A4=B4=E3=80=82=E5=8F=AB=E6=B3=95=E3=80=81=E8=8C=83?= =?UTF-8?q?=E5=9B=B4=E5=8F=AA=E7=85=A7=E5=A5=91=E7=BA=A6=E4=B8=80=E4=BB=BD?= =?UTF-8?q?=EF=BC=88lib/termsAnalysis=EF=BC=9AOP=5FLABELS=20/=20MODE=5FLAB?= =?UTF-8?q?ELS=20/=20LABELS=20/=20RANGES=EF=BC=8Ccheck-studio=20=E8=AF=BB?= =?UTF-8?q?=E5=90=8E=E7=AB=AF=E6=BA=90=E7=A0=81=E9=80=90=E9=A1=B9=E6=AF=94?= =?UTF-8?q?=E5=AF=B9=EF=BC=89=E3=80=82check-studio=20=E5=8A=A0=E4=B8=A4?= =?UTF-8?q?=E8=8A=82=EF=BC=9A=E5=8F=AB=E6=B3=95=E4=B8=8E=E5=A5=91=E7=BA=A6?= =?UTF-8?q?=E4=B8=80=E8=87=B4=E3=80=81=E5=88=86=E6=9E=90=E5=8D=A1=E7=9A=84?= =?UTF-8?q?=E7=94=BB=E5=B8=83=E7=BC=96=E8=BE=91=E4=B8=8E=E6=A0=A1=E9=AA=8C?= =?UTF-8?q?=E5=AE=9A=E4=BD=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/src/canvas/Inspector.tsx | 380 ++++++++++++++++++++++-- frontend/src/canvas/InspectorSheet.tsx | 9 +- frontend/src/canvas/NodeCard.tsx | 4 + frontend/src/canvas/analysisItems.ts | 165 +++++++++++ frontend/src/canvas/nodeDefs.ts | 41 ++- frontend/src/index.css | 4 + frontend/src/lib/terms.ts | 2 + frontend/src/lib/termsAnalysis.ts | 391 +++++++++++++++++++++++++ frontend/src/store/studio.ts | 6 +- frontend/src/types.ts | 258 +++++++++++++++- scripts/check-studio.mjs | 226 ++++++++++++++ 11 files changed, 1454 insertions(+), 32 deletions(-) create mode 100644 frontend/src/canvas/analysisItems.ts create mode 100644 frontend/src/lib/termsAnalysis.ts diff --git a/frontend/src/canvas/Inspector.tsx b/frontend/src/canvas/Inspector.tsx index d0a099e8..e67938bc 100644 --- a/frontend/src/canvas/Inspector.tsx +++ b/frontend/src/canvas/Inspector.tsx @@ -15,7 +15,15 @@ import { formatShortcut } from '../lib/keys' import { INPUT_FIELD_TEXT, JUDGE_FIELD_LABEL, JUDGE_ON_UNSUPPORTED_HINT, JUDGE_ON_UNSUPPORTED_LABEL, JUDGE_SETTING_TEXT, MERGE_TEXT, CATALOG_UI_TEXT, NODE_TYPE_LABEL, PROVIDER_REF_TEXT, SUBGRAPH_UPGRADE_HELP, judgeByModelText, upgradeNewerText } from '../lib/terms' import { TemplateText } from './TemplateText' import { MERGE_ALIAS, isQueryNode, mergeAlias } from './mergeInputs' -import type { NodeType, Provider, ValidationIssue, WorkflowVersion } from '../types' +import { + ITEM_ID, TWO_QUERY_SKIP, allowsHeadline, allowsParent, allowsTwoQueries, itemsOf, nextItemId, paramSpecs, paramText, + paramValue, withMode, withOp, type ParamSpec, +} from './analysisItems' +import { + ANALYSIS_MODES, ANALYSIS_MODE_LABEL, ANALYSIS_OPS, ANALYSIS_OP_LABEL, ANALYSIS_SETTING_KEYS, ANALYSIS_SETTING_LABEL, + ANALYSIS_TEXT, isAnalysisOp, settingRangeText, settingValueProblem, type AnalysisSettingKey, +} from '../lib/termsAnalysis' +import type { AnalysisItem, NodeType, Provider, ValidationIssue, WorkflowVersion } from '../types' /** 检查器里一条落到字段上的问题 */ type FieldIssue = ValidationIssue & { at: FieldRef | null } @@ -151,6 +159,34 @@ function NodeRefsInput({ nodeId, type, value, onChange }: { ) } +/** 上游能当查询输入的节点(选了数据库查询工具的「调用工具」节点、合并查询):合并查询、分析卡共用 */ +function useUpstreamQueries(nodeId: string): { value: string; label: string; hint?: string }[] { + const nodes = useStudio((s) => s.nodes) + const edges = useStudio((s) => s.edges) + return useMemo(() => { + const parents = new Map() + for (const e of edges) parents.set(e.target, [...(parents.get(e.target) ?? []), e.source]) + const seen = new Set() + const stack = [...(parents.get(nodeId) ?? [])] + while (stack.length) { + const id = stack.pop()! + if (seen.has(id) || id === nodeId) continue + seen.add(id) + stack.push(...(parents.get(id) ?? [])) + } + return nodes + .filter((n) => seen.has(n.id) && isQueryNode(n)) + .map((n) => { + const label = String(n.data?.label || n.id) + const tool = String((n.data?.config as Record | undefined)?.tool ?? '') + return { + value: n.id, label: label === n.id ? label : `${label}(${n.id})`, + hint: n.data?.nodeType === 'merge' ? NODE_TYPE_LABEL.merge : tool, + } + }) + }, [nodes, edges, nodeId]) +} + interface MergeRow { alias: string; node: string } /** config.inputs({别名: 节点 id})→ 行。对象的键顺序就是行的顺序 */ @@ -178,28 +214,7 @@ function MergeInputList({ nodeId, value, issues, onChange }: { const nodes = useStudio((s) => s.nodes) const edges = useStudio((s) => s.edges) const uid = useId() - const options = useMemo(() => { - const parents = new Map() - for (const e of edges) parents.set(e.target, [...(parents.get(e.target) ?? []), e.source]) - const seen = new Set() - const stack = [...(parents.get(nodeId) ?? [])] - while (stack.length) { - const id = stack.pop()! - if (seen.has(id) || id === nodeId) continue - seen.add(id) - stack.push(...(parents.get(id) ?? [])) - } - return nodes - .filter((n) => seen.has(n.id) && isQueryNode(n)) - .map((n) => { - const label = String(n.data?.label || n.id) - const tool = String((n.data?.config as Record | undefined)?.tool ?? '') - return { - value: n.id, label: label === n.id ? label : `${label}(${n.id})`, - hint: n.data?.nodeType === 'merge' ? NODE_TYPE_LABEL.merge : tool, - } - }) - }, [nodes, edges, nodeId]) + const options = useUpstreamQueries(nodeId) const external = useMemo(() => mergeRowsOf(value), [value]) const [rows, setRows] = useState(external) @@ -295,6 +310,319 @@ function MergeInputList({ nodeId, value, issues, onChange }: { ) } +const AT = ANALYSIS_TEXT.node + +/** 选一个上游查询节点。已经选了、后来删掉或挪到下游的节点照样列出来并说明,好让人看见并改掉 */ +function QueryPick({ id, value, options, onChange }: { + id: string; value: string; options: { value: string; label: string }[]; onChange: (v: string) => void +}) { + const known = options.some((o) => o.value === value) + return ( + + ) +} + +/** + * 分析卡的分析项(engine/schema.analysis_issues 的配置):每一项一个标识、一种分析方法(有模式的再选模式)、要分析的上游 + * 查询节点(或基期、本期两份查询),再按方法摆参数格(analysisItems.paramSpecs);可加、比率的变化拆解另有总数核对、下钻。 + * 阈值不在这里写(统一写在阈值覆盖里)。服务端按 items[i].<键> 报的问题落在那一格下面,定位按 [data-item] [data-sub] 落进来 + */ +function AnalysisItemList({ nodeId, value, issues, onChange }: { + nodeId: string; value: any; issues: FieldIssue[]; onChange: (v: any) => void +}) { + const uid = useId() + const options = useUpstreamQueries(nodeId) + const items = itemsOf(value) + const commit = (next: AnalysisItem[]) => onChange(next) + const update = (i: number, next: AnalysisItem) => commit(items.map((it, j) => (j === i ? next : it))) + const setParam = (i: number, key: string, v: unknown) => { + const params = { ...(items[i].params ?? {}) } + if (v === undefined) delete params[key] + else params[key] = v + update(i, { ...items[i], params }) + } + const add = () => commit([...items, { id: nextItemId(items), params: {} }]) + return ( +
+ {items.map((item, i) => { + const own = issues.filter((x) => x.at?.index === i) + const at = (sub: string) => own.filter((x) => x.at?.sub === sub || x.at?.sub?.startsWith(`${sub}.`)) + const lines = (sub: string) => at(sub).map((x, k) => ) + const op = isAnalysisOp(item.op) ? item.op : null + const modes = op ? ANALYSIS_MODES[op] : undefined + const params = (item.params && typeof item.params === 'object' ? item.params : {}) as Record + const two = !!op && allowsTwoQueries(op) && ('base_query' in params || 'target_query' in params) + const id = String(item.id ?? '') + const dup = !!id && items.some((other, j) => j !== i && String(other.id ?? '') === id) + const local = !id || !ITEM_ID.test(id) ? AT.idInvalid : dup ? AT.idDuplicate(id) : '' + const tone = own.some((x) => x.level === 'error') || local ? 'error' : own.length ? 'warning' : undefined + const before = items.slice(0, i).filter((it) => it.op === 'decompose' && it.id) + const parent = params.parent && typeof params.parent === 'object' ? params.parent as { of?: string; key?: unknown } : null + const field = (spec: ParamSpec) => { + const fid = `${uid}-${i}-p-${spec.key}` + const label = AT.param[spec.key] ?? spec.key + const text = paramText(spec.kind, params[spec.key]) + const set = (t: string) => setParam(i, spec.key, paramValue(spec.kind, t)) + let control + if (spec.kind === 'basis' || spec.kind === 'grain') { + const opts = spec.kind === 'basis' ? AT.basis : AT.grain + control = ( + + ) + } else if (spec.kind === 'pairs') { + control =