From a9f1555da201ec8ccb0770e7a5a954970e9964f3 Mon Sep 17 00:00:00 2001 From: ThearchyHelios Date: Wed, 21 Oct 2026 13:01:47 +0800 Subject: [PATCH 1/7] =?UTF-8?q?=E5=88=86=E6=9E=90=E8=83=BD=E5=8A=9B?= =?UTF-8?q?=E5=85=A5=E5=8F=A3=EF=BC=88=E4=B8=80=EF=BC=89=EF=BC=9A=E8=AE=BE?= =?UTF-8?q?=E7=BD=AE=E5=8A=A0=E3=80=8C=E5=88=86=E6=9E=90=E3=80=8D=E4=B8=80?= =?UTF-8?q?=E7=BB=84=EF=BC=88api/settings.py=20=E7=9A=84=20analysis?= =?UTF-8?q?=EF=BC=9A=E5=88=A4=E5=AE=9A=E7=94=A8=E7=9A=84=E9=98=88=E5=80=BC?= =?UTF-8?q?=E3=80=81=E4=B8=80=E6=AC=A1=E8=BF=90=E8=A1=8C=E6=9C=80=E5=A4=9A?= =?UTF-8?q?=E5=88=86=E6=9E=90=E5=87=A0=E6=AC=A1=E3=80=81=E6=9C=80=E5=A4=9A?= =?UTF-8?q?=E4=B8=8B=E9=92=BB=E5=87=A0=E5=B1=82=E3=80=81=E5=88=97=E5=87=BA?= =?UTF-8?q?=E5=89=8D=E5=87=A0=E6=AE=B5=EF=BC=89=EF=BC=8C=E9=94=AE=E3=80=81?= =?UTF-8?q?=E7=BC=BA=E7=9C=81=E3=80=81=E5=8F=96=E5=80=BC=E8=8C=83=E5=9B=B4?= =?UTF-8?q?=E3=80=81=E5=8F=AB=E6=B3=95=E5=85=A8=E5=8F=96=20app/analysis/co?= =?UTF-8?q?ntract.py=EF=BC=88DEFAULTS=20/=20RANGES=20/=20LABELS=20/=20sett?= =?UTF-8?q?ings=5Fproblem=EF=BC=89=EF=BC=8C=E8=AE=BE=E7=BD=AE=E6=8E=A5?= =?UTF-8?q?=E5=8F=A3=E9=87=8C=E5=92=8C=20ledger=20=E7=BB=84=E5=90=8C?= =?UTF-8?q?=E4=B8=80=E4=B8=AA=E5=86=99=E6=B3=95=EF=BC=9AGET=20=E6=8C=89?= =?UTF-8?q?=E7=94=9F=E6=95=88=E7=9A=84=E6=A0=B7=E5=AD=90=E7=BB=99=EF=BC=88?= =?UTF-8?q?=E5=AD=98=E5=9D=8F=E4=BA=86=E7=9A=84=E9=A1=B9=E6=8C=89=E7=BC=BA?= =?UTF-8?q?=E7=9C=81=EF=BC=8C=E3=80=8C=E6=98=8E=E6=98=BE=E8=B6=85=E5=87=BA?= =?UTF-8?q?=E3=80=8D=E4=B8=8D=E5=A4=A7=E4=BA=8E=E3=80=8C=E8=B6=85=E5=87=BA?= =?UTF-8?q?=E5=B8=B8=E8=A7=84=E6=B3=A2=E5=8A=A8=E3=80=8D=E3=80=81=E6=9C=80?= =?UTF-8?q?=E5=A4=9A=E7=94=A8=E5=87=A0=E6=9C=9F=E5=8E=86=E5=8F=B2=E5=B0=8F?= =?UTF-8?q?=E4=BA=8E=E8=87=B3=E5=B0=91=E5=87=A0=E6=9C=9F=E7=9A=84=E9=82=A3?= =?UTF-8?q?=E4=B8=80=E5=AF=B9=E4=B8=80=E8=B5=B7=E6=8C=89=E7=BC=BA=E7=9C=81?= =?UTF-8?q?=EF=BC=89=EF=BC=8CPUT=20=E5=85=88=E6=9F=A5=E5=90=8E=E5=86=99?= =?UTF-8?q?=EF=BC=88=E4=B8=8D=E8=AE=A4=E8=AF=86=E7=9A=84=E9=94=AE=E3=80=81?= =?UTF-8?q?=E7=B1=BB=E5=9E=8B=E3=80=81=E8=8C=83=E5=9B=B4=E3=80=81=E4=B8=A4?= =?UTF-8?q?=E4=B8=A4=E4=B9=8B=E9=97=B4=E7=9A=84=E5=A4=A7=E5=B0=8F=EF=BC=8C?= =?UTF-8?q?=E8=AF=B4=E6=B3=95=E7=94=A8=E8=AE=BE=E7=BD=AE=E9=A1=B9=E7=9A=84?= =?UTF-8?q?=E5=8F=AB=E6=B3=95=EF=BC=89=EF=BC=8C=E6=B5=AE=E7=82=B9=E7=9A=84?= =?UTF-8?q?=E9=A1=B9=E5=AD=98=E6=88=90=E6=B5=AE=E7=82=B9=EF=BC=9Banalysis?= =?UTF-8?q?=5Fsettings=20=E7=BB=99=E5=8F=91=E8=B5=B7=E6=97=B6=E7=9A=84?= =?UTF-8?q?=E5=BF=AB=E7=85=A7=E5=92=8C=E6=B2=A1=E6=9C=89=E5=BF=AB=E7=85=A7?= =?UTF-8?q?=E6=97=B6=E7=8E=B0=E8=AF=BB=E7=94=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/app/api/settings.py | 61 ++++++++++++++++++ backend/tests/test_analysis_settings.py | 85 +++++++++++++++++++++++++ 2 files changed, 146 insertions(+) create mode 100644 backend/tests/test_analysis_settings.py diff --git a/backend/app/api/settings.py b/backend/app/api/settings.py index a1ba2b51..bf9d1d22 100644 --- a/backend/app/api/settings.py +++ b/backend/app/api/settings.py @@ -9,6 +9,7 @@ from pydantic import BaseModel, Field from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession +from app.analysis import contract as analysis_contract from app.api import health from app.api.coded import PUBLIC_URL_ENV_LOCKED, CodedHTTPException from app.core import public_url @@ -872,6 +873,59 @@ async def eval_settings(session: AsyncSession) -> dict[str, Any]: return eval_values(row.value if row else None) +# -------------------------------------------------------------------------- +# 分析:判定用的阈值和次数上限(docs/design/analysis-round/01-算子与判定.md §10)。键、缺省、取值范围、叫法、校验都在 +# app/analysis/contract.py 一处:设置页、发起时的快照(run.options["analysis"],engine/launch.py)、分析卡上的覆盖按同一张表。 +# agent 改不了这些:分析工具的参数里没有阈值。搭建者只读,管理员写(整个设置接口的规矩) +# -------------------------------------------------------------------------- + +#: 设置里 analysis 这一组(contract.SETTINGS_KEY) +ANALYSIS_GROUP = analysis_contract.SETTINGS_KEY +#: 每一项的叫法(设置页、报错都用它,句子里不裸写键名) +ANALYSIS_SETTING_LABEL = analysis_contract.LABELS + + +def analysis_problem(value: Any) -> str | None: + """设置页写进来的 analysis 这一组有什么问题(不认识的键、类型、范围、两两之间的大小),没问题返回 None。写进来的这一组 + 整个替换存着的(同别的组),缺的项按缺省,所以两两之间的大小也按缺省补齐了比。""" + if not isinstance(value, dict): + return f"「分析」设置格式有误,需要是一个对象,当前为「{value}」" + for key in value: + if key not in analysis_contract.DEFAULTS: + return (f"「分析」设置中不支持「{key}」,可设置的项:" + + "、".join(f"「{ANALYSIS_SETTING_LABEL[k]}」" for k in analysis_contract.DEFAULTS)) + return analysis_contract.settings_problem(value) + + +def _analysis_item_ok(key: str, value: Any) -> bool: + """一项本身对不对(类型、范围),不看和别的项的大小:存着的一组逐项认,两两之间的另看。""" + kind, low, high = analysis_contract.RANGES[key] + if isinstance(value, bool) or not isinstance(value, (int, float)) or (kind is int and not isinstance(value, int)): + return False + return low <= value <= high + + +def analysis_values(stored: Any) -> dict[str, Any]: + """库里存的(或刚写进来的)analysis 一组 → 补齐缺省;存坏了的项按缺省。两两之间的大小对不上的(「明显超出」不大于 + 「超出常规波动」、最多用几期历史小于至少几期)那一对都按缺省:只换掉一项,可能换出另一种对不上。""" + stored = stored if isinstance(stored, dict) else {} + out = dict(analysis_contract.DEFAULTS) + for key in analysis_contract.DEFAULTS: + if key in stored and _analysis_item_ok(key, stored[key]): + out[key] = float(stored[key]) if analysis_contract.RANGES[key][0] is float else stored[key] + for pair in (("k_warn", "k_alert"), ("min_history", "max_history")): + if analysis_contract.settings_problem({k: out[k] for k in pair}) is not None: + out.update({k: analysis_contract.DEFAULTS[k] for k in pair}) + return out + + +async def analysis_settings(session: AsyncSession) -> dict[str, Any]: + """设置里的 analysis 一组,没存过的补缺省。发起运行时快照这一份(engine/launch.py),没有快照的运行由分析工具、分析卡、 + 口径卡的可比性检查现读(app/tools/analysis.py)。""" + row = await session.get(Setting, ANALYSIS_GROUP) + return analysis_values(row.value if row else None) + + # -------------------------------------------------------------------------- # 邮件服务(notify/mail.py,04 细案 B2):只有管理员能改(整个设置接口的改动都要管理员),口令加密存、读的时候只说配没配 # -------------------------------------------------------------------------- @@ -954,6 +1008,8 @@ DEFAULT_SETTINGS: dict[str, Any] = { LEDGER_GROUP: ledger_values(None), # 评测:预算的全站缺省(题集上可以改)、保留几次明细、结论裁判的缺省开关 EVAL_GROUP: eval_values(None), + # 分析:判定用的阈值、一次运行最多分析几次、最多下钻几层(app/analysis/contract.py) + ANALYSIS_GROUP: analysis_values(None), # 邮件服务(SMTP):通知渠道的邮件格式用它发信。缺省关 MAIL_GROUP: _mail_public(None), # 运行并发(app/cluster/slots.py):全站同时运行的上限,交互、应用、定时、系统四类各自的上限和优先级。多进程时几个进程合起来算 @@ -1024,6 +1080,7 @@ async def get_settings(session: AsyncSession = Depends(get_session)) -> dict[str merged[APPS_GROUP] = apps_values(stored.get(APPS_GROUP)) merged[LEDGER_GROUP] = ledger_values(stored.get(LEDGER_GROUP)) merged[EVAL_GROUP] = eval_values(stored.get(EVAL_GROUP)) + merged[ANALYSIS_GROUP] = analysis_values(stored.get(ANALYSIS_GROUP)) merged[CONCURRENCY_GROUP] = concurrency_values(stored.get(CONCURRENCY_GROUP)) # 口令不回(password 恒为 null),只说配没配(password_set) merged[MAIL_GROUP] = _mail_public(stored.get(MAIL_GROUP)) @@ -1136,6 +1193,10 @@ async def put_settings( if problem := eval_problem(payload.values[EVAL_GROUP]): raise HTTPException(422, problem) payload.values[EVAL_GROUP] = eval_values(payload.values[EVAL_GROUP]) + if ANALYSIS_GROUP in payload.values: + if problem := analysis_problem(payload.values[ANALYSIS_GROUP]): + raise HTTPException(422, problem) + payload.values[ANALYSIS_GROUP] = analysis_values(payload.values[ANALYSIS_GROUP]) if CONCURRENCY_GROUP in payload.values: from app.cluster import slots diff --git a/backend/tests/test_analysis_settings.py b/backend/tests/test_analysis_settings.py new file mode 100644 index 00000000..229dddd3 --- /dev/null +++ b/backend/tests/test_analysis_settings.py @@ -0,0 +1,85 @@ +"""分析设置(设置 → 偏好设置 → 分析,docs/design/analysis-round/01-算子与判定.md §10)。 + +- 设置接口的 analysis 一组:键、缺省、范围、叫法全取 app/analysis/contract.py;GET 按生效的样子给(存坏了的项按缺省), + PUT 先查后写(不认识的键、类型、范围、两两之间的大小) +""" +from __future__ import annotations + +import pytest +from httpx import ASGITransport, AsyncClient + +from app.analysis import contract +from app.api.settings import ANALYSIS_GROUP, analysis_problem, analysis_values +from app.db.base import SessionLocal +from app.db.models import Setting +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 clean_group(): + yield + async with SessionLocal() as session: + row = await session.get(Setting, ANALYSIS_GROUP) + if row is not None: + await session.delete(row) + await session.commit() + + +# -------------------------------------------------------------------------- +# 设置接口 +# -------------------------------------------------------------------------- + + +def test_the_group_is_the_contract(): + assert ANALYSIS_GROUP == contract.SETTINGS_KEY + assert analysis_values(None) == contract.DEFAULTS + # 存坏了的项按缺省;两两之间的大小对不上的那一对都按缺省 + broken = {"main_share": "一半", "max_calls": 3.5, "k_warn": 4, "k_alert": 3.5, "min_history": 20, "max_history": 10, + "table_top_k": 5, "trend_alpha": True} + assert analysis_values(broken) == {**contract.DEFAULTS, "table_top_k": 5} + # 浮点的项存成浮点:发起时的快照、结论里记的阈值一个写法 + assert analysis_values({"k_warn": 3, "k_alert": 4})["k_warn"] == 3.0 + assert isinstance(analysis_values({"k_warn": 3, "k_alert": 4})["k_alert"], float) + + +@pytest.mark.parametrize("value, why", [ + ("宽松", "需要是一个对象"), + ({"bogus": 1}, "不支持「bogus」"), + ({"main_share": 0.95}, f"{contract.LABELS['main_share']}要在 0.3 到 0.9 之间"), + ({"max_calls": 2.5}, f"{contract.LABELS['max_calls']}要填整数"), + ({"k_warn": 3.5}, f"{contract.LABELS['k_alert']}要大于{contract.LABELS['k_warn']}"), + ({"min_history": 60}, f"{contract.LABELS['max_history']}不能小于{contract.LABELS['min_history']}"), +]) +def test_problems_are_said_with_labels(value, why): + assert why in (analysis_problem(value) or "") + + +async def test_get_put_round_trip(client): + got = (await client.get("/api/settings")).json() + assert got[ANALYSIS_GROUP] == contract.DEFAULTS + put = await client.put("/api/settings", json={"values": {ANALYSIS_GROUP: {"main_share": 0.6, "max_calls": 5}}}) + assert put.status_code == 200, put.text + got = (await client.get("/api/settings")).json()[ANALYSIS_GROUP] + assert got == {**contract.DEFAULTS, "main_share": 0.6, "max_calls": 5} + # 整组替换:这次没写的项回到缺省 + await client.put("/api/settings", json={"values": {ANALYSIS_GROUP: {"k_warn": 2.5}}}) + assert (await client.get("/api/settings")).json()[ANALYSIS_GROUP] == {**contract.DEFAULTS, "k_warn": 2.5} + + +async def test_put_refuses_bad_values_and_stores_nothing(client): + r = await client.put("/api/settings", json={"values": {ANALYSIS_GROUP: {"main_share": 0.6, "k_alert": 1.5}}}) + assert r.status_code == 422 and contract.LABELS["k_alert"] in r.json()["detail"] + assert (await client.get("/api/settings")).json()[ANALYSIS_GROUP] == contract.DEFAULTS From 601b84918d1943c223854b9ca6e12fb5cc0434dd Mon Sep 17 00:00:00 2001 From: ThearchyHelios Date: Wed, 21 Oct 2026 13:02:30 +0800 Subject: [PATCH 2/7] =?UTF-8?q?=E5=88=86=E6=9E=90=E8=83=BD=E5=8A=9B?= =?UTF-8?q?=E5=85=A5=E5=8F=A3=EF=BC=88=E4=BA=8C=EF=BC=89=EF=BC=9A=E4=BA=94?= =?UTF-8?q?=E4=B8=AA=20agent=20=E5=88=86=E6=9E=90=E5=B7=A5=E5=85=B7?= =?UTF-8?q?=EF=BC=88analysis=5Fdecompose=20/=20baseline=20/=20trend=20/=20?= =?UTF-8?q?differ=20/=20comparable=EF=BC=8C=E5=90=8D=E5=AD=97=E5=8F=96=20c?= =?UTF-8?q?ontract.TOOL=5FNAMES=EF=BC=89=EF=BC=8C=E5=8F=82=E6=95=B0?= =?UTF-8?q?=E5=8F=AA=E6=9C=89=E3=80=8C=E7=AE=97=E4=BB=80=E4=B9=88=E3=80=8D?= =?UTF-8?q?=EF=BC=88=E5=BC=95=E7=94=A8=E6=9C=AC=E8=8A=82=E7=82=B9=E6=9F=A5?= =?UTF-8?q?=E8=BF=87=E7=9A=84=E7=BB=93=E6=9E=9C=20Q3=E3=80=81=E5=88=97?= =?UTF-8?q?=E7=9A=84=E8=A7=92=E8=89=B2=E3=80=81=E6=9C=9F=E6=AC=A1=E3=80=81?= =?UTF-8?q?=E6=80=BB=E6=95=B0=20headline=E3=80=81=E4=B8=8B=E9=92=BB=20pare?= =?UTF-8?q?nt=E3=80=81=E5=BA=A6=E9=87=8F=E5=8F=AB=E6=B3=95=E3=80=81?= =?UTF-8?q?=E5=9C=A8=E9=AA=8C=E8=AF=81=E4=BB=80=E4=B9=88=EF=BC=89=EF=BC=8C?= =?UTF-8?q?=E6=B2=A1=E6=9C=89=E9=98=88=E5=80=BC=E5=8F=82=E6=95=B0=EF=BC=9B?= =?UTF-8?q?=E4=BA=94=E4=B8=AA=E5=B7=A5=E5=85=B7=E5=85=B1=E7=94=A8=E4=B8=80?= =?UTF-8?q?=E4=BB=BD=E3=80=8C=E5=88=86=E6=9E=90=E6=96=B9=E6=B3=95=E3=80=8D?= =?UTF-8?q?=E8=AF=B4=E6=98=8E=EF=BC=88ToolSpec=20=E6=96=B0=E5=A2=9E=20guid?= =?UTF-8?q?e=EF=BC=9A=E6=96=B9=E6=B3=95=E8=AF=B4=E6=98=8E=20+=20=E5=8E=BB?= =?UTF-8?q?=E9=87=8D=E7=94=A8=E7=9A=84=20id=EF=BC=8Cregister=20/=20build?= =?UTF-8?q?=5Ftools=20=E9=80=8F=E4=BC=A0=E8=BF=9B=E5=B7=A5=E5=85=B7?= =?UTF-8?q?=E7=9A=84=20metadata=EF=BC=89=EF=BC=8CAgent=20=E8=8A=82?= =?UTF-8?q?=E7=82=B9=E3=80=81=E5=8D=8F=E4=BD=9C=E6=88=90=E5=91=98=E7=BB=91?= =?UTF-8?q?=E4=BA=86=E4=BB=BB=E4=B8=80=E5=88=86=E6=9E=90=E5=B7=A5=E5=85=B7?= =?UTF-8?q?=E5=B0=B1=E6=8E=A5=E5=9C=A8=20Skill=20=E5=90=8E=E9=9D=A2?= =?UTF-8?q?=E6=B3=A8=E5=85=A5=20system=EF=BC=8C=E5=90=8C=E4=B8=80=E4=BB=BD?= =?UTF-8?q?=E5=8F=AA=E6=B3=A8=E5=85=A5=E4=B8=80=E6=AC=A1=EF=BC=8C=E6=A8=A1?= =?UTF-8?q?=E5=9E=8B=E8=B0=83=E7=94=A8=E8=8A=82=E7=82=B9=E4=B8=8D=E6=B3=A8?= =?UTF-8?q?=E5=85=A5=E3=80=82Agent=20=E5=BB=BA=E5=B7=A5=E5=85=B7=E6=97=B6?= =?UTF-8?q?=E7=BB=8F=20ToolContext.extra=20=E4=BA=A4=E4=B8=80=E4=B8=AA?= =?UTF-8?q?=E6=9C=AC=E8=8A=82=E7=82=B9=E7=9A=84=E5=88=86=E6=9E=90=E5=AE=B9?= =?UTF-8?q?=E5=99=A8=EF=BC=88app/tools/analysis.py=20=E7=9A=84=20AnalysisS?= =?UTF-8?q?cope=EF=BC=89=EF=BC=8C=E6=AF=8F=E6=AC=A1=E6=9F=A5=E8=AF=A2?= =?UTF-8?q?=E8=AE=B0=E5=8F=B0=E8=B4=A6=E6=97=B6=E5=BE=80=E9=87=8C=E6=94=BE?= =?UTF-8?q?=E4=B8=80=E4=BB=BD=E8=BE=93=E5=85=A5=E5=BF=AB=E7=85=A7=EF=BC=88?= =?UTF-8?q?=E7=BC=96=E5=8F=B7=E5=92=8C=E3=80=8C=E3=80=90=E8=AF=81=E6=8D=AE?= =?UTF-8?q?=20Qn=E3=80=91=E3=80=8D=E5=90=8C=E4=B8=80=E4=B8=AA=EF=BC=89?= =?UTF-8?q?=EF=BC=8C=E5=88=86=E6=9E=90=E7=BB=93=E8=AE=BA=E8=90=BD=E6=88=90?= =?UTF-8?q?=20analysis=5Fresult=20=E5=B7=A5=E4=BB=B6=E3=80=81=E5=8F=B0?= =?UTF-8?q?=E8=B4=A6=E8=AE=B0=20contract.ledger=5Fentry=EF=BC=88=E5=B8=A6?= =?UTF-8?q?=20call=5Fid=EF=BC=89=EF=BC=8C=E4=BA=A4=E5=9B=9E=E6=A8=A1?= =?UTF-8?q?=E5=9E=8B=E3=80=8C=E3=80=90=E5=88=86=E6=9E=90=20An=E3=80=91?= =?UTF-8?q?=E3=80=8D=E5=8A=A0=E5=88=A4=E5=AE=9A=E8=AF=AD=E3=80=81=E5=88=A4?= =?UTF-8?q?=E5=AE=9A=E6=A0=87=E5=87=86=E3=80=81=E5=85=B3=E9=94=AE=E6=95=B0?= =?UTF-8?q?=E3=80=81=E9=99=84=E5=B8=A6=E8=AF=B4=E6=98=8E=E3=80=81=E7=AE=97?= =?UTF-8?q?=E4=B8=8D=E4=BA=86=E7=9A=84=E5=8E=9F=E5=9B=A0=E3=80=81=E7=BB=93?= =?UTF-8?q?=E6=9E=9C=E8=A1=A8=E5=89=8D=E5=87=A0=E6=AE=B5=EF=BC=9Btool.end?= =?UTF-8?q?=20=E5=B8=A6=20analysis=5Fartifact=EF=BC=9B=E4=B8=8B=E9=92=BB?= =?UTF-8?q?=E6=8C=89=20An=20=E5=8F=96=E6=9C=AC=E8=8A=82=E7=82=B9=E7=9A=84?= =?UTF-8?q?=E7=BB=93=E8=AE=BA=EF=BC=9B=E8=B0=83=E7=94=A8=E5=86=99=E9=94=99?= =?UTF-8?q?=EF=BC=88contract.CallError=EF=BC=8C=E5=90=AB=E8=BF=99=E4=B8=AA?= =?UTF-8?q?=E8=8A=82=E7=82=B9=E7=9A=84=E5=88=86=E6=9E=90=E6=AC=A1=E6=95=B0?= =?UTF-8?q?=E7=94=A8=E5=AE=8C=20max=5Fcalls=EF=BC=89=E4=BD=9C=E4=B8=BA?= =?UTF-8?q?=E5=B7=A5=E5=85=B7=E9=94=99=E8=AF=AF=E4=BA=A4=E5=9B=9E=E3=80=81?= =?UTF-8?q?=E4=B8=8D=E8=AE=B0=E5=8F=B0=E8=B4=A6=E3=80=81=E4=B8=8D=E5=8D=A0?= =?UTF-8?q?=E7=BC=96=E5=8F=B7=EF=BC=9B=E7=AE=97=E4=B8=8D=E4=BA=86=E7=85=A7?= =?UTF-8?q?=E6=A0=B7=E8=AE=B0=E7=BB=93=E8=AE=BA=E5=B9=B6=E5=8F=91=E4=B8=80?= =?UTF-8?q?=E6=9D=A1=20analysis=5Frefused=E3=80=82=E7=BC=96=E5=8F=B7?= =?UTF-8?q?=E5=8F=AA=E5=9C=A8=E8=AE=B0=E5=8F=B0=E8=B4=A6=E6=97=B6=E6=94=BE?= =?UTF-8?q?=E8=BF=9B=E5=AE=B9=E5=99=A8=EF=BC=8C=E5=AE=A1=E6=89=B9=E6=81=A2?= =?UTF-8?q?=E5=A4=8D=E9=87=8D=E6=94=BE=E6=97=B6=E5=88=86=E6=9E=90=E4=B8=8D?= =?UTF-8?q?=E5=86=8D=E6=89=A7=E8=A1=8C=E3=80=81=E7=BC=96=E5=8F=B7=E4=B8=8D?= =?UTF-8?q?=E9=94=99=E4=BD=8D=E3=80=82=E9=81=AE=E7=BD=A9=E5=88=97=E6=8C=89?= =?UTF-8?q?=E8=AF=81=E6=8D=AE=E9=9D=A2=E6=9D=BF=E5=90=8C=E4=B8=80=E4=B8=AA?= =?UTF-8?q?=E5=87=BD=E6=95=B0=E8=AE=A4=EF=BC=88=E6=95=B0=E6=8D=AE=E6=BA=90?= =?UTF-8?q?=E8=AE=BE=E7=9A=84=E3=80=81=E6=8C=89=20SQL=20=E8=AE=A4=E5=87=BA?= =?UTF-8?q?=E6=94=B9=E4=BA=86=E5=90=8D=E5=AD=97=E7=9A=84=EF=BC=89=EF=BC=8C?= =?UTF-8?q?=E9=81=AE=E7=BD=A9=E8=BF=90=E8=A1=8C=E4=BA=A4=E7=BB=99=E7=AE=97?= =?UTF-8?q?=E5=AD=90=E7=9A=84=E6=98=AF=E9=81=AE=E8=BF=87=E7=9A=84=E5=BF=AB?= =?UTF-8?q?=E7=85=A7=E3=80=82=E9=98=88=E5=80=BC=E8=AF=BB=E5=8F=91=E8=B5=B7?= =?UTF-8?q?=E6=97=B6=E7=9A=84=E5=BF=AB=E7=85=A7=EF=BC=8C=E6=B2=A1=E6=9C=89?= =?UTF-8?q?=E5=B0=B1=E7=8E=B0=E8=AF=BB=E8=AE=BE=E7=BD=AE=EF=BC=9B=E8=BF=90?= =?UTF-8?q?=E8=A1=8C=E6=9C=9F=E4=BE=9D=E8=B5=96=E7=BB=99=E8=BF=90=E8=A1=8C?= =?UTF-8?q?=E5=BD=93=E5=A4=A9=E3=80=81=E6=8C=89=E8=BF=90=E8=A1=8C=E7=9B=AE?= =?UTF-8?q?=E5=BD=95=E8=A7=86=E5=9B=BE=E8=AF=BB=E7=9A=84=E7=9B=AE=E5=BD=95?= =?UTF-8?q?=E3=80=81=E5=89=96=E6=9E=90=E4=BA=8B=E5=AE=9E=E3=80=81=E7=8E=B0?= =?UTF-8?q?=E6=9F=A5=E8=A1=A8=E8=8C=83=E5=9B=B4=E7=9A=84=E6=8E=A2=E6=9F=A5?= =?UTF-8?q?=EF=BC=88W1-a=20=E7=9A=84=20make=5Fprober=EF=BC=8C=E8=BF=98?= =?UTF-8?q?=E6=B2=A1=E6=9C=89=E6=97=B6=E4=B8=8D=E7=BB=99=EF=BC=89=E3=80=82?= =?UTF-8?q?=E5=8D=8F=E4=BD=9C=E6=88=90=E5=91=98=E5=90=8C=E6=A0=B7=E6=B3=A8?= =?UTF-8?q?=E5=85=A5=E6=96=B9=E6=B3=95=E3=80=81=E5=90=8C=E6=A0=B7=E7=BC=96?= =?UTF-8?q?=E5=8F=B7=EF=BC=88=E5=8D=8F=E4=BD=9C=E8=8A=82=E7=82=B9=E4=B8=8D?= =?UTF-8?q?=E8=AE=B0=E5=8F=B0=E8=B4=A6=EF=BC=89=E3=80=82=E4=BD=BF=E7=94=A8?= =?UTF-8?q?=E8=80=85=E9=97=AE=E6=95=B0=E6=8D=AE=E7=9A=84=E7=99=BD=E5=90=8D?= =?UTF-8?q?=E5=8D=95=E5=8A=A0=E4=B8=8A=E4=BA=94=E4=B8=AA=E5=88=86=E6=9E=90?= =?UTF-8?q?=E5=B7=A5=E5=85=B7=E5=92=8C=E5=88=86=E6=9E=90=E5=8D=A1=EF=BC=9B?= =?UTF-8?q?=E8=B0=83=E7=94=A8=E5=B7=A5=E5=85=B7=E8=8A=82=E7=82=B9=E8=B0=83?= =?UTF-8?q?=E5=88=86=E6=9E=90=E5=B7=A5=E5=85=B7=E5=9C=A8=E7=94=BB=E5=B8=83?= =?UTF-8?q?=E4=B8=8A=E5=B0=B1=E6=8A=A5=E9=94=99=EF=BC=88analysis.tool=5Fno?= =?UTF-8?q?de=EF=BC=89=E3=80=82=E8=83=BD=E5=8A=9B=E7=99=BB=E8=AE=B0?= =?UTF-8?q?=E4=B8=8D=E6=8A=8A=E5=AE=83=E4=BB=AC=E5=BD=93=E6=9F=A5=E8=AF=A2?= =?UTF-8?q?=EF=BC=88is=5Fplatform=5Fquery=20=E4=B8=8D=E8=AE=A4=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/app/auth/chat_policy.py | 18 +- backend/app/engine/nodes/llm.py | 63 +++- backend/app/engine/nodes/multi.py | 57 +++- backend/app/engine/schema.py | 11 + backend/app/tools/analysis.py | 397 ++++++++++++++++++++++ backend/app/tools/builtin/__init__.py | 4 +- backend/app/tools/builtin/analysis.py | 265 +++++++++++++++ backend/app/tools/datasource.py | 20 ++ backend/app/tools/registry.py | 32 ++ backend/tests/fixtures/analysis_ops.py | 61 ++++ backend/tests/test_analysis_tools.py | 439 +++++++++++++++++++++++++ backend/tests/test_catalog_view.py | 2 +- 12 files changed, 1351 insertions(+), 18 deletions(-) create mode 100644 backend/app/tools/analysis.py create mode 100644 backend/app/tools/builtin/analysis.py create mode 100644 backend/tests/fixtures/analysis_ops.py create mode 100644 backend/tests/test_analysis_tools.py diff --git a/backend/app/auth/chat_policy.py b/backend/app/auth/chat_policy.py index 5811cad7..a4e96a35 100644 --- a/backend/app/auth/chat_policy.py +++ b/backend/app/auth/chat_policy.py @@ -4,13 +4,14 @@ start_run 先查来源和运行类别)。图本身再按这里收窄,理由是同一条:对 user 角色来说遮罩就是边界(A7 第 6 条),它能 碰的只有「读数据、整理、撰写」这一类,不能执行代码、不能调外部系统、不能写任何东西: -- 节点只许:输入、模型、Agent、报告、输出、知识库检索、数据整形、条件分支、口径卡、合并查询、调用工具。代码、协作、 +- 节点只许:输入、模型、Agent、报告、输出、知识库检索、数据整形、条件分支、口径卡、合并查询、分析卡、调用工具。代码、协作、 子工作流、循环、人工审批、记忆写入、校验、双盲复算、反方审稿一律不许——后几种不是危险,而是问数据用不到,多放一种 就多一处要单独核对遮罩的地方; - 工具(Agent 绑的、调用工具节点调的同一份白名单,engine/launch.tool_bindings 一并列出)只许数据源工具(db_query__ / db_schema__,查询结果按 MASK_RESULTS_OPTION 在工具边界遮)和只读的内置工具(计算、当前时间、JSON 取值、知识库检索、 - 记忆读取)。自定义工具、MCP、文件、网页、HTTP、代码执行、平台的写入能力(records_append 这类)一律不许;模板拼出来的 - 工具名({{ vars.tool }})发起时不知道是谁,也不许。调用工具节点放行的理由:它和 Agent 调同一个查询工具等价——同一个 + 记忆读取、分析)。分析工具不查库,只读本节点查过、在工具边界遮过的结果,遮罩列不当维度、度量(app/tools/analysis.py); + 分析卡读上游查询节点的快照,同一套遮罩。自定义工具、MCP、文件、网页、HTTP、代码执行、平台的写入能力(records_append + 这类)一律不许;模板拼出来的工具名({{ vars.tool }})发起时不知道是谁,也不许。调用工具节点放行的理由:它和 Agent 调同一个查询工具等价——同一个 ToolContext 带着遮罩、只读两个开关(engine/nodes/tools.py 的 _tool_ctx),参数由人写死反而比模型挑的更好核对; 周期看板、异常处置这些模板的取数节点就是它(个人应用和问数据同一份白名单,blueprints/personal.policy_problem); - 数据源查询一律只读:可写源上的写语句由守卫拒掉,不交给审批(api/runs.py、个人应用刷新、个人蓝图试运行给运行打上 @@ -28,11 +29,14 @@ from __future__ import annotations from typing import Any +from app.analysis.contract import TOOL_NAMES + #: 问数据运行里 user 角色能用的节点类型(engine/schema.py 的 NodeType 取值) CHAT_NODE_TYPES = frozenset({"input", "llm", "agent", "report", "output", "retrieve", "transform", "branch", - "metrics", "merge", "tool"}) -#: Agent 能绑的只读内置工具(tools/builtin/) -CHAT_BUILTIN_TOOLS = frozenset({"calculator", "current_time", "json_query", "kb_search", "memory_recall"}) + "metrics", "merge", "analysis", "tool"}) +#: Agent 能绑的只读内置工具(tools/builtin/):五个分析工具也在内(contract.TOOL_NAMES,不查库) +CHAT_BUILTIN_TOOLS = frozenset({"calculator", "current_time", "json_query", "kb_search", "memory_recall", + *TOOL_NAMES.values()}) #: 运行时改记忆范围、知识库的配置键(engine/nodes 的 ctx.cfg("memory_scope") / ctx.cfg("collection"),图级 defaults 同样生效) SCOPE_KEYS = ("memory_scope", "collection") SCOPE_REFUSED = "没有权限:使用者在问数据中不能指定记忆范围和知识库,使用的是管理员设好的默认值" @@ -90,7 +94,7 @@ def chat_graph_problem(graph: Any, *, where: str = "问数据") -> str | None: return f"没有权限:{who}的工具名由模板拼成,发起前无法确定是哪个工具,使用者在{where}中不能这样绑定工具" if not _tool_allowed(name): return (f"没有权限:{who}绑定了工具「{name}」。使用者在{where}中只能使用数据源查询和只读的内置工具" - "(计算、当前时间、JSON 取值、知识库检索、记忆读取)") + "(计算、当前时间、JSON 取值、知识库检索、记忆读取、分析)") return None diff --git a/backend/app/engine/nodes/llm.py b/backend/app/engine/nodes/llm.py index bf737196..7dff9e3f 100644 --- a/backend/app/engine/nodes/llm.py +++ b/backend/app/engine/nodes/llm.py @@ -10,6 +10,7 @@ from langchain_core.tools import BaseTool from langgraph.func import task from sqlalchemy import select +from app.analysis.contract import CallError, ledger_entry from app.core import artifact_store from app.core.config import settings from app.core.errors import describe_exception, not_configured, raw_detail @@ -49,6 +50,17 @@ from app.providers.factory import ( ) from app.engine.sql_problems import event_checks from app.data import catalog_view +from app.tools.analysis import ( + REFUSED_CODE as ANALYSIS_REFUSED, + SCOPE_KEY as ANALYSIS_SCOPE_KEY, + AnalysisScope, + call_error_text, + input_snapshot, + is_analysis_tool, + payload_of, + reply_text, + run_snapshot, +) from app.tools.datasource import QUERY_PREFIX, run_masks_results, run_reads_only, run_scope_fixed, run_versions from app.tools.registry import ( ToolArgsError, @@ -57,6 +69,7 @@ from app.tools.registry import ( args_model_of, build_tools, describe_args, + guides_text, prepare_args, ) from app.tools.trust import asks_trustable, ask_gate, call_policy, node_task, trust_key_of @@ -110,7 +123,10 @@ async def _load_skills(names: list[str]) -> str: return "# 适用的方法论\n\n" + "\n\n".join(blocks) -async def _build_messages(state: GraphState, ctx: NodeContext) -> list[BaseMessage]: +async def _build_messages(state: GraphState, ctx: NodeContext, tools: list[BaseTool] | None = None + ) -> list[BaseMessage]: + """发给模型的消息。tools 是这个节点绑定的工具:它们带的方法说明(ToolSpec.guide,如分析方法)接在 Skill 后面,同一份 + 只注入一次。模型调用节点没有工具,不注入;没有带说明的工具时 system 和以前一字不差。""" tctx = template_context(state) messages: list[BaseMessage] = [] @@ -121,6 +137,8 @@ async def _build_messages(state: GraphState, ctx: NodeContext) -> list[BaseMessa skill_text = await _load_skills(ctx.cfg("skills", []) or []) if skill_text: system_parts.append(skill_text) + if guide := guides_text(tools): + system_parts.append(guide) if system_parts: messages.append(SystemMessage(content="\n\n".join(system_parts))) @@ -328,7 +346,9 @@ async def run_llm(state: GraphState, ctx: NodeContext) -> dict[str, Any]: # -------------------------------------------------------------------------- -async def _resolve_tools(ctx: NodeContext, state: GraphState) -> list[BaseTool]: +async def _resolve_tools(ctx: NodeContext, state: GraphState, scope: AnalysisScope | None = None) -> list[BaseTool]: + """建这个节点绑的工具。scope 是本节点的分析容器(绑了分析工具才有):经 ToolContext.extra 交给分析工具,工具建在 + 查询之前,查一次往里放一次(_record_call)。""" names = ctx.cfg("tools", []) or [] # 遮罩、只读的运行:记忆范围、知识库只认运行定下的,模型在参数里挑的也不算(tools/registry.ToolContext.fixed_scope) fixed = run_scope_fixed(ctx.run) @@ -346,6 +366,7 @@ async def _resolve_tools(ctx: NodeContext, state: GraphState) -> list[BaseTool]: catalog_view=catalog_view.of_run(ctx.run), # 数据源工具运行中途补固定版本时经它留一条事件(tools/datasource.py 的 _pin_late) emit=ctx.emit, + extra={ANALYSIS_SCOPE_KEY: scope} if scope is not None else {}, ) async with SessionLocal() as session: try: @@ -425,14 +446,17 @@ async def run_agent(state: GraphState, ctx: NodeContext) -> dict[str, Any]: except ProviderNotConfigured as e: raise NodeError(ctx.node.id, not_configured(e)) from e - tools = await _resolve_tools(ctx, state) + # 绑了分析工具才建分析容器:本节点查过的结果、做过的分析都放在这里(app/tools/analysis.py),编号和交回模型的一致 + scope = (AnalysisScope(node_id=ctx.node.id, snapshot=run_snapshot(ctx.run)) + if any(is_analysis_tool(n) for n in ctx.cfg("tools", []) or []) else None) + tools = await _resolve_tools(ctx, state, scope) tool_map = {t.name: t for t in tools} # 默认串行:一轮只调一个工具,拿到结果再想下一步。并行更快,但一批工具 # 中间没有任何新的思考——第二个调用是照着"还没看到第一个结果"时的判断发出来的 parallel_tools = bool(ctx.cfg("parallel_tools", False)) model = bind_tools_safely(base_model, tools, parallel=parallel_tools) - messages = await _build_messages(state, ctx) + messages = await _build_messages(state, ctx, tools) # 护栏(engine/guards.py)。None 是升级前发起的运行:默认 12 步、没有别的护栏,和以前一样 guard = _guard(ctx, model_id) # 证据台账、工具结果前的编号、cite_fields:升级前发起的运行一样都不加(见 evidence.ledger_enabled) @@ -493,6 +517,10 @@ async def run_agent(state: GraphState, ctx: NodeContext) -> dict[str, Any]: # 到点就收回控制权,驱动的收尾在后台做完。模型拿到的是"等了多久、为什么放弃" content, ok = str(e), False extra = {"error": str(e), "timed_out": True} + except CallError as e: + # 分析调用写错了(列不存在、列被遮罩、分析次数用完……,细案 01 §8.1):不记结论、不进台账,原话交回模型照着改 + content, ok = call_error_text(e), False + extra = {"error": content[:300], "call_error": e.code} except Exception as e: # noqa: BLE001 - 工具失败要喂回模型,让它自己纠错 content = f"工具执行失败:{describe_exception(e)}" schema = args_model_of(tool) @@ -522,6 +550,8 @@ 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(封存范围内),证据接口、时间线从这里认这一步的结论 + analysis = payload_of(content) if ok and is_analysis_tool(name) else None ctx.emit( EventType.TOOL_END if ok else EventType.TOOL_ERROR, tool=name, @@ -530,12 +560,18 @@ 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 {}), # 查询当时数据源的表结构快照:和查询快照一样落在封存范围内,证据接口从这里认它 **({"schema_artifact": query["schema_artifact"]} if query and query.get("schema_artifact") else {}), # 这次查询的 SQL 检查结果单独带上:preview 截到前 2000 个字符,checks 通常在截掉的那段里 **({"checks": checks} if ok and name.startswith(QUERY_PREFIX) and (checks := event_checks(content)) else {}), **extra, ) + if analysis is not None and analysis["result"].get("status") == "refused": + # 算不了是一条结论(照常交回模型、进台账),照实提醒一句。发在 task 里:重放取 checkpoint,不会再发一遍 + ctx.emit(EventType.LOG, level="warn", code=ANALYSIS_REFUSED, call_id=call_id, tool=name, + reason_code=analysis["result"].get("reason_code"), reason=analysis["result"].get("reason"), + message=f"{name} 没有做成:{analysis['result'].get('reason') or '原因未知'}") 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) @@ -731,7 +767,7 @@ async def run_agent(state: GraphState, ctx: NodeContext) -> dict[str, Any]: if evidence_on: # 编号只取决于 tool_step 的返回值(重放时取自 checkpoint),重放出来的还是这一套 content = _record_call(ctx.node.id, exec_no, call_id, name, args, outcome, record, - queries, entries) + queries, entries, scope) transcript.append(record) messages.append(ToolMessage(content=content, tool_call_id=call_id)) if guard: @@ -870,12 +906,16 @@ async def run_agent(state: GraphState, ctx: NodeContext) -> dict[str, Any]: 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: + entries: list[dict[str, Any]], scope: AnalysisScope | None = None) -> str: """把一次执行过的工具调用记进台账、调用记录和本节点的查询清单,返回给模型看的内容。 查库成功的结果前面加一行「【证据 Q1】」:模型在循环里就知道每次查询的编号,cite_fields 抽取时照这个编号写出处。编号只在本节点内有效;报告目录里的 Q1… 按全局台账另编,两边 靠工件 id 对应,不靠编号。 + + 绑了分析工具的节点(scope):每次查询另往分析容器里放一份输入快照(编号同上);分析工具交回的结论记一条 kind analysis + 的台账(contract.ledger_entry,带 call_id),编号 An,交回模型的换成「【分析 An】」那一段(app/tools/analysis.py)。 + 编号同样只取决于 tool_step 的返回值,重放出来的还是这一套。 """ content = outcome["content"] record["call_id"] = call_id @@ -884,6 +924,15 @@ def _record_call(node_id: str, exec_no: int, call_id: str, name: str, args: dict record["via"] = snapshot entries.append({"kind": "tool", "node_id": node_id, "exec": exec_no, "artifact": snapshot, "call_id": call_id, "tool": name}) + if scope is not None and outcome.get("ok") and is_analysis_tool(name): + found = payload_of(content) + if found is None: + return content + alias = scope.add_result(found["result"], found["artifact"]) + record.update(alias=alias, analysis_artifact=found["artifact"]) + entries.append(ledger_entry(found["result"], node_id=node_id, exec_no=exec_no, artifact=found["artifact"], + call_id=call_id)) + return reply_text(alias, found["result"]) if not artifact or not is_platform_query(name): return content try: @@ -909,6 +958,8 @@ def _record_call(node_id: str, exec_no: int, call_id: str, name: str, args: dict 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"]}) + if scope is not None: + scope.add_query(alias, input_snapshot(data, ref=alias, artifact=artifact, node_id=node_id)) return f"【证据 {alias}】\n{content}" diff --git a/backend/app/engine/nodes/multi.py b/backend/app/engine/nodes/multi.py index f3ff3002..da37a869 100644 --- a/backend/app/engine/nodes/multi.py +++ b/backend/app/engine/nodes/multi.py @@ -9,6 +9,7 @@ from langchain_core.messages import AIMessage, HumanMessage, SystemMessage, Tool from langgraph.errors import GraphRecursionError from langgraph.func import task +from app.analysis.contract import CallError from app.core.config import settings from app.core.errors import describe_exception, raw_detail from app.core.events import EventType @@ -32,12 +33,24 @@ from app.providers import catalog from app.providers.factory import ModelSpec, bind_tools_safely, get_chat_model from app.engine.sql_problems import event_checks from app.data import catalog_view +from app.tools.analysis import ( + REFUSED_CODE as ANALYSIS_REFUSED, + SCOPE_KEY as ANALYSIS_SCOPE_KEY, + AnalysisScope, + call_error_text, + input_snapshot, + is_analysis_tool, + payload_of, + reply_text, + run_snapshot, +) from app.tools.datasource import QUERY_PREFIX, run_masks_results, run_reads_only, run_scope_fixed, run_versions from app.tools.registry import ( ToolArgsError, ToolContext, args_model_of, build_tools, + guides_text, prepare_args, ) from app.tools.trust import ask_gate, call_policy @@ -302,6 +315,10 @@ async def run_supervisor(state: GraphState, ctx: NodeContext) -> dict[str, Any]: async def _member(name: str, instruction: str, progress: str, tally: dict[str, Any]) -> dict[str, Any]: cfg = agent_map[name] + # 绑了分析工具的成员:这一次执行自己的分析容器(同 Agent 节点)。查询结果照 Agent 的写法编成「【证据 Qn】」,分析结论 + # 编成「【分析 An】」;协作节点不记证据台账,这些编号只给成员自己接着分析、下钻用 + scope = (AnalysisScope(node_id=ctx.node.id, snapshot=run_snapshot(ctx.run)) + if any(is_analysis_tool(t) for t in cfg.get("tools", []) or []) else None) async with SessionLocal() as session: model, model_id = await get_chat_model( session, @@ -325,13 +342,17 @@ async def run_supervisor(state: GraphState, ctx: NodeContext) -> dict[str, Any]: catalog_view=catalog_view.of_run(ctx.run), # 数据源工具运行中途补固定版本时经它留一条事件(tools/datasource.py 的 _pin_late) emit=ctx.emit, + extra={ANALYSIS_SCOPE_KEY: scope} if scope is not None else {}, ) tools = await build_tools(cfg.get("tools", []) or [], tool_ctx, session=session) bound = bind_tools_safely(model, tools, parallel=False) tool_map = {t.name: t for t in tools} + # 绑定的工具带的方法说明(分析方法)接在成员的 system 后面,同一份只注入一次(同 Agent 节点的 _build_messages) + guide = guides_text(tools) + system = cfg.get("system") or f"你是 {name}。" messages: list[Any] = [ - SystemMessage(content=cfg.get("system") or f"你是 {name}。"), + SystemMessage(content=f"{system}\n\n{guide}" if guide else system), HumanMessage(content=f"团队目标:{goal}\n\n已有进展:\n{progress or '(无)'}\n\n你的任务:{instruction}"), ] calls: list[dict[str, Any]] = [] @@ -426,16 +447,29 @@ async def run_supervisor(state: GraphState, ctx: NodeContext) -> dict[str, Any]: except ToolTimeout as e: content = str(e) failure = {"error": content, "timed_out": True} + except CallError as e: + # 分析调用写错了:不记结论,原话交回成员照着改(同 Agent 节点) + content = call_error_text(e) + failure = {"error": content[:300], "call_error": e.code} except Exception as e: # noqa: BLE001 content = f"工具失败:{describe_exception(e)}" failure = {"error": content, "detail": raw_detail(e)} # 查库的 SQL 检查结果单独带上:preview 截到前 1500 个字符,checks 通常在截掉的那段里 checks = event_checks(content) if not failure and tname.startswith(QUERY_PREFIX) else [] + analysis = payload_of(content) if scope is not None and not failure and is_analysis_tool(tname) else None ctx.emit(EventType.TOOL_ERROR if failure else EventType.TOOL_END, tool=tname, agent=name, call_id=cid, preview=content[:1500], duration_ms=int((time.perf_counter() - started) * 1000), **failure, - **({"checks": checks} if checks else {})) + **({"checks": checks} if checks else {}), + **({"analysis_artifact": analysis["artifact"]} 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"), + reason=analysis["result"].get("reason"), + message=f"{name} 的 {tname} 没有做成:{analysis['result'].get('reason') or '原因未知'}") calls.append({"tool": tname, "args": targs, "result": content[:2000]}) + if scope is not None and not failure and tname in tool_map: + content = await _numbered(scope, tname, content, analysis, ctx.run.run_id, ctx.node.id) messages.append(ToolMessage(content=content, tool_call_id=cid)) # 步数用完了:最后一步一定是要了工具的,模型从没拿到过"说结论"的那一轮。 @@ -611,6 +645,25 @@ async def run_supervisor(state: GraphState, ctx: NodeContext) -> dict[str, Any]: return updates +async def _numbered(scope: AnalysisScope, tool: str, content: str, analysis: dict[str, Any] | None, run_id: str, + node_id: str) -> str: + """绑了分析工具的协作成员:一次成功的工具调用交回成员的内容。数据源查询(平台执行的,端点读取另核工件是这次运行落下 + 的)编号「【证据 Qn】」并放进分析容器;分析结论编号「【分析 An】」、换成给模型看的那一段。别的工具原样。""" + from app.capabilities.registry import is_platform_query, snapshot_owned + from app.engine.evidence import query_entry_fields + + if analysis is not None: + return reply_text(scope.add_result(analysis["result"], analysis["artifact"]), analysis["result"]) + if not is_platform_query(tool): + return content + fields = query_entry_fields(content) + if fields is None or not await snapshot_owned(fields["artifact"], run_id, tool): + return content + alias = f"Q{len(scope.queries) + 1}" + scope.add_query(alias, input_snapshot(json.loads(content), ref=alias, artifact=fields["artifact"], node_id=node_id)) + return f"【证据 {alias}】\n{content}" + + # -------------------------------------------------------------------------- # 子图:把另一张工作流当成一个节点 # -------------------------------------------------------------------------- diff --git a/backend/app/engine/schema.py b/backend/app/engine/schema.py index 9adf1440..e6e439da 100644 --- a/backend/app/engine/schema.py +++ b/backend/app/engine/schema.py @@ -561,6 +561,13 @@ def _check_dual_check(node: GraphNode, spec: GraphSpec, result: ValidationResult "definitions", "dual_check.definitions_invalid") +def _analysis_tools() -> frozenset[str]: + """五个分析工具的名字(contract.TOOL_NAMES)。""" + from app.analysis.contract import TOOL_NAMES + + return frozenset(TOOL_NAMES.values()) + + def _produces_query(node: GraphNode) -> bool: """这个节点的产出是一份存成快照的查询结果:调用数据库查询工具的「调用工具」节点,或另一个合并查询。""" if node.type == NodeType.MERGE: @@ -938,6 +945,10 @@ def validate_graph(spec: GraphSpec) -> ValidationResult: elif node.type == NodeType.TOOL: if not cfg.get("tool"): result.add("「调用工具」节点还没有选择工具", node_id=node.id, field="tool") + elif str(cfg.get("tool")).strip() in _analysis_tools(): + # 分析工具读的是同一个 Agent 查过的结果(【证据 Qn】),调用工具节点里没有这份容器,运行到这里一定失败 + result.add("「调用工具」节点不能调用分析工具:分析工具分析的是同一个 Agent 查过的结果。模板里要固定做的分析," + "请改用「分析卡」节点", node_id=node.id, field="tool", code="analysis.tool_node") # 写入动作的确认派给谁(应用平台 §3.8):写法同人工节点 from app.engine.lint_process import assignee_problem diff --git a/backend/app/tools/analysis.py b/backend/app/tools/analysis.py new file mode 100644 index 00000000..a969a0e3 --- /dev/null +++ b/backend/app/tools/analysis.py @@ -0,0 +1,397 @@ +"""分析的入口共用的运行期部分(docs/design/analysis-round/00-总方案.md §3.2、§3.4、§3.10)。 + +两个入口——agent 分析工具(tools/builtin/analysis.py)、分析卡(engine/nodes/analysis.py)——和口径卡的可比性检查 +(engine/nodes/metrics.py)都经这里取阈值、拼算子要的运行期依赖、认遮罩列、把结论落成工件,算子只经 app.analysis.run +调用,不各自另起一套: + +- **本节点的查询容器**(AnalysisScope):agent 建工具早于查询(engine/nodes/llm.py),工具经 ToolContext.extra[SCOPE_KEY] + 拿到这个可变容器。节点每记一次查询往里放一份输入快照(编号和交回模型的「【证据 Qn】」同一个),每记一份分析结论往里 + 放一份(编号 An,下钻的 parent 按它找上一层)。只在记台账的地方往里放:工具调用在 @task 里,重放时不执行,记台账的 + 那一步按 checkpoint 里的结果照样放一遍,编号还是同一套。 +- **阈值**:发起时的快照(run.options["analysis"],engine/launch.py)← 分析卡上写的覆盖;没有快照的现读设置。agent 改不了: + 分析工具的参数里没有阈值(契约 D4)。 +- **运行期依赖**(contract.Context):运行当天(服务端时区)、现查表的日期范围(W1-a 的 make_prober,没有这个模块时不给)、 + 数据目录(按运行的目录视图读,和 db_schema 给模型看的同一份)、剖析事实。 +- **遮罩列**:数据源设的遮罩列、查询当时记下的、按 SQL 认出改了名字的(和证据面板、运行对比同一个函数,api/evidence 的 + _mask_sets 的 hidden,宁可多遮),加上遮罩运行里查询时就整列遮掉的。算子不拿它们当维度、度量。 +- **结论**:整份落成内容寻址的工件(contract.ARTIFACT_KIND),台账记 contract.ledger_entry;交回 agent 的是一段短的 + 「【分析 An】」加判定语、关键数、附带说明、算不了的原因。 +""" +from __future__ import annotations + +import json +import logging +from collections.abc import Iterable, Mapping +from dataclasses import dataclass, field +from datetime import date +from typing import Any + +from app.analysis import contract +from app.analysis.contract import AnalysisResult, CallError, InputSnapshot + +logger = logging.getLogger(__name__) + +#: ToolContext.extra 里放本节点分析容器(AnalysisScope)的键 +SCOPE_KEY = "analysis_scope" +#: 五个分析工具的名字(contract.TOOL_NAMES) +ANALYSIS_TOOLS = frozenset(contract.TOOL_NAMES.values()) +#: 交回 agent 的那一段开头的编号写法,和查询的「【证据 Qn】」一个样子 +REPLY_HEAD = "【分析 {alias}】" +#: 调用写错时交回 agent 的那句的开头:说清没有记成结论,下一步照着改 +CALL_ERROR_HEAD = "分析调用有误,没有记成结论:" +#: agent 写的「这次在验证什么」最多留多少字(只在证据面板显示,不进正文) +HYPOTHESIS_MAX = 300 +#: 一份分析算不了(status refused)时那条提醒的 code(运行日志):两个入口同一个。复核信号按它认(engine/review.py 的 +#: WARN_CODES,第 2 波登记) +REFUSED_CODE = "analysis_refused" + + +def is_analysis_tool(name: Any) -> bool: + return isinstance(name, str) and name.strip() in ANALYSIS_TOOLS + + +def call_error_text(error: CallError) -> str: + """调用写错(contract.CallError)交回模型的那句:不记结论、不进台账,模型照着 message 改了再调。""" + return f"{CALL_ERROR_HEAD}{error.message}" + + +# -------------------------------------------------------------------------- +# 本节点的查询容器 +# -------------------------------------------------------------------------- + + +@dataclass +class AnalysisScope: + """一次节点执行里分析工具读的容器(agent 节点、协作成员的一次执行各一个)。 + + queries:编号 Qn → 输入快照(contract.InputSnapshot,masked 留空,用到时由 prepare 补)。results:编号 An → 整份 + 结论;artifacts:An → 结论的工件 id。snapshot:发起时的分析设置快照(没有为 None,现读设置)。mask_cache:快照工件 → + 遮罩列;source_cache:数据源和表 → 目录、剖析事实(一次节点执行里各读一次,只在工具执行时用,不影响编号)。""" + + node_id: str + snapshot: dict[str, Any] | None = None + queries: dict[str, InputSnapshot] = field(default_factory=dict) + results: dict[str, AnalysisResult] = field(default_factory=dict) + artifacts: dict[str, str] = field(default_factory=dict) + mask_cache: dict[str, list[str]] = field(default_factory=dict) + source_cache: dict[str, Any] = field(default_factory=dict) + + def add_query(self, alias: str, snapshot: InputSnapshot) -> None: + self.queries[alias] = snapshot + + def add_result(self, result: AnalysisResult, artifact: str) -> str: + """记一份结论,返回它在本节点的编号(A1、A2……,按记下的先后)。""" + alias = f"{contract.CATALOG_PREFIX}{len(self.results) + 1}" + self.results[alias] = result + self.artifacts[alias] = artifact + return alias + + def resolve(self, name: str) -> InputSnapshot | None: + return self.queries.get(str(name).strip()) + + def parent(self, name: str) -> AnalysisResult | None: + return self.results.get(str(name).strip()) + + +def scope_of(extra: Mapping[str, Any] | None) -> AnalysisScope | None: + scope = (extra or {}).get(SCOPE_KEY) + return scope if isinstance(scope, AnalysisScope) else None + + +# -------------------------------------------------------------------------- +# 阈值 +# -------------------------------------------------------------------------- + + +def run_snapshot(run_ctx: Any) -> dict[str, Any] | None: + """这次运行发起时快照的分析设置(run.options["analysis"],engine/launch.py 写,runner 放进 RunContext.extra 的 + run_options)。没有快照(图里本来没有分析、不经 launch_run 发起的)为 None。""" + from app.engine.runner import RUN_OPTIONS_KEY + + options = (getattr(run_ctx, "extra", None) or {}).get(RUN_OPTIONS_KEY) + snapshot = options.get(contract.SETTINGS_KEY) if isinstance(options, dict) else None + return snapshot if isinstance(snapshot, dict) else None + + +async def settings_of(snapshot: Mapping[str, Any] | None, + overrides: Mapping[str, Any] | None = None) -> dict[str, Any]: + """这次分析实际用的阈值:缺省 ← 发起时的快照(没有就现读设置)← 分析卡上的覆盖(contract.effective_settings)。""" + if snapshot is None: + from app.api.settings import analysis_settings # 在这里导入:api.settings 导入了 engine 里的模块 + from app.db.base import SessionLocal + + async with SessionLocal() as session: + snapshot = await analysis_settings(session) + return contract.effective_settings(snapshot, overrides) + + +# -------------------------------------------------------------------------- +# 输入快照与遮罩列 +# -------------------------------------------------------------------------- + + +def input_snapshot(content: Mapping[str, Any], *, ref: str, artifact: str, node_id: str) -> InputSnapshot: + """一份查询快照(数据源查询工具交回的、工件库里的)→ 算子的一份输入(masked 留空,prepare 时补)。列的类型对齐成和 + columns 同序的列表(快照里记的是 {列: 类型},没记的为 None);行一律写成和 columns 同序的列表。""" + columns = [str(c) for c in content.get("columns") or []] + types = content.get("column_types") + rows: list[list[Any]] = [] + for row in content.get("rows") or []: + if isinstance(row, (list, tuple)): + rows.append(list(row)) + elif isinstance(row, Mapping): + rows.append([row.get(c) for c in columns]) + else: + rows.append([row]) + return { + "ref": ref, "artifact": artifact, "node_id": node_id, "source": str(content.get("source") or ""), + "sql": str(content.get("sql") or ""), "columns": columns, + "column_types": [types.get(c) if isinstance(types, Mapping) else None for c in columns], + "rows": rows, "truncated": bool(content.get("truncated")), "masked": [], + } + + +async def masked_of(content: Mapping[str, Any] | None, artifact: str | None) -> list[str]: + """一份查询快照里不能当维度、度量的列(小写):结果里取自遮罩列的列按快照的列序在前,其余遮罩列名在后。 + + 认法和证据面板查询步骤、运行对比同一个函数(api/evidence 的 _mask_sets 的 hidden:查询当时记下的加数据源现在设的, + SQL 里给遮罩列起了别名、套了函数的结果列也算,SQL 认不准时整份的列都算,合并查询按它的来历认)。遮罩运行 + (mask_results)里查询时就遮掉的列(masked_at_query、整份遮的 masked_whole)格子里已经是「已遮罩」,一并算上。""" + from app.api.evidence import _Masks # 在这里导入:api.evidence 导入了 engine 里的模块 + + if not isinstance(content, Mapping): + return [] + hidden = set((await _Masks().sets(artifact, content.get("source"), dict(content))).hidden) + columns = [str(c) for c in content.get("columns") or []] + hidden |= {str(c).lower() for c in content.get("masked_at_query") or [] if isinstance(c, str)} + if content.get("masked_whole"): + hidden |= {c.lower() for c in columns} + shown = [c.lower() for c in columns if c.lower() in hidden] + return list(dict.fromkeys([*shown, *sorted(hidden - set(shown))])) + + +async def prepare(scope: AnalysisScope) -> None: + """补上容器里每份输入快照的遮罩列(每份工件只认一次)。快照取不回来、和哈希对不上的照实报错:查询明明交回过, + 分析读不到它的快照,不是 agent 写错了参数。""" + import asyncio + + from app.core import artifact_store + + for alias, snapshot in scope.queries.items(): + artifact = snapshot.get("artifact") or "" + if artifact not in scope.mask_cache: + try: + content = await asyncio.to_thread(artifact_store.load, artifact) + except ValueError as e: + raise RuntimeError(f"{alias} 的查询快照与哈希不一致,疑似被修改,不能分析") from e + if content is None: + raise RuntimeError(f"{alias} 的查询快照已不存在,不能分析") + scope.mask_cache[artifact] = await masked_of(content, artifact) + snapshot["masked"] = list(scope.mask_cache[artifact]) + + +# -------------------------------------------------------------------------- +# 运行期依赖:运行当天、目录、剖析事实、现查表范围 +# -------------------------------------------------------------------------- + + +def _tables_of(snapshots: Iterable[Mapping[str, Any]]) -> dict[str, set[str]]: + """数据源名 → 这几份快照的 SQL 里用到的表(evidence.sql_tables,去掉 schema 前缀前的写法)。合并查询不是数据源。""" + from app.engine.evidence import sql_tables + from app.engine.merge_query import MERGE_SOURCE + + out: dict[str, set[str]] = {} + for snap in snapshots: + name = str(snap.get("source") or "") + if not name or name == MERGE_SOURCE: + continue + try: + tables = sql_tables(str(snap.get("sql") or "")) + except Exception: # noqa: BLE001 - 认不出表就只是少给一点目录 + tables = [] + out.setdefault(name, set()).update(str(t) for t in tables) + return out + + +async def context_of(snapshots: Iterable[Mapping[str, Any]], *, tool_ctx: Any, + cache: dict[str, Any] | None = None) -> contract.Context: + """算子要的运行期依赖(contract.Context)。 + + - today:运行当天,服务端时区(算子据此判本期完不完整) + - catalogs:数据源名 → {表名: 这张表的目录(app/data/catalog 的 notes)},按运行的目录视图读(tool_ctx.catalog_view, + 和 db_schema 给模型看的、冻结进表结构快照的同一份),只给快照的 SQL 里用到的表 + - facts:数据源名 → {表名: 剖析事实(catalog_profile_jobs.last_facts)};上传表格的剖析不是这次查的那一版的不给 + - probe:现查一列的实际范围(W1-a 的 make_prober,按这次运行固定的数据版本解析数据源;没有这个模块时为 None) + + tool_ctx 是查询用的那个 ToolContext(固定的数据版本、目录视图都从它取)。cache 是调用方的缓存(同一个节点里分析 + 好几次,目录、剖析事实读一次)。读不到的源当没有:目录、剖析是附加说明,不能挡住分析。""" + cache = cache if cache is not None else {} + catalogs: dict[str, Any] = {} + facts: dict[str, Any] = {} + found: dict[str, Any] = {} + for name, tables in _tables_of(snapshots).items(): + key = f"{name}\n" + "\n".join(sorted(tables)) + if key not in cache: + cache[key] = await _source_context(name, tables, tool_ctx) + entry = cache[key] + if entry is None: + continue + found[name] = entry["source"] + catalogs[name], facts[name] = entry["catalog"], entry["facts"] + return {"today": date.today(), "probe": _prober(found, facts), "catalogs": catalogs, "facts": facts} + + +async def _source_context(name: str, tables: set[str], tool_ctx: Any) -> dict[str, Any] | None: + """一个数据源这几张表的目录、剖析事实,和查询用的那个源(探查用)。读不到为 None。""" + from app.data import catalog as data_catalog + from app.data import catalog_view + from app.data.catalog_profile_jobs import last_facts + from app.db.base import SessionLocal + from app.tools.datasource import resolved_source + + try: + resolved = await resolved_source(name, tool_ctx) + if resolved is None: + return None + row, source = resolved + entries = await catalog_view.read(catalog_view.CatalogView.parse(getattr(tool_ctx, "catalog_view", None)), row) + names = {data_catalog.resolve_table_name(getattr(source, "schema_cache", None), t) or t for t in tables} + async with SessionLocal() as session: + profiled = await last_facts(session, str(row.id), sorted(names)) if names else {} + except Exception: # noqa: BLE001 - 目录、剖析读不到当没有(目录视图指定的快照不在了另由查询工具照实报) + logger.warning("分析读取数据源 %s 的目录、剖析事实失败,这次不附", name, exc_info=True) + return None + snapshot = getattr(source, "snapshot_id", None) + return { + "source": source, + "catalog": {t: dict(entries[t].notes or {}) for t in sorted(names) if t in entries}, + # 上传表格:剖析时是另一版数据,范围对不上这次读的这一版(同 tools/datasource 的 _coverage) + "facts": {t: f for t, f in profiled.items() if not (snapshot and f.get("snapshot_id") != snapshot)}, + } + + +def _probe_factory() -> Any: + """W1-a 的 app.analysis.probe.make_prober;还没有这个模块时为 None(算子只用剖析事实,核对不了的写「核对不了」)。""" + try: + from app.analysis.probe import make_prober # type: ignore[import-not-found] + except ModuleNotFoundError as e: + if e.name not in ("app.analysis.probe", "app.analysis"): + raise + return None + return make_prober + + +def _prober(sources: Mapping[str, Any], facts: Mapping[str, Any]) -> contract.Prober | None: + """按数据源名分派的探查(contract.Prober)。每个源第一次被问到时才建它自己的探查: + + make_prober(查询用的源, masked=遮罩列, cache=coverage.Cache(), facts=按表取剖析事实的协程函数) + + 签名以 W1-a 的交付为准(合并时由编排者对齐这一处)。遮罩列上的不探(data/coverage 的规矩),探查失败由它自己退回 + 剖析事实。""" + make = _probe_factory() + if make is None or not sources: + return None + from app.data import coverage + from app.data.engine import masked_columns + + built: dict[str, Any] = {} + + async def probe(source: str, table: str, column: str) -> contract.Probe | None: + if source not in built: + obj = sources.get(source) + known = (facts.get(source) or {}) if isinstance(facts, Mapping) else {} + + async def by_table(name: str, known: Mapping[str, Any] = known) -> Mapping[str, Any] | None: + return known.get(name) + + built[source] = None if obj is None else make( + obj, masked=masked_columns(getattr(obj, "options", None)), cache=coverage.Cache(), facts=by_table) + found = built[source] + return await found(source, table, column) if found is not None else None + + return probe + + +def tool_context(run_ctx: Any, node_id: str, emit: Any = None) -> Any: + """分析卡、口径卡没有自己的 ToolContext:拼一个只用来解析数据源的(固定的数据版本、目录视图、遮罩、只读同运行)。 + 记忆范围、知识库用不着,不读节点配置。""" + from app.data import catalog_view + from app.tools.datasource import run_masks_results, run_reads_only, run_scope_fixed, run_versions + from app.tools.registry import ToolContext + + return ToolContext(run_id=run_ctx.run_id, node_id=node_id, sandbox_session=run_ctx.thread_id, + data_versions=run_versions(run_ctx), mask_results=run_masks_results(run_ctx), + force_readonly=run_reads_only(run_ctx), fixed_scope=run_scope_fixed(run_ctx), + catalog_view=catalog_view.of_run(run_ctx), emit=emit) + + +# -------------------------------------------------------------------------- +# 结论:落成工件、交回模型 +# -------------------------------------------------------------------------- + + +async def store(result: AnalysisResult, *, run_id: str, node_id: str) -> str: + """整份结论落成内容寻址的工件(contract.ARTIFACT_KIND)。复算、证据面板、导出都按这个 id 取回。""" + from app.core.artifact_store import put_json + + return await put_json(result, kind=contract.ARTIFACT_KIND, run_id=run_id, node_id=node_id, + meta={"op": result.get("op"), "status": result.get("status")}) + + +def payload_of(content: Any) -> dict[str, Any] | None: + """分析工具交回的 JSON(文本或对象)→ {artifact, result};不是一份分析结论的返回 None。""" + data = content + 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, result = data.get("analysis_artifact"), data.get("result") + if not (isinstance(artifact, str) and artifact and isinstance(result, dict) and result.get("op") in contract.OPS): + return None + return {"artifact": artifact, "result": result} + + +def reply_text(alias: str, result: AnalysisResult) -> str: + """交回模型的那一段:「【分析 An】」加一个紧凑的 JSON——判定、判定语、判定标准、关键数、附带说明、算不了的原因、 + 结果表的前几段(列几段照结论里记的 table_top_k)。整份结论在工件里,模型要的是能接着往下做的那几样。""" + verdict = result.get("verdict") or {} + body: dict[str, Any] = {"status": result.get("status"), "op": result.get("op")} + if result.get("mode"): + body["mode"] = result.get("mode") + if result.get("status") == "ok": + body.update(verdict=verdict.get("code"), subjects=verdict.get("subjects") or [], + direction=verdict.get("direction")) + else: + body.update(reason_code=result.get("reason_code"), reason=result.get("reason")) + body["phrase"] = result.get("phrase") + if result.get("criterion"): + body["criterion"] = result.get("criterion") + if result.get("values"): + body["values"] = result.get("values") + if caveats := [c.get("text") for c in result.get("caveats") or [] if isinstance(c, dict) and c.get("text")]: + body["caveats"] = caveats + table = result.get("table") + if isinstance(table, dict) and table.get("rows"): + top = int((result.get("settings") or {}).get("table_top_k") or contract.DEFAULTS["table_top_k"]) + rows = list(table.get("rows") or []) + body["table"] = {"columns": table.get("columns"), "rows": rows[:top], + **({"more_rows": len(rows) - top} if len(rows) > top else {})} + return REPLY_HEAD.format(alias=alias) + "\n" + json.dumps(body, ensure_ascii=False, default=str) + + +def hypothesis_of(value: Any) -> str | None: + """agent(或模板作者)写的「这次在验证什么」:去掉首尾空白、截到 HYPOTHESIS_MAX 字;空的为 None。""" + text = str(value or "").strip() + return text[:HYPOTHESIS_MAX] if text else 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", + "run_snapshot", "scope_of", "settings_of", "store", "tool_context", +] diff --git a/backend/app/tools/builtin/__init__.py b/backend/app/tools/builtin/__init__.py index 313410d2..6c3f2551 100644 --- a/backend/app/tools/builtin/__init__.py +++ b/backend/app/tools/builtin/__init__.py @@ -1,5 +1,5 @@ """导入即注册。新增内置工具时在这里加一行。""" -from app.tools.builtin import code, data, files, knowledge, platform, web # noqa: F401 +from app.tools.builtin import analysis, code, data, files, knowledge, platform, web # noqa: F401 -__all__ = ["code", "data", "files", "knowledge", "platform", "web"] +__all__ = ["analysis", "code", "data", "files", "knowledge", "platform", "web"] diff --git a/backend/app/tools/builtin/analysis.py b/backend/app/tools/builtin/analysis.py new file mode 100644 index 00000000..830bbbea --- /dev/null +++ b/backend/app/tools/builtin/analysis.py @@ -0,0 +1,265 @@ +"""agent 分析工具(docs/design/analysis-round/00-总方案.md §3.2):变化拆解、波动基线、趋势、差异检验、可比性检查,一个算子 +一个工具(名字取 contract.TOOL_NAMES),参数各自聚焦,模型少写错。 + +- **参数只有「算什么」**:引用本节点查过的结果(query: "Q3",和交回模型的「【证据 Q3】」同一个编号)、列的角色、期次、 + 总数、上一层拆解、度量的叫法、这次在验证什么。没有任何阈值参数:阈值只来自设置快照(D4),模型不能把阈值调到想要的 + 结论。参数名写错、多写了参数,照别的工具一样交回「它接受什么」(registry.prepare_args)。 +- **调用写错**(contract.CallError:列不存在、列被遮罩、比率当可加、这个节点的分析次数用完……)不记结论,原话交回模型改; + **算不了**(历史不够、本期不完整、截断……)是一条结论,照样落成工件、进台账。 +- **方法说明**:五个工具共用一份「分析方法」(METHOD_GUIDE),绑了任一个就注入 system,同一份只注入一次。 +- 工具本身不查库:读的是本节点的查询容器(app/tools/analysis.AnalysisScope),能力登记里不认它们是查询 + (capabilities.registry.is_platform_query 只认数据源查询和声明了表格的端点读取)。 +""" +from __future__ import annotations + +from typing import Any, Literal + +from pydantic import BaseModel, Field + +from app.analysis import contract +from app.analysis.contract import CallError +from app.tools.registry import ToolContext, ToolGuide, register + +CATEGORY = "分析" + +#: 「分析方法」:五个分析工具共用,绑了任一个就注入 system(第一版,第 2 波定稿)。改了方法就改 id 里的版本号:评测的 +#: prompt_fingerprint 认得出提示词变了 +ANALYSIS_METHOD_PROMPT = """\ +# 分析方法(平台内置,用分析工具时照这个做) + +1. 先定现象:取本期和问题对应的基期,用 analysis_comparable 确认两期能直接比;历史够长(缺省至少 8 期)就用 analysis_baseline 放回历史看——是常规波动就照实说,不硬找原因。 +2. 指标是乘积或比率时,先用 analysis_decompose 的 factor、ratio 模式拆因子、拆结构。 +3. 按数据目录里的候选维度由粗到细拆(additive 模式),每次用 headline 给出总数核对拆开后是否闭合;找到主要来源再用 parent 下钻一层:下钻的 SQL 由你写(只查那一段),系统按上一层那一段的值核对闭合。 +4. 每次调用在 hypothesis 里写明这次在验证什么;拆了没发现集中也是结论,照实写。 +5. 不作因果断言:数据只说明变化来自哪里;原因要有知识库原话或输入作依据,否则写成待核实的推测。 +6. 阈值和方法由平台定,工具参数里没有阈值;算不了时照实说算不了的原因,不要换个说法硬下判断。 + +分析结论的编号「【分析 An】」只在本节点有效:下钻时 parent 写它(如 {"of": "A1", "key": ["华东"]})。判定语由系统写好了,结论里照它的意思说,不要改写判断的强弱。""" +METHOD_GUIDE = ToolGuide(id="analysis.method@1", text=ANALYSIS_METHOD_PROMPT) + +#: 交回 agent 的说法:这个节点里没有可分析的查询容器(调用工具节点、工具库试用、没记台账的老运行) +NO_SCOPE = ("分析工具只能由 Agent 在本节点查过数之后调用:它分析的是本节点交回过的查询结果(【证据 Qn】的编号)。" + "模板里要固定做的分析,请改用「分析卡」节点") +#: 交回 agent 的说法:本节点还没有成功的查询 +NO_QUERIES = "本节点还没有成功的查询可以分析:先用数据库查询工具查出要分析的数,再按交回的编号(Q1、Q2……)调用分析工具" +#: 这个节点的分析次数用完 +MAX_CALLS = ("这个节点已经做了 {done} 次分析,达到「{label}」的上限({limit} 次),不能再分析了。" + "请基于已有的分析结论给出结论,没查清的照实说") + +_QUERY = "要分析的查询结果:本节点交回过的编号,如 Q3(和「【证据 Q3】」同一个)" +_LABEL = "度量的叫法,系统写判定语时用(如「销售额」);不要写数字、百分号和单位,留空就用列名" +_HYPOTHESIS = "这次在验证什么,一句话(如「下降是不是集中在某个地区」):只在证据面板显示,不进报告正文" +_PERIOD = "期次列的列名(日期、周、月……)" +_GRAIN = "期次的粒度:day、week、month、quarter;留空按期次的写法推断" + + +class Headline(BaseModel): + """总数的两格:拆开后各段之和要分别等于它们(拆解闭合)。""" + + base: str = Field(description="基期总数那一格,写成「编号.r行号.列名」,如 Q1.r1.gmv(行号从 0 数)") + target: str = Field(description="本期总数那一格,如 Q1.r0.gmv") + + +class Parent(BaseModel): + """下钻:这一层是上一层拆解里某一段的拆解。""" + + of: str = Field(description="上一层拆解的编号,如 A1(「【分析 A1】」里的那个)") + key: list[str] = Field(description="上一层里要往下拆的那一段的维度值,一个维度一个值(如 华东)") + + +class DecomposeArgs(BaseModel): + mode: Literal["additive", "ratio", "factor"] = Field( + description="additive:可加的量(销售额、订单数)按维度算各段对总变化的贡献;ratio:比率(退款率、客单价)" + "拆成结构变化和各段自身的变化;factor:乘积(销售额 = 订单数 × 客单价)拆成各因子的贡献") + query: str | None = Field(default=None, description=_QUERY + ";这份结果里有期次列,用 base、target 指定比哪两期") + base_query: str | None = Field(default=None, + description="基期、本期是两份查询时:基期那份的编号(这时不写 query、period)") + target_query: str | None = Field(default=None, description="基期、本期是两份查询时:本期那份的编号") + by: list[str] | None = Field(default=None, description="维度列的列名,1 到 2 列(additive、ratio 用)") + period: str | None = Field(default=None, description=_PERIOD) + base: str | None = Field(default=None, description="基期:期次列里的一个值,如 2026-W41") + target: str | None = Field(default=None, description="本期:期次列里的一个值,如 2026-W42") + measure: str | None = Field(default=None, description="度量列(additive 用)") + numerator: str | None = Field(default=None, description="分子列(ratio 用,如退款单数)") + denominator: str | None = Field(default=None, description="分母列(ratio 用,如订单数)") + product: str | None = Field(default=None, description="乘积列(factor 用,如销售额)") + factors: list[str] | None = Field(default=None, description="因子列,2 到 4 列(factor 用,如订单数、客单价)") + headline: Headline | None = Field(default=None, description="可选。总数的两格,用来核对拆开后各段之和对不对得上") + parent: Parent | None = Field(default=None, description="可选。下钻:这一层是哪次拆解里哪一段的拆解") + label: str | None = Field(default=None, description=_LABEL) + hypothesis: str | None = Field(default=None, description=_HYPOTHESIS) + + +class BaselineArgs(BaseModel): + query: str = Field(description=_QUERY + ";要一条按期次排的序列(要看某一段就在 SQL 里筛好)") + period: str = Field(description=_PERIOD) + measure: str = Field(description="度量列") + target: str | None = Field(default=None, description="本期:期次列里的一个值;留空取最后一期") + basis: Literal["change", "level"] | None = Field( + default=None, description="change:和比较期比的变化率(缺省);level:值本身(存量、比率用)") + grain: Literal["day", "week", "month", "quarter"] | None = Field(default=None, description=_GRAIN) + lag: int | None = Field(default=None, description="和往前第几期比:日缺省 7(和上周同一天比),周、月缺省 1;" + "要同比写 52(周)或 12(月),历史要相应够长") + label: str | None = Field(default=None, description=_LABEL) + hypothesis: str | None = Field(default=None, description=_HYPOTHESIS) + + +class TrendArgs(BaseModel): + query: str = Field(description=_QUERY + ";要一条按期次排的序列") + period: str = Field(description=_PERIOD) + measure: str = Field(description="度量列") + grain: Literal["day", "week", "month", "quarter"] | None = Field(default=None, description=_GRAIN) + label: str | None = Field(default=None, description=_LABEL) + hypothesis: str | None = Field(default=None, description=_HYPOTHESIS) + + +class DifferArgs(BaseModel): + mode: Literal["rate", "mean", "rows"] = Field( + description="rate:比率(转化率、退款率),给分子、分母两列;mean:均值,给每组的均值、标准差、样本数三列," + "或合计、平方和、样本数三列;rows:逐行的值,算子自己算均值和方差(结果不能截断)") + query: str = Field(description=_QUERY) + by: list[str] | None = Field(default=None, description="分组列的列名") + reference: str | None = Field(default=None, description="对照组:分组列里的一个值;留空和整体比") + pairs: list[list[str]] | None = Field(default=None, description="只比这几对:每对写两个组在分组列里的值") + period: str | None = Field(default=None, description="按期次比同一组的两期时:期次列") + base: str | None = Field(default=None, description="按期次比时:基期") + target: str | None = Field(default=None, description="按期次比时:本期") + numerator: str | None = Field(default=None, description="分子列:成功数(rate 用,如退款单数)") + denominator: str | None = Field(default=None, description="分母列:试验数(rate 用,如订单数)") + mean: str | None = Field(default=None, description="每组的均值列(mean 用)") + std: str | None = Field(default=None, description="每组的标准差列(mean 用)") + sum: str | None = Field(default=None, description="每组的合计列(mean 用,没有标准差时和平方和一起给)") + sum_sq: str | None = Field(default=None, description="每组的平方和列(mean 用)") + n: str | None = Field(default=None, description="每组的样本数列(mean 用)") + measure: str | None = Field(default=None, description="逐行的值列(rows 用)") + label: str | None = Field(default=None, description=_LABEL) + hypothesis: str | None = Field(default=None, description=_HYPOTHESIS) + + +class ComparableArgs(BaseModel): + base_query: str | None = Field(default=None, description="基期那份查询的编号,如 Q1") + target_query: str | None = Field(default=None, description="本期那份查询的编号,如 Q2") + query: str | None = Field(default=None, description="两期在同一份查询里按期次分组时:那份的编号(这时不写上面两项)") + period: str | None = Field(default=None, description="同一份查询时:期次列") + base: str | None = Field(default=None, description="同一份查询时:基期") + target: str | None = Field(default=None, description="同一份查询时:本期") + label: str | None = Field(default=None, description=_LABEL) + hypothesis: str | None = Field(default=None, description=_HYPOTHESIS) + + +#: 算子 → 参数模型。分析卡的一项按同一份参数模型校验参数(engine/nodes/analysis.py) +ARGS: dict[str, type[BaseModel]] = { + "decompose": DecomposeArgs, "baseline": BaselineArgs, "trend": TrendArgs, "differ": DifferArgs, + "comparable": ComparableArgs, +} +#: 不算「算什么」的参数:模式另放,这次在验证什么另放(contract.AnalysisRequest) +NOT_PARAMS = frozenset({"mode", "hypothesis"}) + + +def param_names(op: str) -> list[str]: + """一个算子的参数名(不含模式和「在验证什么」),按参数模型里的先后。分析卡校验一项的参数时按它。""" + return [k for k in ARGS[op].model_fields if k not in NOT_PARAMS] + + +def request_of(op: str, args: BaseModel) -> contract.AnalysisRequest: + """校验过的参数 → 一次分析的请求(contract.AnalysisRequest):没填的参数不带。""" + from app.tools.analysis import hypothesis_of + + data = args.model_dump(exclude_none=True) + request: contract.AnalysisRequest = { + "op": op, "mode": data.get("mode") if op in contract.MODES else None, + "params": {k: v for k, v in data.items() if k not in NOT_PARAMS}, + } + if hypothesis := hypothesis_of(data.get("hypothesis")): + request["hypothesis"] = hypothesis + return request + + +async def run_tool(op: str, ctx: ToolContext, args: BaseModel) -> dict[str, Any]: + """一次 agent 分析:读本节点的查询容器、按设置快照取阈值、调算子、结论落成工件。交回 {analysis_artifact, result}: + 记台账、编号 An、给模型看的那一段由节点在记台账时做(engine/nodes/llm.py 的 _record_call),重放时照样做一遍。 + + 调用写错(含这个节点的分析次数用完)抛 contract.CallError,节点作为工具错误交回模型、不记台账。""" + import app.analysis as analysis_ops # 按模块取 run:测试、合并前的替身都只换模块上的这一个函数 + + from app.analysis.contract import LABELS + from app.tools.analysis import context_of, prepare, scope_of, settings_of, store + + scope = scope_of(ctx.extra) + if scope is None: + raise CallError("no_scope", NO_SCOPE) + if not scope.queries: + raise CallError("no_queries", NO_QUERIES) + settings = await settings_of(scope.snapshot) + if len(scope.results) >= settings["max_calls"]: + raise CallError("max_calls", MAX_CALLS.format(done=len(scope.results), label=LABELS["max_calls"], + limit=settings["max_calls"])) + request = request_of(op, args) + await prepare(scope) + context = await context_of(list(scope.queries.values()), tool_ctx=ctx, cache=scope.source_cache) + result = await analysis_ops.run(request, scope.resolve, settings=settings, context=context, parents=scope.parent) + artifact = await store(result, run_id=ctx.run_id, node_id=scope.node_id) + return {"analysis_artifact": artifact, "result": result} + + +@register( + name=contract.TOOL_NAMES["decompose"], + category=CATEGORY, + description="变化拆解:说清一个量的变化从哪来。读本节点查过的结果(【证据 Qn】的编号),按维度算各段对总变化的贡献" + "(additive)、把比率的变化拆成结构变化和各段自身的变化(ratio)、把乘积拆成各因子的贡献(factor);给了总数" + "就核对各段之和对不对得上,parent 接着上一次拆解的某一段往下钻。主要来自哪一段、是否分散由系统按平台的阈值判定。", + args_schema=DecomposeArgs, + guide=METHOD_GUIDE, +) +async def analysis_decompose(ctx: ToolContext, **kwargs: Any) -> dict[str, Any]: + return await run_tool("decompose", ctx, DecomposeArgs(**kwargs)) + + +@register( + name=contract.TOOL_NAMES["baseline"], + category=CATEGORY, + description="波动基线:把本期放回它自己的历史里看,判断这次的变化在不在常规波动范围内。要一份按期次排的序列" + "(期次列 + 度量列);历史不够长、本期还没过完时照实交回算不了的原因。", + args_schema=BaselineArgs, + guide=METHOD_GUIDE, +) +async def analysis_baseline(ctx: ToolContext, **kwargs: Any) -> dict[str, Any]: + return await run_tool("baseline", ctx, BaselineArgs(**kwargs)) + + +@register( + name=contract.TOOL_NAMES["trend"], + category=CATEGORY, + description="趋势:判断一条按期次排的序列是在持续上升、持续下降,还是没有明显趋势(只是一次波动)。", + args_schema=TrendArgs, + guide=METHOD_GUIDE, +) +async def analysis_trend(ctx: ToolContext, **kwargs: Any) -> dict[str, Any]: + return await run_tool("trend", ctx, TrendArgs(**kwargs)) + + +@register( + name=contract.TOOL_NAMES["differ"], + category=CATEGORY, + description="差异检验:判断两组(或同一组的两期)的差异站得住,还是可能只是样本波动。比率给分子、分母两列(rate)," + "均值给均值、标准差、样本数(mean),也可以给逐行的值(rows);样本小时照实标出。", + args_schema=DifferArgs, + guide=METHOD_GUIDE, +) +async def analysis_differ(ctx: ToolContext, **kwargs: Any) -> dict[str, Any]: + return await run_tool("differ", ctx, DifferArgs(**kwargs)) + + +@register( + name=contract.TOOL_NAMES["comparable"], + category=CATEGORY, + description="可比性检查:比两期之前先确认能不能直接比——两份查询是不是只差日期、窗口是否一样长、是不是整期、" + "星期构成是否相同。判定为可比、有条件可比或不可比,并列出原因。", + args_schema=ComparableArgs, + guide=METHOD_GUIDE, +) +async def analysis_comparable(ctx: ToolContext, **kwargs: Any) -> dict[str, Any]: + return await run_tool("comparable", ctx, ComparableArgs(**kwargs)) + + +__all__ = ["ANALYSIS_METHOD_PROMPT", "ARGS", "METHOD_GUIDE", "param_names", "request_of", "run_tool"] diff --git a/backend/app/tools/datasource.py b/backend/app/tools/datasource.py index c54a6823..871ef28d 100644 --- a/backend/app/tools/datasource.py +++ b/backend/app/tools/datasource.py @@ -374,6 +374,26 @@ def _pinned_under_name(pins: dict[str, Any], name: str) -> bool: return any(isinstance(entry, dict) and entry.get("name") == name for entry in pins.values()) +async def resolved_source(name: str, ctx: ToolContext) -> tuple[DataSource, Any] | None: + """按名字找启用着的数据源,按这次运行固定的版本解析成查询用的那个源(_resolve,同 build_datasource_tools):(数据源的行, + 查询用的源)。找不到、固定的版本没了返回 None。 + + 分析(app/tools/analysis.py)读目录、剖析事实、现查表的日期范围时用:查的要和这次运行的查询是同一版数据。用自己的短 + 会话,不提交(行上已经读出来的属性会话关了照样能读)。""" + from app.db.base import SessionLocal + + async with SessionLocal() as session: + row = (await session.execute(select(DataSource).where( + DataSource.name == name, DataSource.enabled.is_(True)))).scalar_one_or_none() + if row is None: + return None + try: + source, _ = await _resolve(session, row, ctx) + except table_versions.SnapshotMissing: + return None + return row, source + + async def _resolve(session: AsyncSession, row: DataSource, ctx: ToolContext) -> tuple[Any, bool]: """查询该用的源,以及它的版本是不是这次运行固定的。 diff --git a/backend/app/tools/registry.py b/backend/app/tools/registry.py index bf98754c..82d048c6 100644 --- a/backend/app/tools/registry.py +++ b/backend/app/tools/registry.py @@ -55,6 +55,17 @@ class ToolContext: extra: dict[str, Any] = field(default_factory=dict) +@dataclass(frozen=True) +class ToolGuide: + """绑了这个工具时一并交给模型的方法说明(写进 system,engine/nodes/llm.py 的 _build_messages、协作成员同样)。 + + 几个工具共用一份说明时写同一个 id:同一份只注入一次(guides_of 按 id 去重)。说明随代码升版,不是 Skill——Skill 是库里 + 用户可改、可停用的行,种子只在同名不存在时插入(seed.py),平台的方法要跟着代码走(分析方法,D12)。""" + + id: str + text: str + + @dataclass class ToolSpec: name: str @@ -72,6 +83,8 @@ class ToolSpec: # 能力登记里的效果,写了就照它登记(平台内置的写入能力记 write:只能放在调用工具节点里,经动作流程执行, # capabilities/platform.py)。不写的按 dangerous / writes_state 推 effect: str | None = None + # 绑了它时一并交给模型的方法说明(ToolGuide)。建工具时放进工具的 metadata["guide"],节点按 guides_of 去重注入 + guide: ToolGuide | None = None def json_schema(self) -> dict[str, Any]: return self.args_schema.model_json_schema() @@ -90,6 +103,7 @@ def register( needs_context: bool = True, writes_state: bool = False, effect: str | None = None, + guide: ToolGuide | None = None, ) -> Callable[[ToolFunc], ToolFunc]: def deco(func: ToolFunc) -> ToolFunc: _REGISTRY[name] = ToolSpec( @@ -102,6 +116,7 @@ def register( needs_context=needs_context, writes_state=writes_state, effect=effect, + guide=guide, ) return func @@ -159,9 +174,26 @@ def build_tool(spec: ToolSpec, ctx: ToolContext) -> BaseTool: coroutine=_run, # 只给异步实现;同步路径直接报错,避免在事件循环里被阻塞调用 func=None, + # 方法说明跟着工具走:节点按绑定的工具取(guides_of),没有说明的工具 metadata 和以前一样是空的 + **({"metadata": {"guide": spec.guide}} if spec.guide is not None else {}), ) +def guides_of(tools: Any) -> list[ToolGuide]: + """这批工具带的方法说明,按 id 去重、按第一次出现的先后。没有说明的工具不算。""" + out: dict[str, ToolGuide] = {} + for tool in tools or []: + guide = ((getattr(tool, "metadata", None) or {}) if tool is not None else {}).get("guide") + if isinstance(guide, ToolGuide) and guide.id not in out: + out[guide.id] = guide + return list(out.values()) + + +def guides_text(tools: Any) -> str: + """注入 system 的那一段:每份说明一段,空行隔开。没有说明为空串。""" + return "\n\n".join(g.text.strip() for g in guides_of(tools) if g.text.strip()) + + async def build_tools( names: list[str], ctx: ToolContext, *, session: Any = None, allow_write: bool = False ) -> list[BaseTool]: diff --git a/backend/tests/fixtures/analysis_ops.py b/backend/tests/fixtures/analysis_ops.py new file mode 100644 index 00000000..c9371b10 --- /dev/null +++ b/backend/tests/fixtures/analysis_ops.py @@ -0,0 +1,61 @@ +"""测试用的分析算子替身(第 1 波各包并行:W1-a 的 app.analysis.run 落地前,入口的测试不等它)。 + +FakeOps 换掉模块上的 app.analysis.run(入口都按模块取 run),按请求交回 app.analysis.testing.fake_result 的假结论, +并记下每次调用拿到的请求、按名字解析到的输入快照、阈值、运行期依赖、上一层结论——测试据此断言入口交进去的东西。 +""" +from __future__ import annotations + +from typing import Any + +import app.analysis as analysis_ops +from app.analysis.testing import fake_result + +#: 每个算子缺省给的判定(fake_result 有现成判定语的那几个) +VERDICT = {"decompose": "driver", "baseline": "outside_band", "trend": "rising", "differ": "no_evidence", + "comparable": "comparable"} + + +class FakeOps: + """errors:按调用的先后,每次要抛的异常(None 表示照常交回);用完了都照常交回。statuses / verdicts 同理。""" + + def __init__(self, monkeypatch, *, errors: list[Any] | None = None, statuses: list[str] | None = None, + verdicts: list[str] | None = None, peek: list[str] | None = None, **overrides: Any) -> None: + self.calls: list[dict[str, Any]] = [] + #: 每次调用时看一眼这几个名字能不能按 parents 取到上一层结论(visible) + self.peek = list(peek or []) + self.errors = list(errors or []) + self.statuses = list(statuses or []) + self.verdicts = list(verdicts or []) + self.overrides = overrides + monkeypatch.setattr(analysis_ops, "run", self.run) + + async def run(self, request: dict[str, Any], resolve: Any, *, settings: Any, context: Any, + parents: Any = None) -> dict[str, Any]: + params = request.get("params") or {} + names = [params[k] for k in ("query", "base_query", "target_query") if params.get(k)] + snapshots = {name: resolve(name) for name in names} + parent = None + if isinstance(params.get("parent"), dict) and parents is not None: + parent = parents(params["parent"]["of"]) + self.calls.append({"request": request, "snapshots": snapshots, "settings": dict(settings), "context": context, + "parent": parent, "parents": parents, + "visible": {n: parents is not None and parents(n) is not None for n in self.peek}}) + error = self.errors.pop(0) if self.errors else None + if error is not None: + raise error + status = self.statuses.pop(0) if self.statuses else "ok" + verdict = self.verdicts.pop(0) if self.verdicts else VERDICT[request["op"]] + roles = {"query": "data", "base_query": "base", "target_query": "target"} + inputs = [{"ref": name, "artifact": snap["artifact"], "role": roles[key], "columns": snap["columns"], + "rows": "all"} + for key in ("query", "base_query", "target_query") + if (name := params.get(key)) and (snap := snapshots.get(name)) is not None] + if parent is not None: + from app.core.artifact_store import canonical_json, content_hash + + inputs.append({"ref": params["parent"]["of"], "artifact": content_hash(canonical_json(parent)), + "role": "parent"}) + return fake_result(op=request["op"], mode=request.get("mode"), verdict=verdict, status=status, + reason_code="history_short" if status == "refused" else None, inputs=inputs, + params=dict(params), settings=dict(settings), hypothesis=request.get("hypothesis"), + **self.overrides) diff --git a/backend/tests/test_analysis_tools.py b/backend/tests/test_analysis_tools.py new file mode 100644 index 00000000..88a96815 --- /dev/null +++ b/backend/tests/test_analysis_tools.py @@ -0,0 +1,439 @@ +"""agent 分析工具(docs/design/analysis-round/00-总方案.md §3.2):参数、方法说明、查询容器、台账、编号、下钻、次数上限、 +遮罩列、续跑。 + +算子(app.analysis.run)还没落地:换成按请求交回假结论的替身(tests/fixtures/analysis_ops.FakeOps),断言入口交给算子的 +东西、算子交回后入口做的事。模型换成脚本(MockChatModel._decide),不调用任何模型。库是虚构的零售库 shop。 +""" +from __future__ import annotations + +import asyncio +import json +import sqlite3 + +import pytest +from langchain_core.messages import AIMessage, SystemMessage, ToolMessage +from sqlalchemy import select + +from app.analysis import contract +from app.analysis.contract import CallError +from app.core import artifact_store +from app.core.artifact_store import canonical_json, content_hash +from app.data.engine import engines +from app.db.base import SessionLocal +from app.db.models import Artifact, DataSource, Run, RunEvent +from app.engine.runner import run_manager +from app.tools.analysis import CALL_ERROR_HEAD, REFUSED_CODE +from app.tools.builtin.analysis import ANALYSIS_METHOD_PROMPT, ARGS, MAX_CALLS, METHOD_GUIDE, NO_SCOPE +from tests.fixtures.analysis_ops import FakeOps + +TOOL = "db_query__shop" +DECOMPOSE = contract.TOOL_NAMES["decompose"] +BASELINE = contract.TOOL_NAMES["baseline"] +SQL = "SELECT region, week, SUM(amount) AS gmv FROM orders GROUP BY region, week ORDER BY region, week" +MASKED_SQL = "SELECT region, phone AS tel, SUM(amount) AS gmv FROM orders GROUP BY region, phone ORDER BY region" +ROWS = [(1, "华东", "2026-W41", 820.0, "139-0000-0001"), (2, "华东", "2026-W42", 700.0, "139-0000-0001"), + (3, "华南", "2026-W41", 700.0, "139-0000-0002"), (4, "华南", "2026-W42", 666.0, "139-0000-0002"), + (5, "华北", "2026-W41", 600.0, "139-0000-0003"), (6, "华北", "2026-W42", 560.0, "139-0000-0003")] +DECOMPOSE_ARGS = {"mode": "additive", "query": "Q1", "by": ["region"], "period": "week", "base": "2026-W41", + "target": "2026-W42", "measure": "gmv", "label": "销售额", "hypothesis": "下降是不是集中在某个地区"} + + +@pytest.fixture(autouse=True) +async def engine_up(): + await run_manager.setup() + yield + await run_manager.shutdown() + + +async def _shop(tmp_path, options=None): + path = tmp_path / "shop.db" + db = sqlite3.connect(path) + db.executescript("CREATE TABLE orders (id INTEGER PRIMARY KEY, region TEXT, week TEXT, amount REAL, phone TEXT);") + db.executemany("INSERT INTO orders VALUES (?, ?, ?, ?, ?)", ROWS) + 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, options or {} + await session.commit() + source_id = row.id + await engines.invalidate(source_id) + return source_id + + +@pytest.fixture +async def shop(tmp_path): + source_id = await _shop(tmp_path) + yield + await engines.invalidate(source_id) + + +class Script: + """Agent(和协作成员)依次发 calls 里的工具调用,再给结论;seen 记下每次带工具的调用看到的消息。""" + + def __init__(self, monkeypatch, calls, *, final="销售额下降,主要来自华东。", route=None): + from app.providers import mock_model + + self.seen: list[list] = [] + self.plain: list[list] = [] + routed = {"n": 0} + + def decide(model, messages): + if model.response_format: + routed["n"] += 1 + if route is not None and routed["n"] == 1: + return AIMessage(content=json.dumps(route, ensure_ascii=False)) + return AIMessage(content=json.dumps({"assignments": [], "done": True, "reason": "完成"})) + if not model.tools: + self.plain.append(list(messages)) + return AIMessage(content="收尾") + self.seen.append(list(messages)) + 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=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 agent_graph(tools=(TOOL, DECOMPOSE), approval="never", **cfg): + nodes = [node("start", "input"), + node("bot", "agent", prompt="销售额为什么下降", tools=list(tools), approval=approval, **cfg), + node("out", "output", fields=[{"name": "answer", "value": "{{ nodes.bot.text }}"}])] + return {"nodes": nodes, "edges": [{"source": a["id"], "target": b["id"]} for a, b in zip(nodes, nodes[1:])]} + + +async def wait(run_id, statuses=("succeeded", "failed")): + 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, etype=None, node_id=None): + 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 [e.data for e in (await session.execute(q.order_by(RunEvent.seq))).scalars()] + + +async def run_graph(graph, **kw): + run = await run_manager.start(graph=graph, input_payload={}, **kw) + row = await wait(run.id) + assert row.status == "succeeded", row.error + return row + + +def text(message): + return message.content if isinstance(message.content, str) else json.dumps(message.content, ensure_ascii=False) + + +def tool_messages(script): + return [text(m) for m in script.seen[-1] if isinstance(m, ToolMessage)] + + +async def ledger(run_id, node_id="bot"): + [finished] = await events(run_id, "node.finished", node_id) + return finished["evidence"] + + +# -------------------------------------------------------------------------- +# 工具定义:参数只有「算什么」,五个工具共用一份方法说明 +# -------------------------------------------------------------------------- + + +def test_the_five_tools_take_no_thresholds_and_share_one_method(): + from app.capabilities.registry import is_platform_query + from app.tools.registry import all_specs + + specs = all_specs() + for op, name in contract.TOOL_NAMES.items(): + spec = specs[name] + props = spec.args_schema.model_json_schema()["properties"] + assert not set(props) & set(contract.DEFAULTS), f"{name} 的参数里有阈值:{set(props) & set(contract.DEFAULTS)}" + assert spec.args_schema is ARGS[op] and spec.category == "分析" and spec.description + assert spec.guide is METHOD_GUIDE and not spec.dangerous and not spec.writes_state + # 不查库:能力登记、出处链都不把它的结果当查询快照 + assert not is_platform_query(name) + assert "query" in ARGS["decompose"].model_json_schema()["properties"] + assert {"mode", "by", "period", "base", "target", "measure", "numerator", "denominator", "product", "factors", + "headline", "parent", "label", "hypothesis"} <= set(ARGS["decompose"].model_fields) + assert {"basis", "grain", "lag"} <= set(ARGS["baseline"].model_fields) + assert {"reference", "pairs", "mode"} <= set(ARGS["differ"].model_fields) + assert {"base_query", "target_query"} <= set(ARGS["comparable"].model_fields) + + +def test_unknown_parameters_and_thresholds_are_rejected_like_any_tool(): + from app.tools.registry import ToolArgsError, prepare_args + + with pytest.raises(ToolArgsError) as found: + prepare_args(ARGS["decompose"], {**DECOMPOSE_ARGS, "main_share": 0.3}) + assert "main_share 不是该工具的参数" in str(found.value) + with pytest.raises(ToolArgsError): + prepare_args(ARGS["decompose"], {**DECOMPOSE_ARGS, "mode": "average"}) + + +def test_chat_users_may_bind_the_analysis_tools(): + from app.auth.chat_policy import CHAT_BUILTIN_TOOLS, CHAT_NODE_TYPES, chat_graph_problem + + assert set(contract.TOOL_NAMES.values()) <= CHAT_BUILTIN_TOOLS and contract.NODE_TYPE in CHAT_NODE_TYPES + assert chat_graph_problem(agent_graph()) is None + + +def test_a_tool_node_cannot_call_an_analysis_tool(): + from app.engine.schema import GraphSpec, validate_graph + + graph = {"nodes": [node("start", "input"), node("t", "tool", tool=DECOMPOSE, args=DECOMPOSE_ARGS)], + "edges": [{"source": "start", "target": "t"}]} + [issue] = [i for i in validate_graph(GraphSpec.model_validate(graph)).issues if i.code == "analysis.tool_node"] + assert issue.level == "error" and issue.field == "tool" and "分析卡" in issue.message + + +async def test_calling_it_outside_an_agent_says_where_it_belongs(): + from app.tools.registry import ToolContext, Versions, call_tool + + with pytest.raises(CallError) as found: + await call_tool(DECOMPOSE, DECOMPOSE_ARGS, ToolContext(data_versions=Versions.CURRENT)) + assert found.value.message == NO_SCOPE + + +# -------------------------------------------------------------------------- +# 方法说明:绑了分析工具的 Agent 注入一次,模型调用节点、没绑的 Agent 不注入 +# -------------------------------------------------------------------------- + + +async def test_the_method_is_injected_once_into_the_agent_system(monkeypatch, shop): + FakeOps(monkeypatch) + script = Script(monkeypatch, [(TOOL, {"sql": SQL})]) + await run_graph(agent_graph(tools=(TOOL, DECOMPOSE, BASELINE), system="你是零售分析师。")) + [system] = [m for m in script.seen[0] if isinstance(m, SystemMessage)] + assert system.content.startswith("你是零售分析师。") and system.content.count(ANALYSIS_METHOD_PROMPT) == 1 + + script = Script(monkeypatch, [(TOOL, {"sql": SQL})]) + await run_graph(agent_graph(tools=(TOOL,), system="你是零售分析师。")) + [system] = [m for m in script.seen[0] if isinstance(m, SystemMessage)] + assert system.content == "你是零售分析师。", "没绑分析工具的 Agent,system 和以前一字不差" + + +async def test_model_call_nodes_get_no_method(monkeypatch): + from app.providers import mock_model + + seen: list = [] + + def decide(model, messages): + seen.append(list(messages)) + return AIMessage(content="好") + + monkeypatch.setattr(mock_model.MockChatModel, "_decide", decide) + nodes = [node("start", "input"), node("llm", "llm", system="你是零售分析师。", prompt="说一句"), + node("out", "output", fields=[{"name": "a", "value": "{{ nodes.llm.text }}"}])] + await run_graph({"nodes": nodes, "edges": [{"source": "start", "target": "llm"}, {"source": "llm", "target": "out"}]}) + assert ANALYSIS_METHOD_PROMPT not in json.dumps([text(m) for m in seen[0]], ensure_ascii=False) + + +# -------------------------------------------------------------------------- +# 一次分析:查询容器、台账、工件、编号、交回模型的那一段 +# -------------------------------------------------------------------------- + + +async def test_an_analysis_reads_this_nodes_query_and_is_recorded(monkeypatch, shop): + ops = FakeOps(monkeypatch) + script = Script(monkeypatch, [(TOOL, {"sql": SQL}), (DECOMPOSE, DECOMPOSE_ARGS)]) + row = await run_graph(agent_graph()) + + # 算子拿到的:请求只有「算什么」,Q1 解析成本节点那次查询的快照 + [call] = ops.calls + assert call["request"] == {"op": "decompose", "mode": "additive", "hypothesis": "下降是不是集中在某个地区", + "params": {k: v for k, v in DECOMPOSE_ARGS.items() if k not in ("mode", "hypothesis")}} + ends = await events(row.id, "tool.end", "bot") + query_artifact = ends[0]["query_artifact"] + snap = call["snapshots"]["Q1"] + assert snap["ref"] == "Q1" and snap["artifact"] == query_artifact and snap["node_id"] == "bot" + assert snap["source"] == "shop" and snap["sql"] == SQL and snap["truncated"] is False + assert snap["columns"] == ["region", "week", "gmv"] and snap["rows"][0] == ["华东", "2026-W41", 820.0] + assert snap["column_types"] == ["text", "text", "number"] and snap["masked"] == [] + # 阈值:没有发起时的快照,现读设置(没存过就是缺省) + assert call["settings"] == contract.DEFAULTS + assert set(call["context"]) == {"today", "probe", "catalogs", "facts"} + assert call["context"]["probe"] is None, "还没有 W1-a 的 make_prober 时不给探查" + + # 结论落成工件,tool.end 带着它;台账记一条 kind analysis(带 call_id),在那次查询之后 + analysis_artifact = ends[1]["analysis_artifact"] + stored = artifact_store.load(analysis_artifact) + assert stored["op"] == "decompose" and stored["verdict"]["code"] == "driver" + assert stored["hypothesis"] == "下降是不是集中在某个地区" + entries = await ledger(row.id) + assert [e["kind"] for e in entries] == ["tool", "query", "tool", "analysis"] + analysis = entries[-1] + assert analysis == contract.ledger_entry(stored, node_id="bot", exec_no=1, artifact=analysis_artifact, + call_id="call_1") + assert analysis["inputs"] == [query_artifact] + async with SessionLocal() as session: + kinds = {r.kind for r in (await session.execute( + select(Artifact).where(Artifact.id == analysis_artifact))).scalars()} + assert kinds == {contract.ARTIFACT_KIND} + + # 交回模型的:查询是「【证据 Q1】」,分析是「【分析 A1】」加判定语、关键数、结果表的前几段 + first, second = tool_messages(script) + assert first.startswith("【证据 Q1】\n") + head, body = second.split("\n", 1) + assert head == "【分析 A1】" + reply = json.loads(body) + assert reply["verdict"] == "driver" and reply["subjects"] == [["华东"]] and reply["status"] == "ok" + assert reply["phrase"] == stored["phrase"] and reply["values"]["top_share"] == "0.6186" + assert reply["table"]["rows"][0][0] == "华东" and "criterion" in reply + # 调用记录里记着编号和结论工件 + output = artifact_store.load((await events(row.id, "node.finished", "bot"))[0]["artifact"]) + assert [c.get("alias") for c in output["tool_calls"]] == ["Q1", "A1"] + assert output["tool_calls"][1]["analysis_artifact"] == analysis_artifact + + +async def test_a_refused_analysis_is_still_a_recorded_conclusion(monkeypatch, shop): + FakeOps(monkeypatch, statuses=["refused"]) + script = Script(monkeypatch, [(TOOL, {"sql": SQL}), (BASELINE, {"query": "Q1", "period": "week", + "measure": "gmv"})]) + row = await run_graph(agent_graph(tools=(TOOL, BASELINE))) + entries = await ledger(row.id) + assert entries[-1]["kind"] == "analysis" and entries[-1]["status"] == "refused" and entries[-1]["verdict"] is None + reply = json.loads(tool_messages(script)[1].split("\n", 1)[1]) + assert reply["status"] == "refused" and reply["reason_code"] == "history_short" and "8 期" in reply["reason"] + [warn] = [e for e in await events(row.id, "log", "bot") if e.get("code") == REFUSED_CODE] + assert warn["level"] == "warn" and warn["reason_code"] == "history_short" + + +async def test_call_errors_go_back_to_the_model_and_are_not_recorded(monkeypatch, shop): + ops = FakeOps(monkeypatch, errors=[CallError("masked_column", "列「tel」设置了遮罩,不能当维度"), None]) + script = Script(monkeypatch, [(TOOL, {"sql": SQL}), (DECOMPOSE, DECOMPOSE_ARGS), + (DECOMPOSE, {**DECOMPOSE_ARGS, "by": ["week"]})]) + row = await run_graph(agent_graph()) + assert len(ops.calls) == 2 + _, refused, done = tool_messages(script) + assert refused == f"{CALL_ERROR_HEAD}列「tel」设置了遮罩,不能当维度" + assert done.startswith("【分析 A1】"), "写错的那次不占编号" + [error] = [e for e in await events(row.id, "tool.error", "bot")] + assert error["call_error"] == "masked_column" and error["error"] == refused + entries = await ledger(row.id) + assert [e["call_id"] for e in entries if e["kind"] == "analysis"] == ["call_2"] + + +async def test_drill_down_reads_this_nodes_earlier_conclusion(monkeypatch, shop): + ops = FakeOps(monkeypatch) + drill = {**DECOMPOSE_ARGS, "by": ["week"], "parent": {"of": "A1", "key": ["华东"]}, "hypothesis": "华东内部"} + script = Script(monkeypatch, [(TOOL, {"sql": SQL}), (DECOMPOSE, DECOMPOSE_ARGS), (DECOMPOSE, drill)]) + row = await run_graph(agent_graph()) + first, second = ops.calls + # parent 按本节点的编号取回上一层那份结论:就是存下的那一份(按内容寻址,哈希对得上) + stored = artifact_store.load((await events(row.id, "tool.end", "bot"))[1]["analysis_artifact"]) + assert second["parent"] == stored + assert content_hash(canonical_json(second["parent"])) == (await events(row.id, "tool.end", "bot"))[1][ + "analysis_artifact"] + assert second["parents"]("A9") is None + assert tool_messages(script)[2].startswith("【分析 A2】") + + +async def test_the_number_of_analyses_per_node_is_capped(monkeypatch, shop): + ops = FakeOps(monkeypatch) + # 第二次换一个维度(同样的参数会被护栏当成重复调用、直接交回上次的结果,不走到分析) + script = Script(monkeypatch, [(TOOL, {"sql": SQL}), (DECOMPOSE, DECOMPOSE_ARGS), + (DECOMPOSE, {**DECOMPOSE_ARGS, "by": ["week"]})]) + await run_graph(agent_graph(), options={contract.SETTINGS_KEY: {"max_calls": 1}}) + assert len(ops.calls) == 1 + capped = tool_messages(script)[2] + assert capped == CALL_ERROR_HEAD + MAX_CALLS.format(done=1, label=contract.LABELS["max_calls"], limit=1) + # 阈值来自发起时的快照 + assert ops.calls[0]["settings"]["max_calls"] == 1 + + +async def test_masked_columns_reach_the_operator(monkeypatch, tmp_path): + source_id = await _shop(tmp_path, {"mask_columns": ["phone"]}) + try: + ops = FakeOps(monkeypatch) + Script(monkeypatch, [(TOOL, {"sql": MASKED_SQL}), (DECOMPOSE, {**DECOMPOSE_ARGS, "by": ["tel"]})]) + await run_graph(agent_graph()) + masked = ops.calls[0]["snapshots"]["Q1"]["masked"] + # 按 SQL 认出改了名字的遮罩列(phone AS tel)排在前面,数据源设的列名也在 + assert masked[0] == "tel" and "phone" in masked + finally: + await engines.invalidate(source_id) + + +async def test_mask_results_runs_hand_over_the_masked_snapshot(monkeypatch, tmp_path): + source_id = await _shop(tmp_path, {"mask_columns": ["phone"]}) + try: + ops = FakeOps(monkeypatch) + Script(monkeypatch, [(TOOL, {"sql": MASKED_SQL}), (DECOMPOSE, {**DECOMPOSE_ARGS, "by": ["region"]})]) + await run_graph(agent_graph(), options={"mask_results": True, "read_only_data": True}) + snap = ops.calls[0]["snapshots"]["Q1"] + assert "tel" in snap["masked"] + tel = snap["columns"].index("tel") + assert {r[tel] for r in snap["rows"]} == {"已遮罩"}, "遮罩运行里算子拿到的是遮过的快照" + finally: + await engines.invalidate(source_id) + + +# -------------------------------------------------------------------------- +# 续跑:审批恢复时节点整个重放,分析不再执行、编号不错位 +# -------------------------------------------------------------------------- + + +async def test_replay_after_approvals_does_not_repeat_or_renumber(monkeypatch, shop): + ops = FakeOps(monkeypatch) + script = Script(monkeypatch, [(TOOL, {"sql": SQL}), (DECOMPOSE, DECOMPOSE_ARGS), + (DECOMPOSE, {**DECOMPOSE_ARGS, "parent": {"of": "A1", "key": ["华东"]}})]) + run = await run_manager.start(graph=agent_graph(approval="always"), input_payload={}) + for _ in range(3): + 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(ops.calls) == 2, "分析在重放时又执行了一次" + ends = await events(row.id, "tool.end", "bot") + assert len(ends) == 3 + # 重放之后模型看到的编号还是同一套,下钻取到的是同一份结论 + assert [m.split("\n", 1)[0] for m in tool_messages(script)] == ["【证据 Q1】", "【分析 A1】", "【分析 A2】"] + assert ops.calls[1]["parent"] == artifact_store.load(ends[1]["analysis_artifact"]) + entries = [e for e in await ledger(row.id) if e["kind"] == "analysis"] + assert [(e["call_id"], e["artifact"]) for e in entries] == [("call_1", ends[1]["analysis_artifact"]), + ("call_2", ends[2]["analysis_artifact"])] + + +# -------------------------------------------------------------------------- +# 协作成员:同样注入方法说明、同样编号(协作节点不记证据台账) +# -------------------------------------------------------------------------- + + +async def test_a_team_member_gets_the_method_and_numbers(monkeypatch, shop): + ops = FakeOps(monkeypatch) + script = 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 + [system] = [m for m in script.seen[0] if isinstance(m, SystemMessage)] + assert system.content.startswith("你负责拆解") and system.content.count(ANALYSIS_METHOD_PROMPT) == 1 + assert [m.split("\n", 1)[0] for m in tool_messages(script)] == ["【证据 Q1】", "【分析 A1】"] + assert ops.calls[0]["snapshots"]["Q1"]["node_id"] == "team" + [end] = [e for e in await events(row.id, "tool.end", "team") if e.get("analysis_artifact")] + assert artifact_store.load(end["analysis_artifact"])["op"] == "decompose" diff --git a/backend/tests/test_catalog_view.py b/backend/tests/test_catalog_view.py index 3edd71d2..75760f88 100644 --- a/backend/tests/test_catalog_view.py +++ b/backend/tests/test_catalog_view.py @@ -258,7 +258,7 @@ def test_of_run_reads_the_run_options(): #: 给模型、给自查读目录的模块(四处消费 + 它们的帮手)。要读目录一律经 data/catalog_view.py CONSUMERS = ("tools/datasource.py", "look/tables.py", "data/sqlcheck.py", "engine/nodes/dual_check.py", - "engine/verify_agent.py", "api/copilot_context.py") + "engine/verify_agent.py", "api/copilot_context.py", "tools/analysis.py") FORBIDDEN = re.compile(r"\bread_catalogs?\s*\(|\bread_entry\s*\(|\bCatalogNote\b") From 3009a43cee3041e1135c19c27208468b00b02423 Mon Sep 17 00:00:00 2001 From: ThearchyHelios Date: Wed, 21 Oct 2026 13:03:00 +0800 Subject: [PATCH 3/7] =?UTF-8?q?=E5=88=86=E6=9E=90=E8=83=BD=E5=8A=9B?= =?UTF-8?q?=E5=85=A5=E5=8F=A3=EF=BC=88=E4=B8=89=EF=BC=89=EF=BC=9A=E5=88=86?= =?UTF-8?q?=E6=9E=90=E5=8D=A1=EF=BC=88=E8=8A=82=E7=82=B9=E7=B1=BB=E5=9E=8B?= =?UTF-8?q?=20analysis=EF=BC=8C=E5=92=8C=E5=8F=A3=E5=BE=84=E5=8D=A1?= =?UTF-8?q?=E5=B9=B3=E7=BA=A7=EF=BC=8Cengine/nodes/analysis.py=EF=BC=89?= =?UTF-8?q?=E3=80=82=E9=85=8D=E7=BD=AE=20items:=20[{id,=20op,=20mode,=20fr?= =?UTF-8?q?om,=20params,=20hypothesis}]=20=E5=92=8C=E9=98=88=E5=80=BC?= =?UTF-8?q?=E8=A6=86=E7=9B=96=20overrides=EF=BC=9B=E7=94=BB=E5=9B=BE?= =?UTF-8?q?=E6=97=B6=E6=A0=A1=E9=AA=8C=EF=BC=88schema.analysis=5Fissues?= =?UTF-8?q?=EF=BC=8C=E6=89=A7=E8=A1=8C=E5=89=8D=E5=90=8C=E4=B8=80=E5=A5=97?= =?UTF-8?q?=E5=86=8D=E8=BF=87=E4=B8=80=E9=81=8D=EF=BC=89=EF=BC=9A=E8=87=B3?= =?UTF-8?q?=E5=B0=91=E4=B8=80=E9=A1=B9=E3=80=81=E6=A0=87=E8=AF=86=E5=90=88?= =?UTF-8?q?=E6=B3=95=E4=B8=94=E4=B8=8D=E9=87=8D=E5=A4=8D=E3=80=81=E5=88=86?= =?UTF-8?q?=E6=9E=90=E6=96=B9=E6=B3=95=E5=92=8C=E6=A8=A1=E5=BC=8F=E6=98=AF?= =?UTF-8?q?=E5=A5=91=E7=BA=A6=E9=87=8C=E7=9A=84=E3=80=81=E8=BE=93=E5=85=A5?= =?UTF-8?q?=EF=BC=88from=EF=BC=8C=E6=88=96=E4=B8=A4=E4=BB=BD=E6=9F=A5?= =?UTF-8?q?=E8=AF=A2=E7=9A=84=20base=5Fquery=20/=20target=5Fquery=EF=BC=8C?= =?UTF-8?q?=E6=80=BB=E6=95=B0=E4=B8=A4=E6=A0=BC=E8=90=BD=E5=9C=A8=E7=9A=84?= =?UTF-8?q?=E8=8A=82=E7=82=B9=EF=BC=89=E6=8C=87=E5=90=91=E4=B8=8A=E6=B8=B8?= =?UTF-8?q?=E7=9A=84=E8=B0=83=E7=94=A8=E5=B7=A5=E5=85=B7=20/=20=E5=90=88?= =?UTF-8?q?=E5=B9=B6=E6=9F=A5=E8=AF=A2=E8=8A=82=E7=82=B9=EF=BC=88Agent?= =?UTF-8?q?=E3=80=81=E4=B8=8D=E4=BA=A7=E5=87=BA=E6=9F=A5=E8=AF=A2=E7=9A=84?= =?UTF-8?q?=E8=8A=82=E7=82=B9=E3=80=81=E4=B8=8D=E5=9C=A8=E4=B8=8A=E6=B8=B8?= =?UTF-8?q?=E7=9A=84=E6=8A=A5=E9=94=99=EF=BC=8C=E8=B0=83=E7=94=A8=E5=88=AB?= =?UTF-8?q?=E7=9A=84=E5=B7=A5=E5=85=B7=E7=9A=84=E5=8F=AA=E6=8F=90=E9=86=92?= =?UTF-8?q?=EF=BC=89=E3=80=81=E5=8F=82=E6=95=B0=E5=92=8C=20agent=20?= =?UTF-8?q?=E5=B7=A5=E5=85=B7=E5=90=8C=E4=B8=80=E4=BB=BD=E5=AE=9A=E4=B9=89?= =?UTF-8?q?=E4=B8=94=E4=B8=8D=E8=83=BD=E5=86=99=E9=98=88=E5=80=BC=E3=80=81?= =?UTF-8?q?=E4=B8=8B=E9=92=BB=E6=8C=87=E5=90=91=E5=90=8C=E5=8D=A1=E6=8E=92?= =?UTF-8?q?=E5=9C=A8=E5=89=8D=E9=9D=A2=E7=9A=84=E5=8F=98=E5=8C=96=E6=8B=86?= =?UTF-8?q?=E8=A7=A3=E3=80=81=E9=98=88=E5=80=BC=E8=A6=86=E7=9B=96=E6=8C=89?= =?UTF-8?q?=20contract.settings=5Fproblem=E3=80=82=E6=89=A7=E8=A1=8C?= =?UTF-8?q?=E7=85=A7=E5=90=88=E5=B9=B6=E6=9F=A5=E8=AF=A2=E7=9A=84=E5=81=9A?= =?UTF-8?q?=E6=B3=95=E4=BB=8E=20state.nodes=20=E5=8F=96=E4=B8=8A=E6=B8=B8?= =?UTF-8?q?=E7=9A=84=E6=9F=A5=E8=AF=A2=E5=BF=AB=E7=85=A7=20id=E3=80=81?= =?UTF-8?q?=E5=A4=8D=E9=AA=8C=E5=93=88=E5=B8=8C=EF=BC=8C=E5=8F=AA=E8=AE=A4?= =?UTF-8?q?=E5=8F=B0=E8=B4=A6=E9=87=8C=20kind=20query=20=E7=9A=84=E5=BF=AB?= =?UTF-8?q?=E7=85=A7=EF=BC=8C=E9=81=AE=E7=BD=A9=E5=88=97=E5=90=8C=E8=AF=81?= =?UTF-8?q?=E6=8D=AE=E9=9D=A2=E6=9D=BF=EF=BC=9B=E5=8F=82=E6=95=B0=E9=87=8C?= =?UTF-8?q?=E7=9A=84=20{{=20}}=20=E5=85=88=E6=B8=B2=E6=9F=93=EF=BC=9B?= =?UTF-8?q?=E9=98=88=E5=80=BC=E6=98=AF=E7=BC=BA=E7=9C=81=20=E2=86=90=20?= =?UTF-8?q?=E5=8F=91=E8=B5=B7=E6=97=B6=E7=9A=84=E5=BF=AB=E7=85=A7=20?= =?UTF-8?q?=E2=86=90=20=E5=8D=A1=E4=B8=8A=E7=9A=84=E8=A6=86=E7=9B=96?= =?UTF-8?q?=EF=BC=9B=E6=AF=8F=E9=A1=B9=E7=9A=84=E7=BB=93=E8=AE=BA=E8=90=BD?= =?UTF-8?q?=E6=88=90=E5=B7=A5=E4=BB=B6=E3=80=81=E5=8F=B0=E8=B4=A6=E8=AE=B0?= =?UTF-8?q?=E4=B8=80=E6=9D=A1=E5=B8=A6=20item=20=E7=9A=84=20kind=20analysi?= =?UTF-8?q?s=E3=80=81=E5=8F=91=E4=B8=80=E6=9D=A1=20analysis.end=EF=BC=9B?= =?UTF-8?q?=E4=BA=A7=E5=87=BA=20nodes.<=E5=8D=A1>.results.<=E9=A1=B9>=20?= =?UTF-8?q?=3D=20{status,=20verdict,=20direction,=20values,=20phrase,=20ar?= =?UTF-8?q?tifact}=EF=BC=8C=E6=9D=A1=E4=BB=B6=E5=88=86=E6=94=AF=E8=83=BD?= =?UTF-8?q?=E6=8C=89=E5=88=A4=E5=AE=9A=E8=B5=B0=E3=80=81=E6=88=90=E6=9E=9C?= =?UTF-8?q?=E8=83=BD=E5=8F=96=E5=88=A4=E5=AE=9A=E8=AF=AD=E3=80=82=E4=B8=80?= =?UTF-8?q?=E9=A1=B9=E7=AE=97=E4=B8=8D=E4=BA=86=E7=85=A7=E5=AE=9E=E8=AE=B0?= =?UTF-8?q?=EF=BC=88analysis=5Frefused=EF=BC=89=EF=BC=8C=E4=B8=8D=E8=AE=A9?= =?UTF-8?q?=E5=8D=A1=E5=A4=B1=E8=B4=A5=EF=BC=9B=E8=B0=83=E7=94=A8=E5=86=99?= =?UTF-8?q?=E9=94=99=E8=AE=A9=E5=8D=A1=E5=A4=B1=E8=B4=A5=E5=B9=B6=E8=AF=B4?= =?UTF-8?q?=E6=B8=85=E6=98=AF=E5=93=AA=E4=B8=80=E9=A1=B9=E3=80=81=E6=80=8E?= =?UTF-8?q?=E4=B9=88=E6=94=B9=E3=80=82=E7=99=BB=E8=AE=B0=EF=BC=9A=E4=BA=8B?= =?UTF-8?q?=E4=BB=B6=20analysis.end=E3=80=81=E5=8F=AB=E6=B3=95=E3=80=8C?= =?UTF-8?q?=E5=88=86=E6=9E=90=E5=8D=A1=E3=80=8D=E3=80=8C=E5=88=86=E6=9E=90?= =?UTF-8?q?=E9=A1=B9=E3=80=8D=E3=80=8C=E9=98=88=E5=80=BC=E8=A6=86=E7=9B=96?= =?UTF-8?q?=E3=80=8D=E5=92=8C=E5=88=86=E6=9E=90=E6=96=B9=E6=B3=95=E3=80=81?= =?UTF-8?q?=E6=A8=A1=E5=BC=8F=E7=9A=84=E5=8F=AB=E6=B3=95=EF=BC=8C=E7=BC=96?= =?UTF-8?q?=E8=AF=91=E5=99=A8=EF=BC=8C=E6=B2=BB=E7=90=86=EF=BC=88=E5=88=86?= =?UTF-8?q?=E6=9E=90=E5=8D=A1=E7=AE=97=E5=8F=AF=E8=BF=BD=E6=BA=AF=E7=9A=84?= =?UTF-8?q?=E8=8A=82=E7=82=B9=EF=BC=9B=E5=8F=97=E7=AE=A1=E7=BA=A7=E5=88=AB?= =?UTF-8?q?=E5=88=86=E6=9E=90=E5=8D=A1=E7=9A=84=E8=BE=93=E5=85=A5=E8=A6=81?= =?UTF-8?q?=E6=98=AF=E6=95=B0=E6=8D=AE=E5=BA=93=E6=9F=A5=E8=AF=A2=E6=88=96?= =?UTF-8?q?=E5=90=88=E5=B9=B6=E6=9F=A5=E8=AF=A2=20governed.analysis=5Finpu?= =?UTF-8?q?t=EF=BC=8C=E7=94=BB=E5=B8=83=E9=82=A3=E6=9D=A1=E5=90=8C?= =?UTF-8?q?=E4=B9=89=E7=9A=84=E6=8F=90=E9=86=92=E4=B8=8D=E5=86=8D=E9=87=8D?= =?UTF-8?q?=E5=A4=8D=E5=88=97=EF=BC=9B=E5=8F=A3=E5=BE=84=E5=8D=A1=E8=AF=BB?= =?UTF-8?q?=E5=88=86=E6=9E=90=E5=8D=A1=E7=9A=84=E7=BB=93=E6=9E=9C=E7=BB=99?= =?UTF-8?q?=E6=8F=90=E7=A4=BA=20governed.caliber=5Fanalysis=5Finput?= =?UTF-8?q?=EF=BC=8C=E8=BE=93=E5=85=A5=E6=8C=89=20via=20analysis=20?= =?UTF-8?q?=E8=AE=B0=E3=80=81=E8=BF=BD=E4=B8=8D=E5=88=B0=E5=8E=9F=E5=A7=8B?= =?UTF-8?q?=E6=95=B0=E6=8D=AE=EF=BC=89=EF=BC=8C=E5=8D=87=E7=BA=A7=E6=8F=90?= =?UTF-8?q?=E9=86=92=E7=9A=84=E8=AF=81=E6=8D=AE=E7=B1=BB=E8=8A=82=E7=82=B9?= =?UTF-8?q?=EF=BC=8C=E5=8F=98=E9=87=8F=E8=A1=A8=EF=BC=88=E6=AF=8F=E9=A1=B9?= =?UTF-8?q?=E7=9A=84=E7=BB=93=E8=AE=BA=E3=80=81=E5=AF=B9=E4=B8=8A=E6=B8=B8?= =?UTF-8?q?=E8=8A=82=E7=82=B9=E7=9A=84=E5=BC=95=E7=94=A8=EF=BC=89=EF=BC=8C?= =?UTF-8?q?=E6=95=B0=E6=8D=AE=E7=9B=AE=E5=BD=95=E5=BD=B1=E5=93=8D=E9=9D=A2?= =?UTF-8?q?=EF=BC=88=E5=90=8C=E5=90=88=E5=B9=B6=E6=9F=A5=E8=AF=A2=E7=BB=8F?= =?UTF-8?q?=E7=94=B1=E8=BE=93=E5=85=A5=EF=BC=89=EF=BC=8C=E5=8A=A9=E6=89=8B?= =?UTF-8?q?=E6=9F=A5=E7=9C=8B=E7=9A=84=E5=B7=A5=E4=BD=9C=E6=B5=81=E8=AF=B4?= =?UTF-8?q?=E6=98=8E=EF=BC=8C=E4=B8=A4=E6=9D=A1=E6=96=B0=E7=BC=96=E5=8F=B7?= =?UTF-8?q?=E7=9A=84=E7=99=BD=E8=AF=9D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/app/apps/describe.py | 7 + backend/app/core/events.py | 3 + backend/app/data/catalog_impact.py | 26 +- backend/app/engine/compiler.py | 3 +- backend/app/engine/governance.py | 42 ++- backend/app/engine/labels.py | 11 +- backend/app/engine/nodes/analysis.py | 166 +++++++++++ backend/app/engine/nodes/metrics.py | 2 +- backend/app/engine/schema.py | 165 +++++++++++ backend/app/engine/upgrade.py | 4 +- backend/app/engine/variables.py | 22 ++ backend/app/plain.py | 7 + backend/tests/test_analysis_card.py | 419 +++++++++++++++++++++++++++ 13 files changed, 864 insertions(+), 13 deletions(-) create mode 100644 backend/app/engine/nodes/analysis.py create mode 100644 backend/tests/test_analysis_card.py diff --git a/backend/app/apps/describe.py b/backend/app/apps/describe.py index 284fe38d..61f359ca 100644 --- a/backend/app/apps/describe.py +++ b/backend/app/apps/describe.py @@ -47,6 +47,13 @@ def describe_version(version: Any, *, workflow_name: str) -> str: else f"口径卡{title},{caliber},指标转抄自别处的口径卡") lines += [f"- {node_id}.{m['id']} {m.get('name') or ''}{('(' + m['unit'] + ')') if m.get('unit') else ''}" for m in metrics] + elif node["type"] == "analysis": + from app.engine.labels import ANALYSIS_OP_LABEL + + items = [i for i in cfg.get("items") or [] if isinstance(i, dict) and i.get("id")] + lines.append(f"分析卡{title},分析项 {len(items)} 个:" if items else f"分析卡{title},还没有分析项") + lines += [f"- {node_id}.results.{i['id']} {ANALYSIS_OP_LABEL.get(str(i.get('op')), str(i.get('op') or ''))}" + for i in items] elif node["type"] == "report": lines.append(f"报告撰写{title}") elif node["type"] in ("tool", "merge"): diff --git a/backend/app/core/events.py b/backend/app/core/events.py index fa295c27..e03d0b8a 100644 --- a/backend/app/core/events.py +++ b/backend/app/core/events.py @@ -51,6 +51,9 @@ class EventType(StrEnum): # 合并查询做完了:哪几个输入(别名、节点、行数、数据源)、哪条合并 SQL、得到几行、有哪些警告。 # 运行详情据此展示这一步;完整结果在 query_artifact 指向的查询快照里 MERGE_END = "merge.end" + # 分析卡里的一项做完了(engine/nodes/analysis.py):哪一项、什么分析、状态、判定、判定语、结论工件(analysis_artifact)。 + # 运行详情据此展示这一步,运行对比按项对齐两次的判定;整份结论在工件里。agent 分析工具的结论记在 tool.end 上 + ANALYSIS_END = "analysis.end" SANDBOX_START = "sandbox.start" SANDBOX_END = "sandbox.end" diff --git a/backend/app/data/catalog_impact.py b/backend/app/data/catalog_impact.py index c0d330e4..31f1c49b 100644 --- a/backend/app/data/catalog_impact.py +++ b/backend/app/data/catalog_impact.py @@ -150,6 +150,8 @@ def template_refs(spec: Any, source: str, table: str, tables: Iterable[str] | No - Agent 节点、协作成员:绑定了 db_query__<源> → possible(每个成员各一项,带 member)。 - 合并查询:输入(别名 → 节点)里有用到这张表的 → 取输入里最强的那一级,via 写经由哪些输入。合并的合并 一路追到底;成环(校验会报错的图)时停下。 + - 分析卡:各项的输入(主输入、两份查询、总数两格落在的节点)里有用到这张表的,同合并查询(目录里度量类型、业务日期 + 列改了,分析的判定可能跟着变),via 写经由哪些输入(alias 是那一项的标识)。 """ wanted = name_key(table) known = list(tables) if tables is not None else None @@ -175,16 +177,15 @@ def template_refs(spec: Any, source: str, table: str, tables: Iterable[str] | No resolved: dict[str, dict[str, Any] | None] = {} def through(node_id: str, trail: frozenset[str]) -> dict[str, Any] | None: - """合并查询节点经由输入用到这张表的样子;用不到返回 None。""" + """合并查询、分析卡经由输入用到这张表的样子;用不到返回 None。""" if node_id in resolved: return resolved[node_id] node = by_id.get(node_id) - if node is None or _kind(node) != "merge" or node_id in trail: + if node is None or _kind(node) not in ("merge", "analysis") or node_id in trail: return own.get(node_id) via: list[dict[str, Any]] = [] best: str | None = None - inputs = (node.config or {}).get("inputs") - for alias, target in (inputs.items() if isinstance(inputs, Mapping) else []): + for alias, target in _inputs_of(node): if not isinstance(target, str): continue hit = own.get(target) or through(target, trail | {node_id}) @@ -198,13 +199,28 @@ def template_refs(spec: Any, source: str, table: str, tables: Iterable[str] | No for node in nodes: if node.id in own: out.append(own[node.id]) - elif _kind(node) == "merge" and (hit := through(node.id, frozenset())): + elif _kind(node) in ("merge", "analysis") and (hit := through(node.id, frozenset())): out.append(hit) order = {n.id: i for i, n in enumerate(nodes)} out.sort(key=lambda r: order.get(r["node_id"], 0)) return out +def _inputs_of(node: Any) -> list[tuple[str, Any]]: + """合并查询、分析卡的输入:[(别名, 节点 id)]。合并查询是 inputs 的别名;分析卡是各项的标识(一项引用几个节点就几条, + schema.analysis_refs)。""" + config = node.config or {} + if _kind(node) == "merge": + inputs = config.get("inputs") + return list(inputs.items()) if isinstance(inputs, Mapping) else [] + from app.engine.schema import analysis_refs + + items = config.get("items") + found = [(str(item.get("id") or ""), target) for item in (items if isinstance(items, list) else []) + if isinstance(item, Mapping) for _, target in analysis_refs(dict(item))] + return list(dict.fromkeys(found)) + + async def _published(session: AsyncSession) -> list[tuple[Any, Any]]: """每个发布过的模板和它当前的已发布版本:[(Workflow, WorkflowVersion)]。""" from app.db.models import Workflow, WorkflowVersion diff --git a/backend/app/engine/compiler.py b/backend/app/engine/compiler.py index 25dd3c17..8e965bb2 100644 --- a/backend/app/engine/compiler.py +++ b/backend/app/engine/compiler.py @@ -13,7 +13,7 @@ from app.core.events import EventType from app.engine.context import NodeContext, NodeError, NodeRejected, RunContext from app.engine.expressions import open_template_scope, template_scope from app.engine.nodes import ( - challenge, control, dual_check, human, io, knowledge, llm, merge, metrics, multi, report, tools, + analysis, challenge, control, dual_check, human, io, knowledge, llm, merge, metrics, multi, report, tools, ) from app.engine.schema import ( GATE_TYPES, REJECT_HANDLE, GraphNode, GraphSpec, NodeType, approve_mode, back_edges, innermost_loops, @@ -42,6 +42,7 @@ RUNNERS: dict[NodeType, NodeRunner] = { NodeType.VALIDATE: human.run_validate, NodeType.MEMORY: knowledge.run_memory, NodeType.METRICS: metrics.run_metrics, + NodeType.ANALYSIS: analysis.run_analysis, NodeType.RETRIEVE: knowledge.run_retrieve, NodeType.REPORT: report.run_report, NodeType.DUAL_CHECK: dual_check.run_dual_check, diff --git a/backend/app/engine/governance.py b/backend/app/engine/governance.py index 3f7c0c54..e2f389b7 100644 --- a/backend/app/engine/governance.py +++ b/backend/app/engine/governance.py @@ -214,8 +214,9 @@ def _lint_sql(spec: GraphSpec, flag: Callable[..., None], checkers: Mapping[str, sql_level=check.level) -#: validate 里和门禁 G4、G5 说同一件事的两条提示(schema.evidence_issues) -_REPEATS = frozenset({"evidence.caliber_compute_input", "evidence.cite_fields_off"}) +#: validate 里和门禁 G4、G5 说同一件事的两条提示(schema.evidence_issues),和受管级分析卡输入那条(governed.analysis_input) +#: 说同一件事的画布提醒(schema.analysis_issues 对调用别的工具的「调用工具」节点的那条) +_REPEATS = frozenset({"evidence.caliber_compute_input", "evidence.cite_fields_off", "analysis.input_invalid"}) def _not_repeated(issues: list[Any], gate: list[Any], spec: GraphSpec) -> list[Any]: @@ -231,6 +232,7 @@ def _not_repeated(issues: list[Any], gate: list[Any], spec: GraphSpec) -> list[A cited = {i.node_id for i in gate if i.code == "governed.caliber_agent_cite_fields"} compute = {i.node_id for i in gate if i.code == "governed.caliber_compute_input"} covered = {("evidence.cite_fields_off", nid, "output_schema") for nid in cited} + covered |= {("analysis.input_invalid", i.node_id, i.field) for i in gate if i.code == "governed.analysis_input"} for card in spec.nodes: if card.type == NodeType.METRICS and compute: covered |= {("evidence.caliber_compute_input", card.id, f"metrics[{i}].expression") @@ -344,7 +346,8 @@ REPORT_POLICY_ALSO: dict[str, tuple[str, ...]] = {"claims": (CLAIMS_JUDGE,)} JUDGE_UNLIMITED = "不设上限,费用只受句数、时长上限和每日上限约束" #: 产出本身就是证据、或者由系统确定地算出来的节点。成果字段取它们的值,不算「别的节点写的字」 -_TRACEABLE = (NodeType.REPORT, NodeType.METRICS, NodeType.INPUT, NodeType.TOOL, NodeType.RETRIEVE, NodeType.MERGE) +_TRACEABLE = (NodeType.REPORT, NodeType.METRICS, NodeType.INPUT, NodeType.TOOL, NodeType.RETRIEVE, NodeType.MERGE, + NodeType.ANALYSIS) _WRITERS = (NodeType.LLM, NodeType.AGENT, NodeType.SUPERVISOR) #: 往对话里写消息的节点(emit_message 没关时),「最后一条消息」就是它们里最后跑的那个写的 _EMITTERS = (*_WRITERS, NodeType.REPORT) @@ -710,6 +713,39 @@ def _lint_evidence(spec: GraphSpec, flag: Callable[..., None], *, strict: bool) flag(f"{_who(feeder)}为{to}提供数据:「模型调用」节点不调用工具,写出的数字没有出处。请改由 Agent" "(配置「结构化输出 Schema」并开启「按出处核对字段」)或「调用工具」节点取数", code="governed.caliber_model_input", node_id=feeder.id, hard=True) + elif feeder.type == NodeType.ANALYSIS: + # D16:分析是叙述的依据,不是口径的原料。口径卡读它的结果按「追不到原始数据」处理,这里只提示 + flag(f"{_who(feeder)}的结果提供给了{to}:分析结论不作口径卡的输入,口径卡读到的数追不到原始数据。" + "指标请在口径卡里按格从查询结果取数,分析结论在报告里直接引用", + code="governed.caliber_analysis_input", node_id=feeder.id) + + # 受管级的分析卡:输入要在发布时就确定是平台执行的查询(调用数据库查询工具的「调用工具」节点、合并查询) + if strict: + _lint_analysis_inputs(spec, flag) + + +def _lint_analysis_inputs(spec: GraphSpec, flag: Callable[..., None]) -> None: + """受管级别的分析卡,每项的输入(主输入、两份查询、总数两格落在的节点)都要是调用数据库查询工具的「调用工具」节点 + 或「合并查询」。画布校验(schema.analysis_issues)对调用别的工具的「调用工具」节点只提醒(声明了表格的端点读取运行时 + 也交回查询快照,静态看不出),已发布级别照那条提醒;受管出具要发布时就能确定分析的是平台执行的查询,按错误挡, + 画布那条同义的提醒不再列(_not_repeated)。""" + from app.engine.schema import _produces_query, analysis_refs + + nodes = spec.node_map() + for card in spec.nodes: + if card.type != NodeType.ANALYSIS or not isinstance(card.config.get("items"), list): + continue + for i, item in enumerate(card.config["items"]): + if not isinstance(item, dict): + continue + for field, target_id in analysis_refs(item): + target = nodes.get(target_id) + if target is None or target.type != NodeType.TOOL or _produces_query(target): + continue # 不存在、不是查询节点的,画布校验已经按错误报了 + flag(f"{_who(card)}的分析项「{item.get('id')}」分析的是{_who(target)}的结果,它调用的不是数据库查询工具:" + "受管级别要求分析卡的输入是调用数据库查询工具的「调用工具」节点或「合并查询」,发布时就能确定分析的" + "是平台执行的查询。请改选这样的节点", + code="governed.analysis_input", node_id=card.id, hard=True, field=f"items[{i}].{field}") async def unresolved_caliber_upgrades( diff --git a/backend/app/engine/labels.py b/backend/app/engine/labels.py index 1ba796a7..95e25941 100644 --- a/backend/app/engine/labels.py +++ b/backend/app/engine/labels.py @@ -21,7 +21,7 @@ TYPE_LABEL: dict[str, str] = { "supervisor": "多 Agent 协作", "tool": "调用工具", "code": "沙箱代码", "merge": "合并查询", "branch": "条件分支", "loop": "循环", "subgraph": "子工作流", "memory": "长期记忆", "retrieve": "知识检索", "transform": "数据整形", - "human": "人工审批", "validate": "结构校验", "metrics": "口径卡", "report": "报告撰写", + "human": "人工审批", "validate": "结构校验", "metrics": "口径卡", "analysis": "分析卡", "report": "报告撰写", "dual_check": "双盲复算", "challenge": "反方审稿", } @@ -99,10 +99,19 @@ _FIELD_BY_TYPE: dict[str, dict[str, str]] = { "stop_on_reject": "驳回即终止运行"}, "validate": {"source": "待校验内容", "fail_fast": "校验失败即中断"}, "merge": {"inputs": "输入", "sql": "合并 SQL"}, + "analysis": {"items": "分析项", "overrides": "阈值覆盖"}, "dual_check": DUAL_CHECK_FIELD_LABEL, "challenge": CHALLENGE_LABEL, } +#: 分析卡一项里的分析方法、模式的叫法(app/analysis/contract.py 的 OPS、MODES;前端分析卡的表单照这一份) +ANALYSIS_OP_LABEL: dict[str, str] = {"decompose": "变化拆解", "baseline": "波动基线", "trend": "趋势", + "differ": "差异检验", "comparable": "可比性检查"} +ANALYSIS_MODE_LABEL: dict[str, dict[str, str]] = { + "decompose": {"additive": "可加(按维度算贡献)", "ratio": "比率(拆结构与自身)", "factor": "乘积(拆因子)"}, + "differ": {"rate": "比率", "mean": "均值", "rows": "逐行的值"}, +} + #: 出具契约里的键(canvas/issues.ts 的 CONTRACT_KEYS、SUB_KEYS) CONTRACT_KEY_LABEL: dict[str, str] = { "metrics_from": "指标来自", "report_from": "报告来自", "required": "必需指标", "expected": "期望指标", diff --git a/backend/app/engine/nodes/analysis.py b/backend/app/engine/nodes/analysis.py new file mode 100644 index 00000000..f6a2a584 --- /dev/null +++ b/backend/app/engine/nodes/analysis.py @@ -0,0 +1,166 @@ +"""分析卡(节点类型 analysis,docs/design/analysis-round/00-总方案.md §3.2):对上游查询节点的结果固定做几项确定性分析。 + +**模板定骨架。** 做哪几项、每项分析哪个上游查询节点、用哪种方法,写在节点上(items),随版本固定;阈值来自发起时的设置 +快照,卡上可以写覆盖(overrides,同样随版本固定)。问数据里要看了数才知道拆哪个维度的,交给 Agent 的分析工具——两个入口 +共用一套算子(app.analysis.run)、一种结论形状、一个证据种类。 + +**读工件库里那份完整结果。** 照合并查询的做法(engine/nodes/merge.py)从 state.nodes 取上游节点交回的查询快照 id,按 id +取回快照(取回时复验哈希),而且只认这次运行台账里 kind query 的那几份:别的工具拼一个同形的 JSON,里面的快照不是平台 +落的。遮罩列(数据源设的、按 SQL 认出改了名字的)不当维度、不当度量,和证据面板同一套认法(app/tools/analysis.py)。 + +**产出。** nodes.<卡>.results.<项> = {status, verdict, direction, values, phrase, artifact}:verdict 是判定码(outside_band、 +driver……),下游条件分支能按它走(周报「超出常规波动就转人工」)。每项的整份结论落成工件,台账记一条 kind analysis +(contract.ledger_entry,带 item),报告的写作目录编成 A{n};每项另发一条 analysis.end(运行详情、运行对比按项对齐)。 + +**一项算不了不让卡失败。** 算不了(历史不够、本期不完整、截断……)就是这一项的结论(status refused),照实记、发一条 +analysis_refused 提醒,接着做下一项。调用写错(列不存在、比率当可加……)是模板的毛病:整张卡失败,说清是哪一项、怎么改。 + +分析的结果不作口径卡的输入(D16):口径卡读 nodes.<卡>.results 的,按追不到原始数据处理(受管级发布时另有提示)。 +""" +from __future__ import annotations + +import asyncio +from typing import Any + +from pydantic import ValidationError + +from app.analysis import contract +from app.analysis.contract import AnalysisResult, CallError, InputSnapshot +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 ledger_enabled, next_exec +from app.engine.labels import ANALYSIS_OP_LABEL +from app.engine.nodes.merge import _FAILED_PREFIXES, _input_payload +from app.engine.state import GraphState +from app.tools.analysis import ( + REFUSED_CODE, context_of, hypothesis_of, input_snapshot, masked_of, run_snapshot, settings_of, store, tool_context, +) + + +def card_view(result: AnalysisResult, artifact: str) -> dict[str, Any]: + """一项结论在节点产出里的样子(nodes.<卡>.results.<项>):状态、判定码、方向、具名标量、判定语、结论工件。""" + verdict = result.get("verdict") or {} + return {"status": result.get("status"), "verdict": verdict.get("code"), "direction": verdict.get("direction"), + "values": dict(result.get("values") or {}), "phrase": result.get("phrase") or "", "artifact": artifact} + + +async def run_analysis(state: GraphState, ctx: NodeContext) -> dict[str, Any]: + from app.engine.schema import analysis_issues, analysis_refs + + # 配置的规矩和画图时同一套(schema.analysis_issues):发起前的校验已经挡过,这里兜底,写错了照样说清是哪一项 + if errors := [i for i in analysis_issues(ctx.run.spec, ctx.node) if i.level == "error"]: + raise NodeError(ctx.node.id, errors[0].message) + items: list[dict[str, Any]] = ctx.config["items"] + settings = await settings_of(run_snapshot(ctx.run), ctx.config.get("overrides") or None) + + queries = [e for e in state.get("evidence") or [] if isinstance(e, dict) and e.get("kind") == "query"] + inputs: dict[str, InputSnapshot] = {} + for item in items: + for _, node_id in analysis_refs(item): + if node_id not in inputs: + inputs[node_id] = await _load_input(state, ctx, node_id, queries) + context = await context_of(list(inputs.values()), tool_ctx=tool_context(ctx.run, ctx.node.id, ctx.emit)) + + import app.analysis as analysis_ops # 按模块取 run:测试、合并前的替身都只换模块上的这一个函数 + + exec_no = next_exec(state, ctx.node.id) + done: dict[str, AnalysisResult] = {} + results: dict[str, Any] = {} + entries: list[dict[str, Any]] = [] + for item in items: + item_id, op = item["id"], item["op"] + who = f"分析项「{item_id}」({ANALYSIS_OP_LABEL[op]})" + request = _request(item, state, ctx, who) + try: + # 下钻(parent)只认这张卡里排在前面、已经做完的项:done 里此刻只有它们 + result = await analysis_ops.run(request, inputs.get, settings=settings, context=context, parents=done.get) + except CallError as e: + raise NodeError(ctx.node.id, f"{who}:{e.message}。请在分析卡中改正这一项") from None + except Exception as e: # noqa: BLE001 - 算子本身出错:说清是哪一项,原因照实 + raise NodeError(ctx.node.id, f"{who}执行出错:{describe_exception(e)}") from e + try: + artifact = await store(result, run_id=ctx.run.run_id, node_id=ctx.node.id) + except Exception as e: # noqa: BLE001 - 存不下就没有出处可言,不能带着一份引用不了的结论往下走 + raise NodeError(ctx.node.id, f"{who}的结论没能存下来(工件库写入失败),下游无法引用它。" + "请稍后点「继续运行」重试", retryable=True) from e + done[item_id] = result + results[item_id] = card_view(result, artifact) + entries.append(contract.ledger_entry(result, node_id=ctx.node.id, exec_no=exec_no, artifact=artifact, + item=item_id)) + verdict = result.get("verdict") or {} + ctx.emit(EventType.ANALYSIS_END, item=item_id, op=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"), analysis_artifact=artifact) + if result.get("status") == "refused": + # 算不了是一条结论,不让卡失败;照实提醒一句(结构化字段给界面读,不让它从 message 里解析中文) + ctx.emit(EventType.LOG, level="warn", code=REFUSED_CODE, item=item_id, op=op, + reason_code=result.get("reason_code"), reason=result.get("reason"), + message=f"「{ctx.node.title}」的{who}没有做成:{result.get('reason') or '原因未知'}") + + output: dict[str, Any] = {"results": results} + updates: dict[str, Any] = {"nodes": {ctx.node.id: output}} + if ledger_enabled(ctx.run) and entries: + updates["evidence"] = entries + var_name = str(ctx.config.get("assign_to") or "").strip() + if var_name: + updates["vars"] = {var_name: output} + return updates + + +def _request(item: dict[str, Any], state: GraphState, ctx: NodeContext, who: str) -> contract.AnalysisRequest: + """一项 → 一次分析的请求。参数里的 {{ }} 先按当前状态渲染(按运行输入选期次);主输入(from)就是 agent 工具的 + query。参数按 agent 工具同一份参数模型校验(tools/builtin/analysis.ARGS),写错了说清是哪一项、它接受什么。""" + from app.tools.builtin.analysis import ARGS, request_of + from app.tools.registry import describe_args + + op = item["op"] + params = ctx.render(dict(item.get("params") or {}), state) + if isinstance(item.get("from"), str) and item["from"].strip(): + params["query"] = item["from"].strip() + if op in contract.MODES: + params["mode"] = item.get("mode") + if hypothesis := hypothesis_of(item.get("hypothesis")): + params["hypothesis"] = hypothesis + try: + args = ARGS[op].model_validate(params) + except ValidationError as e: + raise NodeError(ctx.node.id, f"{who}的{describe_args(ARGS[op], params, e)}") from None + return request_of(op, args) + + +async def _load_input(state: GraphState, ctx: NodeContext, node_id: str, + queries: list[dict[str, Any]]) -> InputSnapshot: + """上游一个查询节点这次交回的查询快照 → 算子的一份输入(含遮罩列)。只认这次运行台账里 kind query 的快照。""" + target = ctx.run.spec.node_map()[node_id] # 节点在不在、在不在上游、是不是查询节点,执行前已经校验过 + who = f"输入「{target.title}」" + value = (state.get("nodes") or {}).get(node_id) + if value is None: + raise NodeError(ctx.node.id, f"{who}还没有运行结果:请确认它连在分析卡之前") + if isinstance(value, dict) and value.get("skipped"): + raise NodeError(ctx.node.id, f"{who}满足跳过条件被跳过了,没有查询结果可以分析") + if isinstance(value, dict) and value.get("failed"): + raise NodeError(ctx.node.id, f"{who}执行失败,没有查询结果可以分析:{value.get('error') or '原因未知'}") + if isinstance(value, str) and value.startswith(_FAILED_PREFIXES): + raise NodeError(ctx.node.id, f"{who}的查询没有成功,无法分析:{value[:300]}") + payload = _input_payload(value) + artifact = payload.get("artifact") if payload else None + if not isinstance(artifact, str) or not artifact: + raise NodeError(ctx.node.id, f"{who}的结果不是存成快照的查询结果。分析卡只能分析数据库查询(调用数据库查询工具的" + "「调用工具」节点)或「合并查询」的结果") + if not any(e.get("artifact") == artifact for e in queries): + raise NodeError(ctx.node.id, f"{who}交回的不是这次运行里平台执行的查询(台账里没有这份查询快照),不能分析。" + "请改选调用数据库查询工具的「调用工具」节点或「合并查询」") + try: + content = await asyncio.to_thread(artifact_store.load, artifact) + except ValueError: + raise NodeError(ctx.node.id, f"{who}的查询快照与哈希不一致,疑似被修改,不能分析") from None + if not isinstance(content, dict): + raise NodeError(ctx.node.id, f"{who}的查询快照已不存在,无法分析。请重新运行它") + snapshot = input_snapshot(content, ref=node_id, artifact=artifact, node_id=node_id) + snapshot["masked"] = await masked_of(content, artifact) + return snapshot + + +__all__ = ["REFUSED_CODE", "card_view", "run_analysis"] diff --git a/backend/app/engine/nodes/metrics.py b/backend/app/engine/nodes/metrics.py index ac1c88de..d040fa29 100644 --- a/backend/app/engine/nodes/metrics.py +++ b/backend/app/engine/nodes/metrics.py @@ -694,7 +694,7 @@ _REF_HEAD = re.compile(r"^(vars|nodes|input)\.([A-Za-z_一-鿿][\w\-一-鿿]*)(. _VIA = {"input": "input", "code": "code", "agent": "agent", "llm": "llm", "tool": "tool", "transform": "transform", "metrics": "metric", "retrieve": "retrieval", "loop": "loop", "human": "human", "validate": "validate", "supervisor": "team", "subgraph": "subgraph", - "memory": "memory", "merge": "merge"} + "memory": "memory", "merge": "merge", "analysis": "analysis"} def _input_of(path: str, value: Any, state: GraphState, ctx: NodeContext, diff --git a/backend/app/engine/schema.py b/backend/app/engine/schema.py index e6e439da..447591cb 100644 --- a/backend/app/engine/schema.py +++ b/backend/app/engine/schema.py @@ -43,6 +43,8 @@ class NodeType(StrEnum): HUMAN = "human" # 人工介入 VALIDATE = "validate" # 结构化校验 + 自动重试 METRICS = "metrics" # 口径卡:受控指标集,叙述层唯一合法的数字来源 + # 分析卡:对上游查询快照做固定的几项确定性分析(拆解、基线、趋势、差异、可比性),和口径卡平级(engine/nodes/analysis.py) + ANALYSIS = "analysis" REPORT = "report" # 报告撰写:模型只写引用标记,数字由系统从口径卡取出来渲染 # 下面两种目前只是占位(执行器原样放行),各自的工作包补上真正的逻辑 DUAL_CHECK = "dual_check" # 双盲复算:另起一路独立算一遍,和原结果比对 @@ -632,6 +634,167 @@ def _check_merge(node: GraphNode, spec: GraphSpec, result: ValidationResult) -> result.add(problem, node_id=node.id, field="sql", code="merge.sql_invalid") +#: 分析卡一项的标识:要能写进取值链(nodes.<卡>.results.<标识>),条件分支按它取判定 +ANALYSIS_ITEM_ID = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$") +#: 分析卡一项里写上游节点 id 的参数:基期、本期是两份查询时各选一个(主输入写在 from,对应 agent 工具的 query) +ANALYSIS_NODE_PARAMS = ("base_query", "target_query") +#: 一项里写成「节点 id.r行号.列名」的格(总数 headline 的两格) +ANALYSIS_CELL = re.compile(r"^([A-Za-z_][\w-]*)\.r(\d+)\.(.+)$") + + +def analysis_refs(item: dict[str, Any]) -> list[tuple[str, str]]: + """分析卡一项引用的上游节点:[(这一项里的字段路径, 节点 id)],按 from、base_query、target_query、总数两格的先后。 + 写错了的(不是字符串、格的写法不对)不在里面,由 analysis_issues 报。""" + params = item.get("params") if isinstance(item.get("params"), dict) else {} + out: list[tuple[str, str]] = [] + if isinstance(item.get("from"), str) and item["from"].strip(): + out.append(("from", item["from"].strip())) + out += [(f"params.{k}", params[k].strip()) for k in ANALYSIS_NODE_PARAMS + if isinstance(params.get(k), str) and params[k].strip()] + headline = params.get("headline") + for side in ("base", "target"): + cell = ANALYSIS_CELL.match(headline[side].strip()) \ + if isinstance(headline, dict) and isinstance(headline.get(side), str) else None + if cell is not None: + out.append((f"params.headline.{side}", cell.group(1))) + return out + + +def _analysis_input(spec: GraphSpec, target_id: str, upstream: set[str]) -> tuple[str, str] | None: + """分析卡一项的一个输入有什么问题:(级别, 半句说明),没问题为 None。规则同合并查询:只认能稳定取到一份查询快照的 + 上游节点。调用的不是数据库查询工具的「调用工具」节点只提醒:登记里声明了表格的端点读取运行时也交回查询快照,静态看 + 不出来;交回的不是平台执行的查询,运行到这一项就失败(受管级别发布时按错误挡,engine/governance.py)。""" + target = spec.node_map().get(target_id) + if target is None: + return "error", f"选择的输入「{target_id}」在工作流中不存在" + if target.type == NodeType.AGENT: + return "error", (f"选择的输入「{target.title}」是 Agent:Agent 可能查询多次,无法确定分析哪一次的结果。" + "请把要分析的那条查询单独放进「调用工具」节点(选择数据库查询工具),再把它选作输入;" + "要 Agent 看了数再决定拆哪个维度的,给它绑分析工具") + if target.type not in (NodeType.TOOL, NodeType.MERGE): + return "error", (f"选择的输入「{target.title}」是「{type_label(target.type)}」节点,不产出查询结果。" + "输入只能是调用数据库查询工具的「调用工具」节点,或「合并查询」节点") + if target_id not in upstream: + return "error", f"选择的输入「{target.title}」不在分析卡的上游:分析时它还没有运行。请把它连到分析卡之前" + if not _produces_query(target): + return "warning", (f"选择的输入「{target.title}」调用的不是数据库查询工具:运行时它交回的要是平台执行的查询结果" + "才能分析,否则这一项会失败") + return None + + +def analysis_issues(spec: GraphSpec, node: GraphNode) -> list[ValidationIssue]: + """分析卡的配置有什么问题(画图时的校验和执行时同一套,engine/nodes/analysis.py 执行前先过一遍,模板写错了早暴露)。 + + - items:至少一项;每项的标识合法、不重复;分析方法(op)、模式(mode)是契约里的;输入(from,或两份查询时的 + base_query、target_query,总数两格落在的节点)指向上游的「调用工具」/「合并查询」节点 + - params:只能写这种分析的参数(和 agent 工具的参数同一份定义,tools/builtin/analysis.py),不能写阈值——阈值统一 + 写在 overrides(阈值覆盖)里,按 contract.settings_problem 校验 + - parent(下钻):指向这张卡里排在它前面的一项变化拆解 + """ + from app.analysis import contract + from app.engine.labels import ANALYSIS_MODE_LABEL, ANALYSIS_OP_LABEL + from app.tools.builtin.analysis import param_names + + out: list[ValidationIssue] = [] + + def add(message: str, field: str, code: str, level: str = "error") -> None: + out.append(ValidationIssue(level=level, message=message, node_id=node.id, field=field, # type: ignore[arg-type] + code=code)) + + cfg = node.config + items = cfg.get("items") + if not isinstance(items, list) or not items: + add("「分析卡」还没有配置分析项:至少写一项,说清做什么分析、分析哪个上游查询节点的结果", "items", + "analysis.items_missing") + items = [] + upstream = _ancestors(spec, node.id) + seen: dict[str, int] = {} + for i, item in enumerate(items): + at = f"items[{i}]" + if not isinstance(item, dict): + add(f"第 {i + 1} 个分析项的格式有误,需要是一个对象", at, "analysis.item_invalid") + continue + item_id = item.get("id") + who = f"分析项「{item_id}」" if isinstance(item_id, str) and item_id else f"第 {i + 1} 个分析项" + if not isinstance(item_id, str) or not ANALYSIS_ITEM_ID.match(item_id): + add(f"{who}的标识只能用字母、数字和下划线,且不以数字开头:下游按 nodes.{node.id}.results.<标识> 取它的判定", + f"{at}.id", "analysis.item_invalid") + elif item_id in seen: + add(f"有两个分析项的标识都是「{item_id}」,标识要唯一", f"{at}.id", "analysis.item_invalid") + else: + seen[item_id] = i + op, mode = item.get("op"), item.get("mode") + if op not in contract.OPS: + add((f"{who}的分析方法「{op}」不认识" if op not in (None, "") else f"{who}还没有选择分析方法") + ",可选:" + + "、".join(ANALYSIS_OP_LABEL.values()), f"{at}.op", "analysis.op_invalid") + continue + modes = contract.MODES.get(op) + if modes and mode not in modes: + add(f"{who}是{ANALYSIS_OP_LABEL[op]},要选一种模式:" + "、".join(ANALYSIS_MODE_LABEL[op].values()) + + (f"(当前为「{mode}」)" if mode not in (None, "") else ""), f"{at}.mode", "analysis.mode_invalid") + elif not modes and mode not in (None, ""): + add(f"{who}是{ANALYSIS_OP_LABEL[op]},没有模式可选,请去掉模式", f"{at}.mode", "analysis.mode_invalid") + params = item.get("params") if item.get("params") is not None else {} + if not isinstance(params, dict): + add(f"{who}的参数需要是一个对象", f"{at}.params", "analysis.params_invalid") + continue + allowed = [k for k in param_names(op) if k != "query"] + for key in params: + if key in contract.DEFAULTS: + add(f"{who}的参数里写了阈值「{contract.LABELS[key]}」:阈值不写在分析项里,统一写在这张卡的" + f"「{field_label('overrides', NodeType.ANALYSIS)}」里", f"{at}.params.{key}", "analysis.params_invalid") + elif key == "query": + add(f"{who}要分析的查询节点写在输入里,参数里不写", f"{at}.params.{key}", "analysis.params_invalid") + elif key not in allowed: + add(f"{who}的参数里没有「{key}」,{ANALYSIS_OP_LABEL[op]}可以写:" + "、".join(allowed), + f"{at}.params.{key}", "analysis.params_invalid") + if item.get("hypothesis") is not None and not isinstance(item.get("hypothesis"), str): + add(f"{who}的「在验证什么」需要是一段文字", f"{at}.hypothesis", "analysis.item_invalid") + both = all(isinstance(params.get(k), str) and params[k].strip() for k in ANALYSIS_NODE_PARAMS) + if not (isinstance(item.get("from"), str) and item["from"].strip()) and not both: + two = ";基期、本期是两份查询时,在参数里选上这两个查询节点" if op in ("decompose", "comparable") else "" + add(f"{who}还没有选择输入:选一个上游的查询节点(调用数据库查询工具的「调用工具」节点,或「合并查询」){two}", + f"{at}.from", "analysis.input_invalid") + headline = params.get("headline") + if headline is not None and not (isinstance(headline, dict) + and all(isinstance(headline.get(k), str) for k in ("base", "target"))): + add(f"{who}的总数要写成基期、本期两格,每格写成「节点 id.r行号.列名」,行号从 0 数", + f"{at}.params.headline", "analysis.params_invalid") + elif isinstance(headline, dict): + for side, said in (("base", "基期"), ("target", "本期")): + if not ANALYSIS_CELL.match(headline[side].strip()): + add(f"{who}的总数里{said}那一格要写成「节点 id.r行号.列名」(行号从 0 数),当前为「{headline[side]}」", + f"{at}.params.headline.{side}", "analysis.params_invalid") + for field, target_id in analysis_refs(item): + if (found := _analysis_input(spec, target_id, upstream)) is not None: + add(f"{who}{found[1]}", f"{at}.{field}", "analysis.input_invalid", found[0]) + parent = params.get("parent") if "parent" in allowed else None # 别的分析写了下钻,上面已按多写的参数报 + if parent is not None: + of = parent.get("of") if isinstance(parent, dict) else None + if not (isinstance(parent, dict) and isinstance(of, str) and of and isinstance(parent.get("key"), list) + and parent["key"]): + add(f"{who}的下钻要写清是哪一项里的哪一段:上一层分析项的标识和那一段的维度值", + f"{at}.params.parent", "analysis.parent_invalid") + elif seen.get(of, i) >= i: + add(f"{who}下钻的上一层「{of}」要是这张分析卡里排在它前面的一项", f"{at}.params.parent", + "analysis.parent_invalid") + elif (items[seen[of]] or {}).get("op") != "decompose": + add(f"{who}下钻的上一层「{of}」不是变化拆解", f"{at}.params.parent", "analysis.parent_invalid") + overrides = cfg.get("overrides") + if overrides not in (None, {}): + label = field_label("overrides", NodeType.ANALYSIS) + if not isinstance(overrides, dict): + add(f"「{label}」需要是一个对象", "overrides", "analysis.overrides_invalid") + elif problem := contract.settings_problem(overrides): + add(f"「{label}」有误:{problem}", "overrides", "analysis.overrides_invalid") + return out + + +def _check_analysis(node: GraphNode, spec: GraphSpec, result: ValidationResult) -> None: + for issue in analysis_issues(spec, node): + result.add(issue.message, level=issue.level, node_id=issue.node_id, field=issue.field, code=issue.code) + + #: 报告撰写节点的结论句策略(claims)。off:结论句不参与判档(默认,保持以前的行为); #: require_citation:没挂引用的结论句计入缺口、按出具档位降档。这两档是确定性的(发布前自动修复 #: 给人选的也只有这两档) @@ -989,6 +1152,8 @@ def validate_graph(spec: GraphSpec) -> ValidationResult: _check_report_claims(node, spec, result) elif node.type == NodeType.MERGE: _check_merge(node, spec, result) + elif node.type == NodeType.ANALYSIS: + _check_analysis(node, spec, result) elif node.type == NodeType.CHALLENGE: _check_challenge(node, spec, result) elif node.type == NodeType.OUTPUT: diff --git a/backend/app/engine/upgrade.py b/backend/app/engine/upgrade.py index 2bb86478..f7eb6b45 100644 --- a/backend/app/engine/upgrade.py +++ b/backend/app/engine/upgrade.py @@ -51,9 +51,9 @@ LEVELS = ("published", "governed") _LLM_ONLY = ("prompt", "system", "skills", "use_history", "output_schema") #: 新的报告撰写节点照搬过去的模型配置(R3 从 agent 带过去的只有 provider、model:温度、上限是查数用的) _MODEL_KEYS = ("provider", "model") -#: 报告的指令里照搬了这几类节点的文字,就要提醒:报告撰写自己会收集它们的证据 +#: 报告的指令里照搬了这几类节点的文字,就要提醒:报告撰写自己会收集它们的证据(分析卡的结论进台账,写作目录编成 A{n}) _EVIDENCE_TYPES = (NodeType.METRICS, NodeType.TOOL, NodeType.AGENT, NodeType.RETRIEVE, NodeType.CODE, - NodeType.SUBGRAPH, NodeType.MERGE) + NodeType.SUBGRAPH, NodeType.MERGE, NodeType.ANALYSIS) #: 写整段文字的节点:出具契约的 narrative 拼了其中几个的文字,就说不准该换哪一个 _WRITER_TYPES = (NodeType.LLM, NodeType.AGENT) _QUESTION = re.compile(r"(? VariableReport: f"nodes.{node.id}", kind="node", label=f"「{label}」这一步的输出", produced_by=node.id, produced_by_label=label, order=order, ) + # 分析卡每一项的结论(engine/nodes/analysis.py 的 card_view):条件分支按判定走、成果取判定语都从这里取 + if node.type == "analysis" and isinstance(cfg.get("items"), list): + for item in cfg["items"]: + item_id = item.get("id") if isinstance(item, dict) else None + if isinstance(item_id, str) and item_id: + ensure( + f"nodes.{node.id}.results.{item_id}", kind="node", + label=f"「{label}」分析项「{item_id}」的结论", + produced_by=node.id, produced_by_label=label, order=order, + description="status 状态、verdict 判定、direction 方向、values 关键数、phrase 判定语", + ) # 每个根命名空间本身也是合法引用:{{ input }} 取整个输入 dict, # {{ vars }} 取整个变量池——template_context 里它们就是顶层的键。 @@ -345,6 +356,17 @@ def analyze(spec: GraphSpec) -> VariableReport: node_id=node.id, node_label=label, field=field, expr=m.group(0), )) + # 分析卡的输入同样按节点 id 选(主输入、两份查询、总数两格落在的节点):记成对上游节点的引用 + if node.type == "analysis" and isinstance(cfg.get("items"), list): + from app.engine.schema import analysis_refs + + for i, item in enumerate(cfg["items"]): + for field, source in analysis_refs(item) if isinstance(item, dict) else []: + target = index.get(f"nodes.{source}") + if target is not None: + target.refs.append(VarRef(node_id=node.id, node_label=label, field=f"items[{i}].{field}", + expr=f"nodes.{source}")) + # 合并查询的输入按节点 id 选,不写模板:不认这一条的话,变量表里看不出上游那条查询被谁用了。 # 选错的节点(不存在、不在上游、不是查询)由 validate_graph 报,这里只记引用 if node.type == "merge" and isinstance(cfg.get("inputs"), dict): diff --git a/backend/app/plain.py b/backend/app/plain.py index 6c38d3a4..0058ff60 100644 --- a/backend/app/plain.py +++ b/backend/app/plain.py @@ -93,6 +93,13 @@ PLAIN: dict[str, Plain] = { "给口径卡供数的 Agent 没核对字段出处", "口径卡读到的仍是模型给的数。", _A, "打开按出处核对字段。"), "governed.caliber_model_input": Plain( "口径卡的数来自「模型调用」节点", "这种节点不调工具,写出的数没有出处。", _A, "改由「调用工具」节点或配好结构化输出的 Agent 取数。"), + # 分析卡(engine/nodes/analysis.py,docs/design/analysis-round/) + "governed.caliber_analysis_input": Plain( + "口径卡的数来自分析卡", "分析结论是叙述的依据,不是口径的原料,口径卡读到的数追不到原始数据。", _A, + "口径卡里按格从查询结果取数,分析结论在报告里直接引用。"), + "governed.analysis_input": Plain( + "分析卡分析的不是数据库查询的结果", "受管级别要求发布时就能确定分析的是平台执行的查询,否则结论说不清出处。", _A, + "把分析卡这一项的输入改选调用数据库查询工具的「调用工具」节点或「合并查询」。"), # ---------------------------------------------------------------- 写外部系统(engine/lint_capabilities.py) "action.write_in_agent": Plain( "Agent 的工具里有会写外部系统的能力", "写入只能放在「调用工具」节点里,先确认再发送、留生效记录。", _Y, diff --git a/backend/tests/test_analysis_card.py b/backend/tests/test_analysis_card.py new file mode 100644 index 00000000..0aae7593 --- /dev/null +++ b/backend/tests/test_analysis_card.py @@ -0,0 +1,419 @@ +"""分析卡(节点类型 analysis,docs/design/analysis-round/00-总方案.md §3.2):画图时的校验、执行、产出的变量、受管规则、 +登记处(叫法、变量表、助手查看、目录影响面、升级提醒)。运行对比按项对齐见 tests/test_analysis_compare.py。 + +算子(app.analysis.run)换成按请求交回假结论的替身(tests/fixtures/analysis_ops.FakeOps)。库是虚构的零售库 shop。 +""" +from __future__ import annotations + +import asyncio +import sqlite3 + +import pytest +from sqlalchemy import select + +from app.analysis import contract +from app.analysis.contract import CallError +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.runner import run_manager +from app.engine.schema import GraphSpec, NodeType, validate_graph +from tests.fixtures.analysis_ops import FakeOps + +TOOL = "db_query__shop" +WEEKLY = "SELECT week, SUM(amount) AS gmv FROM orders GROUP BY week ORDER BY week" +REGION = "SELECT region, week, SUM(amount) AS gmv FROM orders GROUP BY region, week ORDER BY region, week" +ROWS = [(1, "华东", "2026-W41", 820.0), (2, "华东", "2026-W42", 700.0), (3, "华南", "2026-W41", 700.0), + (4, "华南", "2026-W42", 666.0), (5, "华北", "2026-W41", 600.0), (6, "华北", "2026-W42", 560.0)] +WOW = {"id": "wow", "op": "baseline", "from": "weekly", "params": {"period": "week", "measure": "gmv", + "label": "销售额"}} +WHERE = {"id": "where", "op": "decompose", "mode": "additive", "from": "by_region", + "params": {"by": ["region"], "period": "week", "base": "2026-W41", "target": "2026-W42", "measure": "gmv", + "headline": {"base": "weekly.r0.gmv", "target": "weekly.r1.gmv"}, "label": "销售额"}, + "hypothesis": "下降是不是集中在某个地区"} +DRILL = {"id": "east", "op": "decompose", "mode": "additive", "from": "by_region", + "params": {"by": ["week"], "period": "week", "base": "2026-W41", "target": "2026-W42", "measure": "gmv", + "parent": {"of": "where", "key": ["华东"]}}} + + +@pytest.fixture(autouse=True) +async def engine_up(): + await run_manager.setup() + yield + await run_manager.shutdown() + + +async def _shop(tmp_path, options=None): + path = tmp_path / "shop.db" + db = sqlite3.connect(path) + db.executescript("CREATE TABLE orders (id INTEGER PRIMARY KEY, region TEXT, week TEXT, amount REAL);") + db.executemany("INSERT INTO orders VALUES (?, ?, ?, ?)", ROWS) + 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, options or {} + await session.commit() + source_id = row.id + await engines.invalidate(source_id) + return source_id + + +@pytest.fixture +async def shop(tmp_path): + source_id = await _shop(tmp_path) + 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 edge(a, b, handle=None): + return {"source": a, "target": b, **({"sourceHandle": handle} if handle else {})} + + +def card_graph(items=(WOW, WHERE), *, extra_nodes=(), extra_edges=(), **card): + nodes = [node("start", "input"), + node("weekly", "tool", tool=TOOL, args={"sql": WEEKLY}), + node("by_region", "tool", tool=TOOL, args={"sql": REGION}), + node("card", "analysis", items=[dict(i) if isinstance(i, dict) else i for i in items], **card), + node("out", "output", fields=[{"name": "判定", "value": "{{ nodes.card.results.wow.phrase }}"}]), + *extra_nodes] + edges = [edge("start", "weekly"), edge("weekly", "by_region"), edge("by_region", "card"), edge("card", "out"), + *extra_edges] + return {"nodes": nodes, "edges": edges} + + +def card_issues(graph, level="error"): + return [i for i in validate_graph(GraphSpec.model_validate(graph)).issues + if i.node_id == "card" and (level is None or i.level == level)] + + +async def wait(run_id, statuses=("succeeded", "failed")): + 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.manifest_seq is not None: + return row + raise AssertionError(f"run {run_id} 没有结束:{row.status}") + + +async def events(run_id, etype=None, node_id=None): + 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 [e.data for e in (await session.execute(q.order_by(RunEvent.seq))).scalars()] + + +async def run_graph(graph, **kw): + run = await run_manager.start(graph=graph, input_payload={}, **kw) + return await wait(run.id) + + +async def output_of(run_id, node_id): + [finished] = await events(run_id, "node.finished", node_id) + return finished, artifact_store.load(finished["artifact"]) + + +# -------------------------------------------------------------------------- +# 画图时的校验 +# -------------------------------------------------------------------------- + + +def test_node_type_and_labels(): + from app.engine.labels import field_label, type_label + + assert NodeType.ANALYSIS == contract.NODE_TYPE == "analysis" + assert type_label("analysis") == "分析卡" + assert field_label("items", "analysis") == "分析项" and field_label("overrides", "analysis") == "阈值覆盖" + + +def test_a_well_formed_card_validates(): + assert card_issues(card_graph(items=(WOW, WHERE, DRILL), overrides={"main_share": 0.6}), level=None) == [] + + +@pytest.mark.parametrize("items, field, code, why", [ + ([], "items", "analysis.items_missing", "至少写一项"), + (["wow"], "items[0]", "analysis.item_invalid", "需要是一个对象"), + ([{**WOW, "id": "1st"}], "items[0].id", "analysis.item_invalid", "字母、数字和下划线"), + ([WOW, {**WHERE, "id": "wow"}], "items[1].id", "analysis.item_invalid", "唯一"), + ([{**WOW, "op": "forecast"}], "items[0].op", "analysis.op_invalid", "不认识"), + ([{**WHERE, "mode": None}], "items[0].mode", "analysis.mode_invalid", "要选一种模式"), + ([{**WOW, "mode": "additive"}], "items[0].mode", "analysis.mode_invalid", "没有模式可选"), + ([{**WOW, "from": None}], "items[0].from", "analysis.input_invalid", "还没有选择输入"), + ([{**WOW, "from": "nope"}], "items[0].from", "analysis.input_invalid", "不存在"), + ([{**WOW, "params": {**WOW["params"], "k_warn": 3}}], "items[0].params.k_warn", "analysis.params_invalid", + "阈值覆盖"), + ([{**WOW, "params": {**WOW["params"], "query": "weekly"}}], "items[0].params.query", "analysis.params_invalid", + "输入里"), + ([{**WOW, "params": {**WOW["params"], "bogus": 1}}], "items[0].params.bogus", "analysis.params_invalid", + "没有「bogus」"), + ([{**WHERE, "params": {**WHERE["params"], "headline": {"base": "weekly.gmv", "target": "weekly.r1.gmv"}}}], + "items[0].params.headline.base", "analysis.params_invalid", "节点 id.r行号.列名"), + ([{**WHERE, "params": {**WHERE["params"], "headline": "weekly.r0.gmv"}}], "items[0].params.headline", + "analysis.params_invalid", "两格"), + ([DRILL, WHERE], "items[0].params.parent", "analysis.parent_invalid", "排在它前面"), + ([WOW, {**DRILL, "params": {**DRILL["params"], "parent": {"of": "wow", "key": ["华东"]}}}], + "items[1].params.parent", "analysis.parent_invalid", "不是变化拆解"), + ([WHERE, {**DRILL, "params": {**DRILL["params"], "parent": {"of": "where"}}}], "items[1].params.parent", + "analysis.parent_invalid", "写清是哪一项里的哪一段"), + ([WHERE, {**WOW, "params": {**WOW["params"], "parent": {"of": "where", "key": ["华东"]}}}], + "items[1].params.parent", "analysis.params_invalid", "没有「parent」"), +]) +def test_item_problems(items, field, code, why): + found = [i for i in card_issues(card_graph(items=items)) if i.field == field] + assert found and found[0].code == code and why in found[0].message, [i.message for i in card_issues( + card_graph(items=items))] + + +@pytest.mark.parametrize("extra, why", [ + (node("think", "llm", prompt="写点什么"), "不产出查询结果"), + (node("asker", "agent", prompt="查一下", tools=[TOOL]), "Agent 可能查询多次"), +]) +def test_inputs_must_be_query_nodes(extra, why): + graph = card_graph(items=[{**WOW, "from": extra["id"]}], extra_nodes=[extra], + extra_edges=[edge("by_region", extra["id"]), edge(extra["id"], "card")]) + [issue] = card_issues(graph) + assert issue.field == "items[0].from" and why in issue.message and issue.code == "analysis.input_invalid" + + +def test_inputs_must_be_upstream_and_other_tools_only_warn(): + lonely = node("lonely", "tool", tool=TOOL, args={"sql": WEEKLY}) + [issue] = card_issues(card_graph(items=[{**WOW, "from": "lonely"}], extra_nodes=[lonely], + extra_edges=[edge("start", "lonely")])) + assert "不在分析卡的上游" in issue.message + # headline 那两格落在的节点同样要是上游的查询节点 + headline = {**WHERE["params"], "headline": {"base": "lonely.r0.gmv", "target": "weekly.r1.gmv"}} + [issue] = card_issues(card_graph(items=[{**WHERE, "params": headline}], extra_nodes=[lonely], + extra_edges=[edge("start", "lonely")])) + assert issue.field == "items[0].params.headline.base" and "不在分析卡的上游" in issue.message + # 调用别的工具的调用工具节点:登记里声明了表格的端点读取也交回快照,静态看不出,只提醒 + other = node("ext", "tool", tool="crm_report", args={}) + graph = card_graph(items=[{**WOW, "from": "ext"}], extra_nodes=[other], + extra_edges=[edge("by_region", "ext"), edge("ext", "card")]) + assert card_issues(graph) == [] + [warning] = card_issues(graph, level="warning") + assert warning.code == "analysis.input_invalid" and "不是数据库查询工具" in warning.message + + +@pytest.mark.parametrize("overrides, why", [ + ({"k_warn": 3.0, "k_alert": 2.5}, "要大于"), + ({"main_share": 2}, "之间"), + ({"bogus": 1}, "不认识的分析设置"), + ("tight", "需要是一个对象"), +]) +def test_overrides_are_checked_by_the_contract(overrides, why): + [issue] = card_issues(card_graph(overrides=overrides)) + assert issue.field == "overrides" and issue.code == "analysis.overrides_invalid" and why in issue.message + + +def test_two_snapshot_items_need_both_nodes(): + two = {"id": "same", "op": "comparable", "params": {"base_query": "weekly", "target_query": "by_region"}} + assert card_issues(card_graph(items=[two])) == [] + half = {"id": "same", "op": "comparable", "params": {"base_query": "weekly"}} + [issue] = card_issues(card_graph(items=[half])) + assert issue.field == "items[0].from" and "两个查询节点" in issue.message + + +# -------------------------------------------------------------------------- +# 执行 +# -------------------------------------------------------------------------- + + +async def test_the_card_runs_each_item_and_records_it(monkeypatch, shop): + ops = FakeOps(monkeypatch, verdicts=["outside_band", "driver", "driver"], peek=["wow", "where", "east"]) + row = await run_graph(card_graph(items=(WOW, WHERE, DRILL), overrides={"main_share": 0.6}), + options={contract.SETTINGS_KEY: {"k_warn": 2.5}}) + assert row.status == "succeeded", row.error + + # 算子拿到的:请求、按上游节点 id 解析的快照(只认台账里的那几份)、阈值(缺省 ← 发起时的快照 ← 卡上的覆盖) + wow, where, east = ops.calls + assert wow["request"] == {"op": "baseline", "mode": None, + "params": {"period": "week", "measure": "gmv", "label": "销售额", "query": "weekly"}} + assert where["request"]["hypothesis"] == "下降是不是集中在某个地区" and where["request"]["mode"] == "additive" + assert where["request"]["params"]["headline"] == {"base": "weekly.r0.gmv", "target": "weekly.r1.gmv"} + weekly = wow["snapshots"]["weekly"] + [tool_end] = await events(row.id, "tool.end", "weekly") + assert weekly["ref"] == "weekly" and weekly["node_id"] == "weekly" and weekly["artifact"] == tool_end["query_artifact"] + assert weekly["columns"] == ["week", "gmv"] and weekly["rows"] == [["2026-W41", 2120.0], ["2026-W42", 1926.0]] + assert weekly["sql"] == WEEKLY and weekly["source"] == "shop" and weekly["masked"] == [] + assert wow["settings"] == {**contract.DEFAULTS, "k_warn": 2.5, "main_share": 0.6} + # 下钻取到同卡里前一项的结论;只看得到排在前面、已经做完的项 + assert east["parent"]["op"] == "decompose" and east["parent"]["params"]["query"] == "by_region" + assert east["visible"] == {"wow": True, "where": True, "east": False} + assert wow["visible"] == {"wow": False, "where": False, "east": False} + + finished, output = await output_of(row.id, "card") + results = output["results"] + assert set(results) == {"wow", "where", "east"} # 产出工件按规范 JSON 存,键的先后看配置里项的先后 + assert results["wow"]["verdict"] == "outside_band" and results["wow"]["status"] == "ok" + assert results["wow"]["phrase"].startswith("销售额本期变化") and results["wow"]["direction"] == "down" + assert results["where"]["values"]["top_share"] == "0.6186" + # 台账:每项一条 kind analysis(带 item),工件就是整份结论 + entries = finished["evidence"] + assert [(e["kind"], e["item"]) for e in entries] == [("analysis", "wow"), ("analysis", "where"), + ("analysis", "east")] + for entry, item in zip(entries, ("wow", "where", "east")): + stored = artifact_store.load(entry["artifact"]) + assert entry == contract.ledger_entry(stored, node_id="card", exec_no=1, artifact=entry["artifact"], + item=item) + assert results[item]["artifact"] == entry["artifact"] + # 每项一条 analysis.end + ends = await events(row.id, "analysis.end", "card") + assert [(e["item"], e["op"], e["verdict"], e["analysis_artifact"]) for e in ends] == [ + (e["item"], {"wow": "baseline"}.get(e["item"], "decompose"), results[e["item"]]["verdict"], + results[e["item"]]["artifact"]) for e in ends] + # 成果字段取判定语 + assert row.output["判定"] == results["wow"]["phrase"] + + +async def test_a_refused_item_does_not_fail_the_card(monkeypatch, shop): + FakeOps(monkeypatch, statuses=["refused", "ok"]) + row = await run_graph(card_graph()) + assert row.status == "succeeded", row.error + _, output = await output_of(row.id, "card") + assert output["results"]["wow"]["status"] == "refused" and output["results"]["wow"]["verdict"] is None + assert output["results"]["wow"]["phrase"].endswith("历史只有 5 期,至少要 8 期") + assert output["results"]["where"]["status"] == "ok" + [warn] = [e for e in await events(row.id, "log", "card") if e.get("code") == "analysis_refused"] + assert warn["item"] == "wow" and warn["reason_code"] == "history_short" and "wow" in warn["message"] + + +async def test_a_call_error_fails_the_card_and_names_the_item(monkeypatch, shop): + FakeOps(monkeypatch, errors=[None, CallError("ratio_as_additive", "比率不能按段相加,改用 ratio 模式,给分子、分母两列")]) + row = await run_graph(card_graph()) + assert row.status == "failed" + assert "分析项「where」(变化拆解)" in row.error and "比率不能按段相加" in row.error and "改正这一项" in row.error + + +async def test_a_branch_can_follow_the_verdict(monkeypatch, shop): + FakeOps(monkeypatch, verdicts=["outside_band", "driver"]) + graph = card_graph(extra_nodes=[ + node("route", "branch", cases=[{"key": "alert", "condition": "nodes.card.results.wow.verdict == 'outside_band'"}]), + node("alert", "output", fields=[{"name": "转人工", "value": "{{ nodes.card.results.wow.phrase }}"}])]) + graph["edges"] = [e for e in graph["edges"] if e["target"] != "out"] + [ + edge("card", "route"), edge("route", "alert", "alert"), edge("route", "out", "default")] + row = await run_graph(graph) + assert row.status == "succeeded", row.error + assert "转人工" in row.output + + +async def test_inputs_that_are_not_query_snapshots_fail_clearly(monkeypatch, shop): + FakeOps(monkeypatch) + other = node("calc", "tool", tool="json_query", args={"data": '{"a": 1}', "path": "a"}) + graph = card_graph(items=[{**WOW, "from": "calc"}], extra_nodes=[other], + extra_edges=[edge("by_region", "calc"), edge("calc", "card")]) + row = await run_graph(graph) + assert row.status == "failed" and "不是存成快照的查询结果" in row.error + + +async def test_masked_columns_reach_the_operator(monkeypatch, tmp_path): + source_id = await _shop(tmp_path, {"mask_columns": ["region"]}) + try: + ops = FakeOps(monkeypatch) + row = await run_graph(card_graph(items=[WHERE])) + assert row.status == "succeeded", row.error + assert "region" in ops.calls[0]["snapshots"]["by_region"]["masked"] + finally: + await engines.invalidate(source_id) + + +# -------------------------------------------------------------------------- +# 受管规则、登记处 +# -------------------------------------------------------------------------- + + +def _governed(graph): + from app.engine.governance import lint_for_publish + + return lint_for_publish(GraphSpec.model_validate(graph), level="governed").issues + + +def test_governed_cards_must_analyze_platform_queries(): + other = node("ext", "tool", tool="crm_report", args={}) + graph = card_graph(items=[{**WOW, "from": "ext"}], extra_nodes=[other], + extra_edges=[edge("by_region", "ext"), edge("ext", "card")]) + [issue] = [i for i in _governed(graph) if i.code == "governed.analysis_input"] + assert issue.level == "error" and issue.node_id == "card" and issue.field == "items[0].from" + assert [i for i in _governed(card_graph()) if i.code == "governed.analysis_input"] == [] + # 发布时:受管级按错误挡、画布那条同义的提醒不再列;已发布级只有画布那条提醒 + from app.engine.governance import publish_issues + + spec = GraphSpec.model_validate(graph) + governed = [(i.code, i.level) for i in publish_issues(spec, level="governed") if i.node_id == "card"] + assert ("governed.analysis_input", "error") in governed and ("analysis.input_invalid", "warning") not in governed + published = [(i.code, i.level) for i in publish_issues(spec, level="published") if i.node_id == "card"] + assert published == [("analysis.input_invalid", "warning")] + + +def test_a_caliber_card_reading_analysis_results_gets_a_hint(): + from app.engine.governance import _traceable + + graph = card_graph(extra_nodes=[node("kpi", "metrics", metrics=[ + {"id": "share", "expression": "nodes.card.results.where.values.top_share"}])], + extra_edges=[edge("card", "kpi")]) + [issue] = [i for i in _governed(graph) if i.code == "governed.caliber_analysis_input"] + assert issue.level == "warning" and issue.node_id == "card" and "追不到原始数据" in issue.message + assert _traceable(GraphSpec.model_validate(graph).node_map()["card"]) + + +def test_plain_wording_for_the_new_codes(): + from app.plain import plain_of + + for code in ("governed.analysis_input", "governed.caliber_analysis_input"): + assert plain_of(code) is not None, code + + +async def test_a_caliber_card_reading_an_analysis_card_is_untraced(monkeypatch, shop): + from app.engine.lineage import untraced + + FakeOps(monkeypatch) + graph = card_graph(extra_nodes=[node("kpi", "metrics", metrics=[ + {"id": "share", "expression": "float(nodes.card.results.where.values.top_share)"}])], + extra_edges=[edge("card", "kpi")]) + row = await run_graph(graph) + assert row.status == "succeeded", row.error + _, card = await output_of(row.id, "kpi") + [item] = card["metrics"][0]["inputs"] + assert item["via"] == "analysis" and item["node_id"] == "card" and untraced([item]) == [item] + + +def test_variables_list_each_item_and_count_the_inputs_as_references(): + from app.engine.variables import analyze + + report = analyze(GraphSpec.model_validate(card_graph(items=(WOW, WHERE)))) + found = {v.path: v for v in report.variables} + assert {"nodes.card.results.wow", "nodes.card.results.where"} <= set(found) + assert found["nodes.card.results.wow"].produced_by == "card" + assert any(r.node_id == "card" and r.field == "items[0].from" for r in found["nodes.weekly"].refs) + assert any(r.node_id == "card" and r.field == "items[1].params.headline.base" for r in found["nodes.weekly"].refs) + assert not [i for i in report.issues if i.level != "info"] + + +def test_describe_impact_and_upgrade_know_the_card(): + from types import SimpleNamespace + + from app.apps.describe import describe_version + from app.data.catalog_impact import template_refs + from app.engine.upgrade import _EVIDENCE_TYPES + + graph = card_graph(items=(WOW, WHERE)) + text = describe_version(SimpleNamespace(graph=graph, version=2, graph_hash="ab" * 32, level="published"), + workflow_name="区域周报") + assert "分析卡「card」(card),分析项 2 个" in text and "card.results.where 变化拆解" in text + refs = template_refs(GraphSpec.model_validate(graph), "shop", "orders", None) + [card] = [r for r in refs if r["node_id"] == "card"] + assert card["type"] == "analysis" and card["impact"] == "direct" + assert {(v["node_id"], v["alias"]) for v in card["via"]} == {("weekly", "wow"), ("by_region", "where"), + ("weekly", "where")} + assert NodeType.ANALYSIS in _EVIDENCE_TYPES From fc6d4bd92d87bf1fb5ebbe49f40f2c6d1dfe1bf3 Mon Sep 17 00:00:00 2001 From: ThearchyHelios Date: Wed, 21 Oct 2026 13:03:23 +0800 Subject: [PATCH 4/7] =?UTF-8?q?=E5=88=86=E6=9E=90=E8=83=BD=E5=8A=9B?= =?UTF-8?q?=E5=85=A5=E5=8F=A3=EF=BC=88=E5=9B=9B=EF=BC=89=EF=BC=9A=E5=8F=91?= =?UTF-8?q?=E8=B5=B7=E8=BF=90=E8=A1=8C=E6=97=B6=E6=8A=8A=E5=88=86=E6=9E=90?= =?UTF-8?q?=E8=AE=BE=E7=BD=AE=E5=BF=AB=E7=85=A7=E8=BF=9B=20run.options["an?= =?UTF-8?q?alysis"]=EF=BC=88engine/launch.py=20=E7=9A=84=20=5Fwith=5Fanaly?= =?UTF-8?q?sis=5Fsnapshot=EF=BC=8C=E7=85=A7=E6=A0=B8=E6=9F=A5=E8=AE=BE?= =?UTF-8?q?=E7=BD=AE=E7=9A=84=20=5Fwith=5Fverify=5Fsnapshot=EF=BC=89?= =?UTF-8?q?=EF=BC=9A=E5=8F=AA=E5=9C=A8=E5=9B=BE=EF=BC=88=E5=90=AB=E4=B8=80?= =?UTF-8?q?=E5=B1=82=E5=B1=82=E5=A5=97=E7=9D=80=E7=9A=84=E5=AD=90=E5=B7=A5?= =?UTF-8?q?=E4=BD=9C=E6=B5=81=EF=BC=8C=E5=90=8C=E4=B8=80=E4=B8=AA=E5=B7=A5?= =?UTF-8?q?=E4=BD=9C=E6=B5=81=E7=9A=84=E5=90=8C=E4=B8=80=E7=89=88=E5=8F=AA?= =?UTF-8?q?=E8=AF=BB=E4=B8=80=E6=AC=A1=EF=BC=89=E9=87=8C=E6=9C=89=E5=88=86?= =?UTF-8?q?=E6=9E=90=E5=8D=A1=E3=80=81=E6=88=96=20Agent=20/=20=E5=8D=8F?= =?UTF-8?q?=E4=BD=9C=E6=88=90=E5=91=98=EF=BC=88=E8=8A=82=E7=82=B9=E4=B8=8A?= =?UTF-8?q?=E6=B2=A1=E5=86=99=E5=B0=B1=E5=8F=96=E5=9B=BE=E7=BA=A7=20defaul?= =?UTF-8?q?ts=EF=BC=89=E7=BB=91=E4=BA=86=E4=BB=BB=E4=B8=80=E5=88=86?= =?UTF-8?q?=E6=9E=90=E5=B7=A5=E5=85=B7=E6=97=B6=E5=86=99=EF=BC=8C=E8=B0=83?= =?UTF-8?q?=E7=94=A8=E6=96=B9=E5=B7=B2=E7=BB=8F=E7=BB=99=E4=BA=86=E8=BF=99?= =?UTF-8?q?=E4=B8=80=E7=BB=84=E7=9A=84=E7=85=A7=E5=AE=83=E7=9A=84=EF=BC=9B?= =?UTF-8?q?=E5=88=AB=E7=9A=84=E5=9B=BE=20options=20=E5=8E=9F=E6=A0=B7?= =?UTF-8?q?=E4=B8=8D=E5=8A=A8=EF=BC=8Crun.started=20=E4=B8=80=E5=AD=97?= =?UTF-8?q?=E4=B8=8D=E5=8F=98=E3=80=82runner=20=E6=9C=AC=E6=9D=A5=E5=B0=B1?= =?UTF-8?q?=E6=8A=8A=E6=95=B4=E4=BB=BD=E8=BF=90=E8=A1=8C=E9=80=89=E9=A1=B9?= =?UTF-8?q?=E6=94=BE=E8=BF=9B=20RunContext.extra=EF=BC=8C=E5=88=86?= =?UTF-8?q?=E6=9E=90=E5=B7=A5=E5=85=B7=E3=80=81=E5=88=86=E6=9E=90=E5=8D=A1?= =?UTF-8?q?=E3=80=81=E5=8F=A3=E5=BE=84=E5=8D=A1=E7=9A=84=E5=8F=AF=E6=AF=94?= =?UTF-8?q?=E6=80=A7=E6=A3=80=E6=9F=A5=E9=83=BD=E5=8F=AA=E8=AF=BB=E8=BF=99?= =?UTF-8?q?=E4=B8=80=E4=BB=BD=EF=BC=8C=E6=B2=A1=E6=9C=89=E5=BF=AB=E7=85=A7?= =?UTF-8?q?=E7=9A=84=E7=8E=B0=E8=AF=BB=E8=AE=BE=E7=BD=AE=E3=80=82=E5=AD=90?= =?UTF-8?q?=E5=B7=A5=E4=BD=9C=E6=B5=81=E7=9A=84=E9=81=8D=E5=8E=86=E6=8A=BD?= =?UTF-8?q?=E6=88=90=20=5Fany=5Fgraph=EF=BC=8C=E6=A0=B8=E6=9F=A5=E8=AE=BE?= =?UTF-8?q?=E7=BD=AE=E7=9A=84=E5=88=A4=E6=96=AD=E7=85=A7=E6=97=A7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/app/engine/launch.py | 44 ++++++++++- backend/tests/test_analysis_settings.py | 98 ++++++++++++++++++++++++- 2 files changed, 137 insertions(+), 5 deletions(-) diff --git a/backend/app/engine/launch.py b/backend/app/engine/launch.py index 9bdbf3fa..4ba3897e 100644 --- a/backend/app/engine/launch.py +++ b/backend/app/engine/launch.py @@ -87,7 +87,8 @@ async def launch_run( 参数和 POST /api/runs 的请求体一一对应(input 叫 input_payload);actor 是记在运行上的发起人。workflow_name 是没挂在 工作流上的探索运行记在运行上的名字(蓝图的试运行写蓝图名),不给就是「未保存的工作流」;挂在工作流上的用工作流的名字。 schedule_id 是发起它的定时任务,options 是整次运行沿用的选项快照,都原样交给 run_manager.start;图里有双盲复算、 - 反方审稿节点时 options 另加核查设置的快照(_with_verify_snapshot)。 + 反方审稿节点时 options 另加核查设置的快照(_with_verify_snapshot),有分析卡、或 Agent / 协作成员绑了分析工具时另加 + 分析设置的快照(_with_analysis_snapshot)。 """ # 没挂在工作流上(画布没保存就发起、问数据当场搭的)。老数据里是「临时图」,前端两种都认 name = workflow_name or "未保存的工作流" @@ -182,6 +183,7 @@ async def launch_run( raise LaunchRefused(400, "没有要运行的内容:请求中既没有工作流 ID,也没有工作流内容") await _refuse_missing_tools(session, graph) options = await _with_verify_snapshot(session, graph, options) + options = await _with_analysis_snapshot(session, graph, options) options, capability_changes = await _with_capability_snapshot( session, graph, options, published=published_capabilities) @@ -311,7 +313,43 @@ async def _with_verify_snapshot(session: AsyncSession, graph: dict[str, Any], async def _has_verify_nodes(session: AsyncSession, spec: GraphSpec) -> bool: - """图里有没有双盲复算、反方审稿节点,子工作流里的也算。 + """图里有没有双盲复算、反方审稿节点,子工作流里的也算。""" + return await _any_graph(session, spec, lambda graph: any(node.type in _VERIFY_NODES for node in graph.nodes)) + + +async def _with_analysis_snapshot(session: AsyncSession, graph: dict[str, Any], + options: dict[str, Any] | None) -> dict[str, Any] | None: + """图里(含子工作流)有分析卡、或 Agent / 协作成员绑了分析工具时,把分析设置(api/settings.py 的 analysis 一组: + 判定用的阈值、次数上限)快照进 options["analysis"](docs/design/analysis-round/00-总方案.md §3.10)。 + + 道理同 _with_verify_snapshot:分析在运行中途读阈值,审批停下期间有人改了设置,恢复后的那一段就按另一套阈值判,正式 + 运行也说不清当时按什么规则判的「主要来自」。分析工具、分析卡只读这一份(app/tools/analysis.py 的 run_snapshot), + 没有快照的现读设置。别的图 options 原样不动,run.started 和以前逐字相同;调用方已经给了 options["analysis"] 的照它的。 + """ + from app.api.settings import ANALYSIS_GROUP, analysis_settings + + if isinstance(options, dict) and ANALYSIS_GROUP in options: + return options + try: + spec = GraphSpec.model_validate(graph) + except Exception: # noqa: BLE001 - 图本身不成形交给 run_manager.start 报 + return options + if not await _any_graph(session, spec, _analyzes): + return options + return {**(options or {}), ANALYSIS_GROUP: await analysis_settings(session)} + + +def _analyzes(graph: GraphSpec) -> bool: + """这一张图(不含子工作流)会用到分析设置:有分析卡,或者有绑了分析工具的 Agent、协作成员(tool_bindings 的取法, + 节点上没写就取图级 defaults)。""" + from app.tools.analysis import is_analysis_tool + + return any(node.type == NodeType.ANALYSIS for node in graph.nodes) \ + or any(is_analysis_tool(name) for _, name in tool_bindings(graph)) + + +async def _any_graph(session: AsyncSession, spec: GraphSpec, test: Any) -> bool: + """这张图或它(一层层)套着的子工作流里,有没有一张让 test(图) 为真。 子工作流照运行时的取法读(同 runner._bound_sources):固定了版本的读那一版,没固定的读工作流现在的图。读不到、 图不成形的跳过——那个节点运行时自己会报错。同一个工作流(同一版)只读一次,互相引用也不会转圈。 @@ -320,7 +358,7 @@ async def _has_verify_nodes(session: AsyncSession, spec: GraphSpec) -> bool: queue: list[GraphSpec] = [spec] while queue: graph = queue.pop(0) - if any(node.type in _VERIFY_NODES for node in graph.nodes): + if test(graph): return True for node in graph.nodes: if node.type != NodeType.SUBGRAPH: diff --git a/backend/tests/test_analysis_settings.py b/backend/tests/test_analysis_settings.py index 229dddd3..0b782c5a 100644 --- a/backend/tests/test_analysis_settings.py +++ b/backend/tests/test_analysis_settings.py @@ -1,20 +1,31 @@ -"""分析设置(设置 → 偏好设置 → 分析,docs/design/analysis-round/01-算子与判定.md §10)。 +"""分析设置(设置 → 偏好设置 → 分析,docs/design/analysis-round/01-算子与判定.md §10)和发起时的快照。 - 设置接口的 analysis 一组:键、缺省、范围、叫法全取 app/analysis/contract.py;GET 按生效的样子给(存坏了的项按缺省), PUT 先查后写(不认识的键、类型、范围、两两之间的大小) +- 发起运行时快照进 run.options["analysis"]:只在图(含子工作流)里有分析卡、或 Agent / 协作成员绑了分析工具时写; + 别的图的 run.started 一字不变 """ from __future__ import annotations +import asyncio + import pytest from httpx import ASGITransport, AsyncClient +from sqlalchemy import select from app.analysis import contract from app.api.settings import ANALYSIS_GROUP, analysis_problem, analysis_values from app.db.base import SessionLocal -from app.db.models import Setting +from app.db.models import Run, RunEvent, Setting, Workflow +from app.engine.launch import launch_run from app.engine.runner import run_manager from app.main import app +#: 加 options 之前 run.started 就有的键(同 tests/test_run_options.py) +LEGACY_STARTED_KEYS = {"nodes", "resumed", "memory_scope", "collection", "approval_default", "tool_trust", + "agent_limits", "data_versions", "replay_protocol"} + + @pytest.fixture(autouse=True) async def engine_up(): await run_manager.setup() @@ -38,6 +49,14 @@ async def clean_group(): await session.commit() +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:])]} + + # -------------------------------------------------------------------------- # 设置接口 # -------------------------------------------------------------------------- @@ -83,3 +102,78 @@ async def test_put_refuses_bad_values_and_stores_nothing(client): r = await client.put("/api/settings", json={"values": {ANALYSIS_GROUP: {"main_share": 0.6, "k_alert": 1.5}}}) assert r.status_code == 422 and contract.LABELS["k_alert"] in r.json()["detail"] assert (await client.get("/api/settings")).json()[ANALYSIS_GROUP] == contract.DEFAULTS + + +# -------------------------------------------------------------------------- +# 发起时的快照 +# -------------------------------------------------------------------------- + + +async def _started(run_id: str) -> dict: + for _ in range(200): + async with SessionLocal() as session: + found = (await session.execute(select(RunEvent).where( + RunEvent.run_id == run_id, RunEvent.type == "run.started"))).scalars().first() + if found is not None: + await run_manager.wait_idle(run_id) + return found.data + await asyncio.sleep(0.05) + raise AssertionError("没有 run.started") + + +async def _launch(graph, **kw) -> tuple[Run, dict]: + async with SessionLocal() as session: + run = await launch_run(session, graph=graph, actor="测试", **kw) + return run, await _started(run.id) + + +CARD = node("card", "analysis", items=[{"id": "wow", "op": "baseline", "from": "q", + "params": {"period": "week", "measure": "gmv"}}]) +QUERY = node("q", "tool", tool="calculator", args={"expression": "1 + 1"}) +OUT = node("out", "output", fields=[{"name": "r", "value": "完成"}]) + + +async def test_graphs_without_analysis_start_exactly_as_before(): + run, started = await _launch(chain(node("start", "input"), node("t", "transform", mode="template", template="x"), + OUT)) + assert set(started) == LEGACY_STARTED_KEYS, "没有分析的图,run.started 一个键都不多" + async with SessionLocal() as session: + assert (await session.get(Run, run.id)).options in (None, {}) + # 绑了别的工具的 Agent:有能力治理的快照(以前就有),没有分析这一组 + _, started = await _launch(chain(node("start", "input"), + node("bot", "agent", prompt="说一句", tools=["calculator"]), OUT)) + assert ANALYSIS_GROUP not in (started.get("options") or {}) + + +async def test_an_analysis_card_snapshots_the_settings(): + async with SessionLocal() as session: + session.add(Setting(key=ANALYSIS_GROUP, value={"main_share": 0.6})) + await session.commit() + run, started = await _launch(chain(node("start", "input"), QUERY, CARD, OUT)) + assert started["options"][ANALYSIS_GROUP] == {**contract.DEFAULTS, "main_share": 0.6} + async with SessionLocal() as session: + assert (await session.get(Run, run.id)).options[ANALYSIS_GROUP]["main_share"] == 0.6 + + +@pytest.mark.parametrize("graph", [ + chain(node("start", "input"), node("bot", "agent", prompt="为什么", tools=[contract.TOOL_NAMES["trend"]]), OUT), + chain(node("start", "input"), node("team", "supervisor", goal="为什么", agents=[ + {"name": "分析员", "tools": [contract.TOOL_NAMES["differ"]]}]), OUT), +]) +async def test_agents_and_members_with_analysis_tools_snapshot_the_settings(graph): + _, started = await _launch(graph) + assert started["options"][ANALYSIS_GROUP] == contract.DEFAULTS + + +async def test_a_card_inside_a_subgraph_counts_and_given_options_win(): + async with SessionLocal() as session: + inner = Workflow(name="异动分析(子流程)", graph=chain(node("start", "input"), QUERY, CARD, OUT)) + session.add(inner) + await session.commit() + inner_id = inner.id + outer = chain(node("start", "input"), node("sub", "subgraph", workflow_id=inner_id), OUT) + _, started = await _launch(outer) + assert started["options"][ANALYSIS_GROUP] == contract.DEFAULTS + # 调用方已经给了这一组的,照它的 + _, started = await _launch(outer, options={ANALYSIS_GROUP: {"max_calls": 2}}) + assert started["options"][ANALYSIS_GROUP] == {"max_calls": 2} From e079e0ba8828abe2872e7e7b53a0e51e94edbf84 Mon Sep 17 00:00:00 2001 From: ThearchyHelios Date: Wed, 21 Oct 2026 13:03:29 +0800 Subject: [PATCH 5/7] =?UTF-8?q?=E5=88=86=E6=9E=90=E8=83=BD=E5=8A=9B?= =?UTF-8?q?=E5=85=A5=E5=8F=A3=EF=BC=88=E4=BA=94=EF=BC=89=EF=BC=9A=E8=BF=90?= =?UTF-8?q?=E8=A1=8C=E5=AF=B9=E6=AF=94=E9=87=8C=E4=B8=A4=E6=AC=A1=E8=BF=90?= =?UTF-8?q?=E8=A1=8C=E7=9A=84=E5=88=86=E6=9E=90=E5=8D=A1=E6=8C=89=E8=8A=82?= =?UTF-8?q?=E7=82=B9=E5=92=8C=E9=A1=B9=E7=9A=84=E6=A0=87=E8=AF=86=E5=AF=B9?= =?UTF-8?q?=E9=BD=90=EF=BC=88engine/compare.py=20=E7=9A=84=20analyses?= =?UTF-8?q?=EF=BC=8C=E6=9C=80=E5=B0=8F=E5=8F=AF=E7=94=A8=EF=BC=89=EF=BC=9A?= =?UTF-8?q?=E6=8C=89=E5=B0=81=E5=AD=98=E8=8C=83=E5=9B=B4=E5=86=85=E6=AF=8F?= =?UTF-8?q?=E9=A1=B9=E4=B8=80=E6=9D=A1=E7=9A=84=20analysis.end=20=E8=AE=A4?= =?UTF-8?q?=EF=BC=8C=E5=BE=AA=E7=8E=AF=E9=87=8C=E6=89=A7=E8=A1=8C=E4=BA=86?= =?UTF-8?q?=E5=87=A0=E6=AC=A1=E7=9A=84=E5=8F=96=E6=9C=80=E5=90=8E=E4=B8=80?= =?UTF-8?q?=E6=AC=A1=EF=BC=88=E7=85=A7=E5=AE=9E=E5=86=99=E8=BF=9B=E8=AF=B4?= =?UTF-8?q?=E6=98=8E=EF=BC=89=EF=BC=9B=E6=AF=8F=E9=A1=B9=E5=88=97=E5=87=BA?= =?UTF-8?q?=E4=B8=A4=E6=AC=A1=E7=9A=84=E7=8A=B6=E6=80=81=E3=80=81=E5=88=A4?= =?UTF-8?q?=E5=AE=9A=E3=80=81=E6=96=B9=E5=90=91=E3=80=81=E5=88=A4=E5=AE=9A?= =?UTF-8?q?=E8=AF=AD=E5=92=8C=E7=BB=93=E8=AE=BA=E5=B7=A5=E4=BB=B6=EF=BC=8C?= =?UTF-8?q?change=20=E5=86=99=20same=20/=20verdict=EF=BC=88=E7=8A=B6?= =?UTF-8?q?=E6=80=81=E6=88=96=E5=88=A4=E5=AE=9A=E5=8F=98=E4=BA=86=EF=BC=89?= =?UTF-8?q?/=20phrase=EF=BC=88=E5=88=A4=E5=AE=9A=E4=B8=80=E6=A0=B7?= =?UTF-8?q?=E3=80=81=E5=88=A4=E5=AE=9A=E8=AF=AD=E9=87=8C=E7=9A=84=E6=95=B0?= =?UTF-8?q?=E5=8F=98=E4=BA=86=EF=BC=89/=20added=20/=20removed=EF=BC=8C?= =?UTF-8?q?=E4=B8=8D=E6=8B=86=E5=8F=98=E5=8C=96=E6=9D=A5=E6=BA=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/app/engine/compare.py | 58 +++++++++++++++++++++++++- backend/tests/test_analysis_compare.py | 42 +++++++++++++++++++ 2 files changed, 99 insertions(+), 1 deletion(-) create mode 100644 backend/tests/test_analysis_compare.py diff --git a/backend/app/engine/compare.py b/backend/app/engine/compare.py index 5481382e..6fb43657 100644 --- a/backend/app/engine/compare.py +++ b/backend/app/engine/compare.py @@ -233,6 +233,10 @@ def compare(base: Side, target: Side) -> dict[str, Any]: - queries:每对查询一项 {node_id, node_title, ordinal, structure, base_sql, target_sql, literals: [{base, target}](只列不同的字面量), base_rows, target_rows, base_truncated, target_truncated, base_artifact, target_artifact, differences: [结构上差在哪], problem?(快照读不出来、被改过)} + - analyses:分析卡每一项一条 {node_id, title, item, op, mode, base, target(各自 {status, verdict, direction, + phrase, artifact} 或 None), change}:两次运行的分析卡按节点和项的标识对齐(analysis.end 事件,循环里执行了几次的取 + 最后一次),change 是 same(判定和判定语都一样)/ verdict(状态或判定变了)/ phrase(判定一样、判定语里的数变了)/ + added / removed。只列出两次的判定,不拆变化来源 - notes:整体的说明:连线、默认设置、运行输入的变化,口径卡执行了几次、读不出来,遮罩 """ return _Compare(base, target).result() @@ -330,6 +334,38 @@ def _queries(side: Side) -> list[_Query]: return out +def _analysis_items(side: Side) -> dict[str, tuple[dict[str, dict[str, Any]], int]]: + """封存范围内每张分析卡各项最后一次的结论(analysis.end,engine/nodes/analysis.py 每项一条):{卡: ({项: 事件数据}, + 执行了几次)},项按记下的先后。循环里执行了几次的,同一项后一次的盖过前一次。""" + out: dict[str, tuple[dict[str, dict[str, Any]], int]] = {} + for _, etype, node_id, data in side.events: + if not node_id or not isinstance(data, dict): + continue + if etype == EventType.ANALYSIS_END and isinstance(data.get("item"), str): + items, runs = out.setdefault(node_id, ({}, 0)) + items[data["item"]] = data + elif etype == EventType.NODE_FINISHED and node_id in out: + items, runs = out[node_id] + out[node_id] = (items, runs + 1) + return out + + +def _analysis_side(data: dict[str, Any] | None) -> dict[str, Any] | None: + if data is None: + return None + return {"status": data.get("status"), "verdict": data.get("verdict"), "direction": data.get("direction"), + "phrase": data.get("phrase") or "", "artifact": data.get("analysis_artifact")} + + +def _analysis_change(base: dict[str, Any] | None, target: dict[str, Any] | None) -> str: + if base is None or target is None: + return ADDED if base is None else REMOVED + if (base.get("status"), base.get("verdict"), base.get("direction")) != \ + (target.get("status"), target.get("verdict"), target.get("direction")): + return "verdict" + return "same" if (base.get("phrase") or "") == (target.get("phrase") or "") else "phrase" + + def _outputs(side: Side) -> dict[str, tuple[Any, ...]]: """每个节点在封存范围内交出的产出工件(node.finished 的 artifact),按执行的先后。工件按内容寻址:两次运行的同一个 节点交出的一模一样,工件 id 就一样。产出没落成工件的记 None,核对不了。""" @@ -803,8 +839,9 @@ class _Compare: summary = {name: 0 for name in CLASSES} for m in metrics: summary[m["classification"]] += 1 + analyses = self._analyses() return {"target": self.target.run, "base": self.base.run, "summary": summary, "template": self.template, - "metrics": metrics, "queries": queries, "notes": list(dict.fromkeys(self.notes))} + "metrics": metrics, "analyses": analyses, "queries": queries, "notes": list(dict.fromkeys(self.notes))} def _title_of(self, node: dict[str, Any]) -> str: return str(_data(node).get("label") or node.get("id")) @@ -1395,6 +1432,25 @@ class _Compare: return {"base_version": self.base.run.get("version"), "target_version": self.target.run.get("version"), "same_graph": not nodes and same_edges and same_defaults, "nodes": nodes}, changed, inherited + # ---------------------------------------------------------------- 分析卡 + + def _analyses(self) -> list[dict[str, Any]]: + """两次运行的分析卡按节点、项的标识对齐(最小可用:列出两次的判定和判定语,不拆变化来源)。""" + before, after = _analysis_items(self.base), _analysis_items(self.target) + out: list[dict[str, Any]] = [] + for node_id in [*after, *(n for n in before if n not in after)]: + b, t = before.get(node_id, ({}, 0)), after.get(node_id, ({}, 0)) + for side, (_, runs) in (("基准运行", b), ("这次运行", t)): + if runs > 1: + self.notes.append(f"分析卡「{self.title(node_id)}」在{side}中执行了 {runs} 次,按最后一次对比") + for item in [*t[0], *(i for i in b[0] if i not in t[0])]: + bi, ti = b[0].get(item), t[0].get(item) + meta = ti or bi or {} + out.append({"node_id": node_id, "title": self.title(node_id), "item": item, "op": meta.get("op"), + "mode": meta.get("mode"), "base": _analysis_side(bi), "target": _analysis_side(ti), + "change": _analysis_change(bi, ti)}) + return out + # ---------------------------------------------------------------- 指标 def _metrics(self) -> list[dict[str, Any]]: diff --git a/backend/tests/test_analysis_compare.py b/backend/tests/test_analysis_compare.py new file mode 100644 index 00000000..33da6a32 --- /dev/null +++ b/backend/tests/test_analysis_compare.py @@ -0,0 +1,42 @@ +"""运行对比里的分析卡(engine/compare.py 的 analyses):两次运行的分析卡按节点和项的标识对齐,列出两次的判定和判定语 +(最小可用:不拆变化来源)。运行照 tests/fixtures/compare_runs.py 的虚构周报手写,另加分析卡每项一条 analysis.end。 +""" +from __future__ import annotations + +from app.engine import compare +from tests.fixtures.compare_runs import week + + +def _with_card(run, items, *, runs=1): + for _ in range(runs): + run.add("node.started", "insight", {"node_type": "analysis"}) + for item, verdict, phrase in items: + run.add("analysis.end", "insight", {"item": item, "op": "baseline", "mode": None, "status": "ok", + "verdict": verdict, "direction": "down", "phrase": phrase, + "analysis_artifact": f"{item}-{verdict}-{phrase}"}) + run.add("node.finished", "insight", {"duration_ms": 1, "attempt": 1}) + return run + + +def test_compare_aligns_analysis_items(): + base = _with_card(week("r1"), [("wow", "within_band", "在常规波动范围内"), ("trend", "rising", "呈上升趋势"), + ("gone", "no_trend", "没有明显趋势")]) + target = _with_card(week("r2"), [("wow", "outside_band", "超出常规波动范围"), ("trend", "rising", "呈上升趋势"), + ("new", "rising", "呈上升趋势")], runs=2) + result = compare.compare(base.side(), target.side()) + found = {a["item"]: a for a in result["analyses"]} + assert [a["item"] for a in result["analyses"]] == ["wow", "trend", "new", "gone"] + assert found["wow"]["change"] == "verdict" and found["wow"]["base"]["verdict"] == "within_band" + assert found["wow"]["target"] == {"status": "ok", "verdict": "outside_band", "direction": "down", + "phrase": "超出常规波动范围", "artifact": "wow-outside_band-超出常规波动范围"} + assert found["trend"]["change"] == "same" and found["new"]["change"] == "added" and found["new"]["base"] is None + assert found["gone"]["change"] == "removed" and found["gone"]["target"] is None + assert found["wow"]["title"] == "insight" and found["wow"]["op"] == "baseline" + assert "分析卡「insight」在这次运行中执行了 2 次,按最后一次对比" in result["notes"] + # 判定一样、判定语里的数变了 + target = _with_card(week("r3"), [("wow", "within_band", "在常规波动范围内(-3%)")]) + [wow] = compare.compare(_with_card(week("r4"), [("wow", "within_band", "在常规波动范围内")]).side(), + target.side())["analyses"] + assert wow["change"] == "phrase" + # 没有分析卡的两次运行:analyses 是空的 + assert compare.compare(week("r5").side(), week("r6").side())["analyses"] == [] From 6a96083dcb03b299b383c72e25492a30be535330 Mon Sep 17 00:00:00 2001 From: ThearchyHelios Date: Wed, 21 Oct 2026 13:03:38 +0800 Subject: [PATCH 6/7] =?UTF-8?q?=E5=88=86=E6=9E=90=E8=83=BD=E5=8A=9B?= =?UTF-8?q?=E5=85=A5=E5=8F=A3=EF=BC=88=E5=85=AD=EF=BC=89=EF=BC=9A=E5=8F=A3?= =?UTF-8?q?=E5=BE=84=E5=8D=A1=E7=9A=84=E5=8F=AF=E6=AF=94=E6=80=A7=E6=8C=82?= =?UTF-8?q?=E9=92=A9=EF=BC=88engine/nodes/metrics.py=20=E7=9A=84=20=5Fcomp?= =?UTF-8?q?arability=EF=BC=89=EF=BC=9A=E4=B8=80=E4=B8=AA=E6=8C=87=E6=A0=87?= =?UTF-8?q?=E7=9A=84=E8=BE=93=E5=85=A5=E9=87=8C=E5=8F=96=E8=87=AA=E6=9F=A5?= =?UTF-8?q?=E8=AF=A2=E5=BF=AB=E7=85=A7=E7=9A=84=E6=A0=BC=EF=BC=88cell()=20?= =?UTF-8?q?=E5=8F=96=E7=9A=84=E6=A0=BC=E3=80=81=E6=8C=89=E4=BD=8D=E7=BD=AE?= =?UTF-8?q?=E5=8F=96=E7=9A=84=E6=A0=BC=E3=80=81Agent=20=E6=8C=89=E5=87=BA?= =?UTF-8?q?=E5=A4=84=E4=BA=A4=E6=9D=A5=E7=9A=84=E5=AD=97=E6=AE=B5=EF=BC=89?= =?UTF-8?q?=E6=81=B0=E5=A5=BD=E8=90=BD=E5=9C=A8=E4=B8=A4=E4=BB=BD=E4=B8=8D?= =?UTF-8?q?=E5=90=8C=E7=9A=84=E5=BF=AB=E7=85=A7=E4=B8=8A=EF=BC=8C=E4=B8=A4?= =?UTF-8?q?=E4=BB=BD=20SQL=20=E6=8C=89=E8=A7=84=E8=8C=83=E5=8C=96=EF=BC=88?= =?UTF-8?q?data/sql=5Fsemantics.normalize=5Fsql=EF=BC=8C=E6=8C=89=E6=95=B0?= =?UTF-8?q?=E6=8D=AE=E6=BA=90=E7=9A=84=E6=96=B9=E8=A8=80=EF=BC=89=E5=90=8C?= =?UTF-8?q?=E5=BD=A2=E3=80=81=E5=8F=AA=E5=B7=AE=E5=AD=97=E9=9D=A2=E9=87=8F?= =?UTF-8?q?=E3=80=81=E6=9F=A5=E7=9A=84=E6=98=AF=E5=90=8C=E4=B8=80=E4=B8=AA?= =?UTF-8?q?=E6=95=B0=E6=8D=AE=E6=BA=90=E6=97=B6=EF=BC=88=E6=9C=AC=E5=91=A8?= =?UTF-8?q?=E5=AF=B9=E4=B8=8A=E5=91=A8=EF=BC=89=EF=BC=8C=E5=8F=A3=E5=BE=84?= =?UTF-8?q?=E5=8D=A1=E6=89=A7=E8=A1=8C=E6=97=B6=E8=B0=83=E5=88=86=E6=9E=90?= =?UTF-8?q?=E7=AE=97=E5=AD=90=20comparable=EF=BC=88=E6=97=A9=E7=9A=84?= =?UTF-8?q?=E9=82=A3=E6=9C=9F=E4=BD=9C=E5=9F=BA=E6=9C=9F=EF=BC=9A=E7=AC=AC?= =?UTF-8?q?=E4=B8=80=E5=A4=84=E4=B8=8D=E5=90=8C=E7=9A=84=E5=AD=97=E9=9D=A2?= =?UTF-8?q?=E9=87=8F=E6=98=AF=E6=97=A5=E6=9C=9F=E6=88=96=E5=90=8C=E6=A0=B7?= =?UTF-8?q?=E5=86=99=E6=B3=95=E7=9A=84=E6=9C=9F=E6=AC=A1=E7=9A=84=E6=8C=89?= =?UTF-8?q?=E5=85=88=E5=90=8E=EF=BC=8C=E8=AE=A4=E4=B8=8D=E5=87=BA=E7=9A=84?= =?UTF-8?q?=E8=A1=A8=E8=BE=BE=E5=BC=8F=E9=87=8C=E5=85=88=E5=87=BA=E7=8E=B0?= =?UTF-8?q?=E7=9A=84=E9=82=A3=E4=BB=BD=E7=AE=97=E6=9C=AC=E6=9C=9F=EF=BC=89?= =?UTF-8?q?=EF=BC=8C=E7=BB=93=E8=AE=BA=E8=90=BD=E6=88=90=E5=B7=A5=E4=BB=B6?= =?UTF-8?q?=EF=BC=8C=E6=8C=87=E6=A0=87=E4=B8=8A=E8=AE=B0=20comparability?= =?UTF-8?q?=EF=BC=88contract.MetricComparability=EF=BC=9A=E5=B7=A5?= =?UTF-8?q?=E4=BB=B6=E3=80=81=E5=88=A4=E5=AE=9A=E3=80=81=E5=88=A4=E5=AE=9A?= =?UTF-8?q?=E8=AF=AD=E3=80=81fail=20/=20warn=20=E7=9A=84=E5=8E=9F=E5=9B=A0?= =?UTF-8?q?=EF=BC=8C=E5=8E=9F=E5=9B=A0=E5=85=88=E6=8C=89=E7=BB=93=E8=AE=BA?= =?UTF-8?q?=E8=A1=A8=E7=9A=84=20status=20=E4=B8=80=E5=88=97=E5=8F=96?= =?UTF-8?q?=EF=BC=8C=E6=B2=A1=E6=9C=89=E5=86=8D=E6=8C=89=E5=88=A4=E5=AE=9A?= =?UTF-8?q?=E8=AF=AD=E6=8B=86=EF=BC=89=E3=80=82SQL=20=E7=BB=93=E6=9E=84?= =?UTF-8?q?=E4=B8=8D=E5=90=8C=EF=BC=88=E9=80=80=E6=AC=BE=E9=A2=9D=20/=20?= =?UTF-8?q?=E9=94=80=E5=94=AE=E9=A2=9D=EF=BC=89=E3=80=81=E4=B8=A4=E6=AC=A1?= =?UTF-8?q?=E6=98=AF=E5=90=8C=E4=B8=80=E6=9D=A1=20SQL=E3=80=81=E6=95=B0?= =?UTF-8?q?=E6=8D=AE=E6=BA=90=E4=B8=8D=E5=90=8C=E7=9A=84=E4=B8=8D=E6=98=AF?= =?UTF-8?q?=E5=90=8C=E4=B8=80=E4=B8=AA=E9=87=8F=E7=9A=84=E4=B8=A4=E6=9C=9F?= =?UTF-8?q?=EF=BC=8C=E4=B8=8D=E6=9F=A5=E4=B9=9F=E4=B8=8D=E8=AE=B0=E6=97=A5?= =?UTF-8?q?=E5=BF=97=EF=BC=9B=E5=90=8C=E4=B8=80=E5=AF=B9=E5=BF=AB=E7=85=A7?= =?UTF-8?q?=E4=B8=80=E5=BC=A0=E5=8D=A1=E9=87=8C=E5=8F=AA=E6=9F=A5=E4=B8=80?= =?UTF-8?q?=E6=AC=A1=E3=80=82=E5=BF=AB=E7=85=A7=E8=AF=BB=E4=B8=8D=E5=87=BA?= =?UTF-8?q?=E6=9D=A5=E3=80=81=E6=8E=A2=E6=9F=A5=E5=A4=B1=E8=B4=A5=E3=80=81?= =?UTF-8?q?=E7=AE=97=E5=AD=90=E5=87=BA=E9=94=99=E3=80=81=E7=AE=97=E4=B8=8D?= =?UTF-8?q?=E4=BA=86=E3=80=81=E8=BF=98=E6=B2=A1=E6=9C=89=E7=AE=97=E5=AD=90?= =?UTF-8?q?=EF=BC=88=E7=AC=AC=201=20=E6=B3=A2=E7=9A=84=20run=20=E6=8A=9B?= =?UTF-8?q?=20NotImplementedError=EF=BC=89=E9=83=BD=E4=B8=8D=E6=8C=A1?= =?UTF-8?q?=E5=8F=A3=E5=BE=84=E5=8D=A1=EF=BC=8C=E7=85=A7=E5=AE=9E=E8=AE=B0?= =?UTF-8?q?=E4=B8=80=E6=9D=A1=20metric=5Fcomparability=5Fskipped=EF=BC=8C?= =?UTF-8?q?=E8=BF=99=E4=B8=AA=E6=8C=87=E6=A0=87=E4=B8=8D=E5=86=99=20compar?= =?UTF-8?q?ability?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/app/engine/nodes/metrics.py | 181 +++++++++++++++++++++ backend/tests/test_metric_comparability.py | 168 +++++++++++++++++++ 2 files changed, 349 insertions(+) create mode 100644 backend/tests/test_metric_comparability.py diff --git a/backend/app/engine/nodes/metrics.py b/backend/app/engine/nodes/metrics.py index d040fa29..7e90092e 100644 --- a/backend/app/engine/nodes/metrics.py +++ b/backend/app/engine/nodes/metrics.py @@ -55,6 +55,9 @@ async def run_metrics(state: GraphState, ctx: NodeContext) -> dict[str, Any]: 取数的那条 SQL 对照数据目录查出 error 级问题的(查询快照的 checks,见 data/sqlcheck.py:一对多关联后重复计算、 存量跨期加总),同样值照算,标 sql_check_failed 并写明原因,出具契约据此降档(issuance.sql_check_gaps)。 + + 一个指标的输入落在两份不同的查询快照上、两份 SQL 同形只差字面量(本周对上周)时,自动做一次可比性检查(分析算子 + comparable),结论落成工件、记在指标的 comparability 上(_comparability)。做不成不挡口径卡,照实记一条日志。 """ definitions = ctx.cfg("metrics", []) or [] caliber_raw, caliber_version = ctx.cfg("caliber", "") or ctx.node.title, str(ctx.cfg("caliber_version", "") or "v1") @@ -84,6 +87,8 @@ async def run_metrics(state: GraphState, ctx: NodeContext) -> dict[str, Any]: failed_by: dict[str, list[QueryProblem]] = {} #: 查询快照工件 id → 快照本身(读输入的格落在哪一列「查询区间里这张表没有数据」,_no_data_inputs) snapshots: dict[str, Any] = {} + #: 可比性检查:同一对查询快照只查一次(几个指标都用本周、上周两份快照时),和做检查要用的几样(_Comparing) + comparing = _Comparing() for definition in definitions: metric_id = str(definition.get("id") or "").strip() expr = str(definition.get("expression") or "").strip() @@ -167,6 +172,8 @@ async def run_metrics(state: GraphState, ctx: NodeContext) -> dict[str, Any]: no_data: dict[str, Any] = {} if lacking := _no_data_inputs(inputs, carried, snapshots): no_data = {"no_data": True, "no_data_reason": _no_data_reason(lacking), "no_data_sources": lacking} + comparability = await _comparability(metric_id, str(definition.get("name") or metric_id), inputs, ctx, + comparing, snapshots) metrics.append( { "id": metric_id, @@ -192,6 +199,8 @@ async def run_metrics(state: GraphState, ctx: NodeContext) -> dict[str, Any]: **sql_check, # 只在输入取自「查询区间里这张表没有数据」的列时出现,同上 **no_data, + # 只在输入落在两份同形、只差字面量的查询快照上、可比性检查做成了时出现(contract.MetricComparability) + **({"comparability": comparability} if comparability else {}), } ) @@ -462,6 +471,178 @@ def _rendered(value: Any, definition: dict[str, Any], display: dict[str, Any]) - return MISSING +# -------------------------------------------------------------------------- +# 可比性检查(docs/design/analysis-round/00-总方案.md §3.4 第 3 条) +# -------------------------------------------------------------------------- + +#: 可比性检查没做成(探查失败、算子出错、还没有算子……)时那条日志的 code +COMPARABILITY_SKIPPED = "metric_comparability_skipped" +#: 那条日志的说法 +COMPARABILITY_SKIPPED_TEXT = "指标「{name}」用到的两次查询没做成可比性检查:{why}。指标照常计算,两期能不能直接比请自行核对" +_DAY = re.compile(r"^(\d{4})-?(\d{2})-?(\d{2})(?:[ T].*)?$") + + +class _Comparing: + """一张口径卡执行时做可比性检查记下的:同一对快照的结论(没做成的记 None,不再试)、数据源的类型(规范化 SQL 按 + 它的方言解析)。阈值、运行期依赖到真要检查时才读。""" + + def __init__(self) -> None: + self.done: dict[tuple[str, str], dict[str, Any] | None] = {} + self.kinds: dict[str, str | None] = {} + + +async def _comparability(metric_id: str, name: str, inputs: list[dict[str, Any]], ctx: NodeContext, + comparing: _Comparing, snapshots: dict[str, Any]) -> dict[str, Any] | None: + """指标的可比性(contract.MetricComparability:结论工件、判定、判定语、原因)。只在这种时候做:输入里取自查询快照的格 + (cell() 取的格、按位置取的格、Agent 按出处交来的字段)恰好落在两份不同的快照上,两份 SQL 同形、只差字面量,而且查的是 + 同一个数据源——本周对上周。SQL 结构不同的(退款额 / 销售额)不是同一个量的两期,不查。 + + 哪份是基期:两边第一处不同的字面量是日期(或同样写法的期次)的,早的是基期;认不出的,表达式里先出现的那份算本期 + (「(本周 - 上周) / 上周」的写法)。 + + 做不成(快照读不出来、探查失败、算子出错、算不了、还没有算子)不挡口径卡:照实记一条日志(COMPARABILITY_SKIPPED), + 这个指标不写 comparability。同一对快照只查一次。""" + from app.engine.lineage import SNAPSHOT_VIAS + + artifacts = list(dict.fromkeys(str(i["artifact"]) for i in inputs if i.get("via") in SNAPSHOT_VIAS + and isinstance(i.get("artifact"), str) and i["artifact"])) + if len(artifacts) != 2: + return None + pair = (artifacts[0], artifacts[1]) + key = (min(pair), max(pair)) # 本周减上周、上周减本周用的是同一对快照 + if key in comparing.done: + return comparing.done[key] + found: dict[str, Any] | None = None + try: + found = await _check_comparable(pair, inputs, ctx, comparing, snapshots) + except _NotPeriods: + found = None + except Exception as e: # noqa: BLE001 - 检查是附加的,不能挡住口径卡 + why = e.message if isinstance(e, _Skipped) else describe_exception(e) + ctx.emit(EventType.LOG, level="warn", code=COMPARABILITY_SKIPPED, metric=metric_id, metric_name=name, + reason=why, message=COMPARABILITY_SKIPPED_TEXT.format(name=name, why=why)) + comparing.done[key] = found + return found + + +class _NotPeriods(Exception): + """两份快照不是同一个量的两期(SQL 结构不同、数据源不同、字面量一样、没有 SQL):不查,也不记日志。""" + + +class _Skipped(Exception): + """该查、却做不成(结论算不了、判定认不出):记一条日志,message 是给人看的原因。""" + + def __init__(self, message: str) -> None: + super().__init__(message) + self.message = message + + +async def _check_comparable(pair: tuple[str, str], inputs: list[dict[str, Any]], ctx: NodeContext, + comparing: _Comparing, snapshots: dict[str, Any]) -> dict[str, Any]: + import app.analysis as analysis_ops # 按模块取 run:测试、合并前的替身都只换模块上的这一个函数 + + from app.analysis.contract import VERDICTS + from app.data.sql_semantics import normalize_sql + from app.tools.analysis import ( + context_of, input_snapshot, masked_of, run_snapshot, settings_of, store, tool_context, + ) + + contents = [] + for artifact in pair: + if artifact not in snapshots: + try: + snapshots[artifact] = artifact_store.load(artifact) + except (ValueError, OSError): + snapshots[artifact] = None + if not isinstance(snapshots[artifact], dict): + raise _Skipped("查询快照读不出来") + contents.append(snapshots[artifact]) + sqls = [c.get("sql") for c in contents] + sources = [str(c.get("source") or "") for c in contents] + if not all(isinstance(q, str) and q.strip() for q in sqls) or sources[0] != sources[1]: + raise _NotPeriods() + dialect = await _dialect_of(sources[0], comparing) + norms = [normalize_sql(q, dialect) for q in sqls] + if not norms[0].same_shape(norms[1]) or norms[0].literals == norms[1].literals: + raise _NotPeriods() + first, second = _period_order(norms[0].literals, norms[1].literals) + order = (pair[first], pair[second]) # (基期, 本期) + refs: dict[str, str] = {} + for role, artifact in zip(("base", "target"), order): + producer = next((str(i.get("node_id") or "") for i in inputs if i.get("artifact") == artifact), "") + refs[artifact] = producer if producer and producer not in refs.values() else f"{producer or 'query'}·{role}" + snaps = {} + for artifact in order: + content = snapshots[artifact] + snap = input_snapshot(content, ref=refs[artifact], artifact=artifact, node_id=refs[artifact].split("·")[0]) + snap["masked"] = await masked_of(content, artifact) + snaps[refs[artifact]] = snap + settings = await settings_of(run_snapshot(ctx.run)) + context = await context_of(list(snaps.values()), tool_ctx=tool_context(ctx.run, ctx.node.id, ctx.emit)) + request = {"op": "comparable", "mode": None, + "params": {"base_query": refs[order[0]], "target_query": refs[order[1]]}} + result = await analysis_ops.run(request, snaps.get, settings=settings, context=context) + verdict = (result.get("verdict") or {}).get("code") + if result.get("status") != "ok" or verdict not in VERDICTS["comparable"]: + raise _Skipped(str(result.get("reason") or "检查没有给出判定")) + artifact = await store(result, run_id=ctx.run.run_id, node_id=ctx.node.id) + return {"artifact": artifact, "verdict": verdict, "phrase": str(result.get("phrase") or ""), + "reasons": comparability_reasons(result)} + + +async def _dialect_of(source: str, comparing: _Comparing) -> str | None: + """数据源的类型(规范化 SQL 按它的方言解析);合并查询在 SQLite 里执行。认不出为 None(通用读法)。""" + from app.engine.merge_query import MERGE_SOURCE + + if source == MERGE_SOURCE: + return "sqlite" + if source not in comparing.kinds: + from sqlalchemy import select + + from app.db.base import SessionLocal + from app.db.models import DataSource + + async with SessionLocal() as session: + comparing.kinds[source] = (await session.execute( + select(DataSource.kind).where(DataSource.name == source))).scalar_one_or_none() + return comparing.kinds[source] + + +def _period_order(a: tuple[str, ...], b: tuple[str, ...]) -> tuple[int, int]: + """两份同形 SQL 的字面量 → (基期的下标, 本期的下标)。第一处不同、两边都认得出的日期(YYYY-MM-DD、YYYYMMDD,带不带 + 时间)或同样长短的期次写法(2026-W41、202610)定先后,早的是基期;都认不出的,表达式里先出现的那份(下标 0)是本期。""" + for x, y in zip(a, b): + if x == y: + continue + left, right = x.strip("'\""), y.strip("'\"") + dx, dy = _DAY.match(left), _DAY.match(right) + if dx and dy: + if dx.groups() != dy.groups(): + return (0, 1) if dx.groups() < dy.groups() else (1, 0) + continue + if len(left) == len(right) and re.fullmatch(r"[\dW\-/Qq]+", left) and re.fullmatch(r"[\dW\-/Qq]+", right): + return (0, 1) if left < right else (1, 0) + return 1, 0 + + +def comparability_reasons(result: dict[str, Any]) -> list[str]: + """可比性结论里 fail、warn 的原因,一句一个(contract.MetricComparability.reasons)。结果表里有 status 一列和一列说明 + (reason / text / detail / message)的照表取;没有的按判定语「两期不可比:原因;原因」拆。可比的没有原因。""" + table = result.get("table") or {} + columns = [str(c) for c in table.get("columns") or []] + text_col = next((c for c in ("reason", "text", "detail", "message") if c in columns), None) + if "status" in columns and text_col is not None: + si, ti = columns.index("status"), columns.index(text_col) + found = [str(row[ti]) for row in table.get("rows") or [] + if isinstance(row, list) and len(row) > max(si, ti) and row[si] in ("fail", "warn") and row[ti]] + if found: + return found + phrase = str(result.get("phrase") or "") + if (result.get("verdict") or {}).get("code") == "comparable" or ":" not in phrase: + return [] + return [part.strip() for part in phrase.split(":", 1)[1].split(";") if part.strip()] + + #: 清单里输入取自「查询区间里这张表没有数据」的列的指标后面那句:直接读清单的模型节点不能把 0 当成真的是 0 LISTED_NO_DATA = "(这段时间表里没有数据:不要当成 0 解读)" #: 指标的 no_data_reason 打头那半句(后面接查询记下的说明) diff --git a/backend/tests/test_metric_comparability.py b/backend/tests/test_metric_comparability.py new file mode 100644 index 00000000..ad33d65b --- /dev/null +++ b/backend/tests/test_metric_comparability.py @@ -0,0 +1,168 @@ +"""口径卡的可比性挂钩(docs/design/analysis-round/00-总方案.md §3.4 第 3 条):一个指标的输入落在两份不同的查询快照上、 +两份 SQL 同形只差字面量(本周对上周)时,口径卡执行时自动做一次可比性检查,结论落成工件、记在指标的 comparability 上。 + +- 同形只差日期 → 有 comparability,早的那期是基期(和表达式里谁先写无关);同一对快照只查一次 +- SQL 结构不同(退款额 / 销售额)、两次是同一条 SQL → 不查、不记日志 +- 算子出错、算不了、还没有算子(第 1 波的 run 抛 NotImplementedError)→ 不挡口径卡,记一条 metric_comparability_skipped +""" +from __future__ import annotations + +import asyncio +import sqlite3 + +import pytest +from sqlalchemy import select + +from app.analysis.contract import CallError +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.nodes.metrics import COMPARABILITY_SKIPPED, comparability_reasons +from app.engine.runner import run_manager +from tests.fixtures.analysis_ops import FakeOps + +TOOL = "db_query__shop" +THIS = "SELECT SUM(amount) AS gmv FROM orders WHERE day BETWEEN '2026-10-12' AND '2026-10-18'" +LAST = "SELECT SUM(amount) AS gmv FROM orders WHERE day BETWEEN '2026-10-05' AND '2026-10-11'" +REFUNDS = "SELECT SUM(refund) AS gmv FROM orders WHERE refund > 0 AND day BETWEEN '2026-10-12' AND '2026-10-18'" +WOW = "(cell(nodes.this_week, 0, 'gmv') - cell(nodes.last_week, 0, 'gmv')) / cell(nodes.last_week, 0, 'gmv')" + + +@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, day TEXT, region TEXT, amount REAL, refund REAL);") + db.executemany("INSERT INTO orders VALUES (?, ?, ?, ?, ?)", [ + (1, "2026-10-06", "华东", 500.0, 0.0), (2, "2026-10-08", "华南", 400.0, 20.0), + (3, "2026-10-13", "华东", 450.0, 0.0), (4, "2026-10-15", "华北", 300.0, 15.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 graph(metrics, *, this=THIS, last=LAST): + nodes = [node("start", "input"), + node("this_week", "tool", tool=TOOL, args={"sql": this}), + node("last_week", "tool", tool=TOOL, args={"sql": last}), + node("card", "metrics", caliber="周报口径", metrics=metrics), + node("out", "output", fields=[{"name": "r", "value": "{{ nodes.card.text }}"}])] + return {"nodes": nodes, "edges": [{"source": a["id"], "target": b["id"]} for a, b in zip(nodes, nodes[1:])]} + + +async def run_card(g) -> tuple[Run, dict, list[dict]]: + run = await run_manager.start(graph=g, 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 + async with SessionLocal() as session: + evts = [(e.type, e.node_id, e.data) for e in (await session.execute( + select(RunEvent).where(RunEvent.run_id == run.id).order_by(RunEvent.seq))).scalars()] + [finished] = [d for t, n, d in evts if t == "node.finished" and n == "card"] + card = artifact_store.load(finished["artifact"]) + logs = [d for t, n, d in evts if t == "log" and n == "card"] + return row, card, logs + + +def by_id(card): + return {m["id"]: m for m in card["metrics"]} + + +async def test_two_periods_of_the_same_query_get_a_comparability_check(monkeypatch): + ops = FakeOps(monkeypatch) + _, card, logs = await run_card(graph([ + {"id": "wow", "name": "销售额环比", "expression": WOW, "decimals": 4}, + {"id": "diff", "name": "销售额变化", "expression": "cell(nodes.last_week, 0, 'gmv') - cell(nodes.this_week, 0, 'gmv')"}, + {"id": "this", "name": "本周销售额", "expression": "cell(nodes.this_week, 0, 'gmv')"}])) + metrics = by_id(card) + [call] = ops.calls # 同一对快照只查一次 + # 早的那期是基期:表达式里先写的是本周还是上周都一样 + assert call["request"] == {"op": "comparable", "mode": None, + "params": {"base_query": "last_week", "target_query": "this_week"}} + base, target = call["snapshots"]["last_week"], call["snapshots"]["this_week"] + assert base["sql"] == LAST and target["sql"] == THIS and base["node_id"] == "last_week" + assert base["artifact"] == metrics["wow"]["inputs"][1]["artifact"] and base["masked"] == [] + found = metrics["wow"]["comparability"] + assert found == metrics["diff"]["comparability"] + stored = artifact_store.load(found["artifact"]) + assert stored["op"] == "comparable" and found == {"artifact": found["artifact"], "verdict": "comparable", + "phrase": "两期可比", "reasons": []} + # 只用到一份快照的指标不查 + assert "comparability" not in metrics["this"] + assert not [log for log in logs if log.get("code") == COMPARABILITY_SKIPPED] + + +async def test_an_incomparable_pair_lists_its_reasons(monkeypatch): + FakeOps(monkeypatch, verdicts=["incomparable"]) + _, card, _ = await run_card(graph([{"id": "wow", "name": "销售额环比", "expression": WOW}])) + found = by_id(card)["wow"]["comparability"] + assert found["verdict"] == "incomparable" and found["phrase"] == "两期不可比:本期只有 3 天数据" + assert found["reasons"] == ["本期只有 3 天数据"] + + +async def test_different_structures_and_the_same_query_are_not_checked(monkeypatch): + ops = FakeOps(monkeypatch) + ratio = "cell(nodes.this_week, 0, 'gmv') / cell(nodes.last_week, 0, 'gmv')" + _, card, logs = await run_card(graph([{"id": "refund_rate", "name": "退款率", "expression": ratio}], last=REFUNDS)) + assert "comparability" not in by_id(card)["refund_rate"] and ops.calls == [] + _, card, logs2 = await run_card(graph([{"id": "same", "name": "同一条", "expression": ratio}], last=THIS)) + assert "comparability" not in by_id(card)["same"] and ops.calls == [] + assert not [log for log in [*logs, *logs2] if log.get("code") == COMPARABILITY_SKIPPED] + + +async def test_without_the_operator_the_card_still_runs(): + """第 1 波的 app.analysis.run 还抛 NotImplementedError:口径卡照常算,照实记一条没做成。""" + _, card, logs = await run_card(graph([{"id": "wow", "name": "销售额环比", "expression": WOW, "decimals": 4}])) + wow = by_id(card)["wow"] + assert "comparability" not in wow and wow["value"] == pytest.approx((450 + 300 - 900) / 900, abs=1e-4) + [log] = [log for log in logs if log.get("code") == COMPARABILITY_SKIPPED] + assert log["level"] == "warn" and log["metric"] == "wow" and "销售额环比" in log["message"] + assert "没做成可比性检查" in log["message"] + + +@pytest.mark.parametrize("ops_kw", [ + {"errors": [CallError("no_window", "认不出时间条件")]}, + {"errors": [RuntimeError("探查超时")]}, + {"statuses": ["refused"]}, +]) +async def test_failures_do_not_block_the_card(monkeypatch, ops_kw): + FakeOps(monkeypatch, **ops_kw) + _, card, logs = await run_card(graph([{"id": "wow", "name": "销售额环比", "expression": WOW}])) + assert "comparability" not in by_id(card)["wow"] + [log] = [log for log in logs if log.get("code") == COMPARABILITY_SKIPPED] + assert log["reason"] + + +def test_reasons_come_from_the_check_table_when_there_is_one(): + result = {"verdict": {"code": "conditional"}, "phrase": "两期有条件可比:两段的周末天数不同", + "table": {"columns": ["check", "status", "reason"], "labels": {}, + "rows": [["同口径", "pass", None], ["窗口长度", "warn", "窗口长度不同:7 天对 6 天"], + ["星期构成", "warn", "两段的周末天数不同:2 天对 1 天"], ["整期", "unknown", "核对不了"]]}} + assert comparability_reasons(result) == ["窗口长度不同:7 天对 6 天", "两段的周末天数不同:2 天对 1 天"] + assert comparability_reasons({"verdict": {"code": "comparable"}, "phrase": "两期可比"}) == [] From 71dc16bc1a92209cd89891632cab1aa4c2d1c0da Mon Sep 17 00:00:00 2001 From: ThearchyHelios Date: Wed, 21 Oct 2026 13:27:12 +0800 Subject: [PATCH 7/7] =?UTF-8?q?=E5=88=86=E6=9E=90=E8=83=BD=E5=8A=9B?= =?UTF-8?q?=E5=85=A5=E5=8F=A3=EF=BC=88=E4=B8=83=EF=BC=89=EF=BC=9A=E6=8C=89?= =?UTF-8?q?=20W1-a=20=E7=AE=97=E5=AD=90=E5=BA=93=E7=9A=84=E5=AE=9E?= =?UTF-8?q?=E9=99=85=E6=8E=A5=E5=8F=A3=E5=AF=B9=E9=BD=90=E3=80=82=E5=B7=A5?= =?UTF-8?q?=E5=85=B7=E5=8F=82=E6=95=B0=E5=90=8D=E4=BB=A5=E7=AE=97=E5=AD=90?= =?UTF-8?q?=E4=B8=BA=E5=87=86=EF=BC=9A=E5=B7=AE=E5=BC=82=E6=A3=80=E9=AA=8C?= =?UTF-8?q?=E7=9A=84=E5=9D=87=E5=80=BC=E6=98=AF=20mean=20/=20sd=20/=20n=20?= =?UTF-8?q?=E6=88=96=20sum=20/=20sumsq=20/=20n=EF=BC=8C=E9=80=90=E8=A1=8C?= =?UTF-8?q?=E6=98=AF=20value=EF=BC=8C=E5=8F=AF=E9=80=89=20pairs=20/=20refe?= =?UTF-8?q?rence=EF=BC=9B=E5=8F=AF=E6=AF=94=E6=80=A7=E6=A3=80=E6=9F=A5?= =?UTF-8?q?=E5=8F=A6=E6=94=B6=E5=8F=AF=E9=80=89=E7=9A=84=20measure?= =?UTF-8?q?=E3=80=81grain=EF=BC=88=E4=B8=8D=E6=94=B6=20label=EF=BC=89?= =?UTF-8?q?=EF=BC=9B=E5=88=86=E6=9E=90=E5=8D=A1=E7=9A=84=E5=8F=82=E6=95=B0?= =?UTF-8?q?=E6=A0=A1=E9=AA=8C=E7=94=A8=E5=90=8C=E4=B8=80=E4=BB=BD=E5=AE=9A?= =?UTF-8?q?=E4=B9=89=EF=BC=8C=E8=BF=90=E8=A1=8C=E5=BD=93=E5=A4=A9=E3=80=81?= =?UTF-8?q?=E7=9B=AE=E5=BD=95=E4=BA=8B=E5=AE=9E=E3=80=81=E4=B8=8B=E9=92=BB?= =?UTF-8?q?=E5=B1=82=E6=95=B0=E7=94=B1=E7=AE=97=E5=AD=90=E8=87=AA=E5=B7=B1?= =?UTF-8?q?=E8=AE=B0=EF=BC=8C=E5=88=86=E6=9E=90=E9=A1=B9=E9=87=8C=E5=86=99?= =?UTF-8?q?=E4=BA=86=E4=B8=8D=E6=94=B6=E3=80=82=E4=BA=A4=E7=BB=99=E7=AE=97?= =?UTF-8?q?=E5=AD=90=E7=9A=84=E8=BE=93=E5=85=A5=E6=98=AF=E5=B7=A5=E4=BB=B6?= =?UTF-8?q?=E5=BA=93=E9=87=8C=E9=82=A3=E4=BB=BD=E6=9F=A5=E8=AF=A2=E5=BF=AB?= =?UTF-8?q?=E7=85=A7=E5=8E=9F=E6=A0=B7=EF=BC=88=E5=90=AB=20SQL=20=E6=A3=80?= =?UTF-8?q?=E6=9F=A5=E7=BB=93=E6=9E=9C=20checks=E3=80=81=E3=80=8C=E6=9F=A5?= =?UTF-8?q?=E8=AF=A2=E5=8C=BA=E9=97=B4=E9=87=8C=E8=BF=99=E5=BC=A0=E8=A1=A8?= =?UTF-8?q?=E6=B2=A1=E6=9C=89=E6=95=B0=E6=8D=AE=E3=80=8Dno=5Fdata=E3=80=81?= =?UTF-8?q?=E6=9F=A5=E8=AF=A2=E5=BD=93=E6=97=B6=E8=AE=B0=E4=B8=8B=E7=9A=84?= =?UTF-8?q?=E9=81=AE=E7=BD=A9=EF=BC=89=EF=BC=8C=E5=8A=A0=E4=B8=8A=E5=87=BA?= =?UTF-8?q?=E5=A4=84=E5=92=8C=E9=81=AE=E7=BD=A9=E5=88=97=EF=BC=88=E6=95=B0?= =?UTF-8?q?=E6=8D=AE=E6=BA=90=E8=AE=BE=E7=9A=84=E3=80=81=E6=8C=89=20SQL=20?= =?UTF-8?q?=E8=AE=A4=E5=87=BA=E6=94=B9=E4=BA=86=E5=90=8D=E5=AD=97=E7=9A=84?= =?UTF-8?q?=E3=80=81=E9=81=AE=E7=BD=A9=E8=BF=90=E8=A1=8C=E9=87=8C=E6=9F=A5?= =?UTF-8?q?=E8=AF=A2=E6=97=B6=E5=B0=B1=E9=81=AE=E6=8E=89=E7=9A=84=EF=BC=89?= =?UTF-8?q?=EF=BC=8C=E5=90=88=E5=B9=B6=E6=9F=A5=E8=AF=A2=E5=86=99=E6=98=8E?= =?UTF-8?q?=20sqlite=20=E6=96=B9=E8=A8=80=EF=BC=9Bagent=20=E7=9A=84?= =?UTF-8?q?=E5=AE=B9=E5=99=A8=E5=9C=A8=E5=88=86=E6=9E=90=E5=89=8D=E6=8A=8A?= =?UTF-8?q?=E6=AF=8F=E4=BB=BD=E6=8D=A2=E6=88=90=E5=AE=8C=E6=95=B4=E5=BF=AB?= =?UTF-8?q?=E7=85=A7=EF=BC=88=E6=AF=8F=E4=BB=BD=E5=8F=AA=E5=8F=96=E5=9B=9E?= =?UTF-8?q?=E4=B8=80=E6=AC=A1=EF=BC=89=E3=80=82=E6=8E=A2=E6=9F=A5=E5=99=A8?= =?UTF-8?q?=E6=94=B9=E7=94=A8=20analysis.make=5Fprober(=E6=95=B0=E6=8D=AE?= =?UTF-8?q?=E6=BA=90=E5=90=8D=20=E2=86=92=20=E6=8C=89=E8=BF=90=E8=A1=8C?= =?UTF-8?q?=E5=9B=BA=E5=AE=9A=E7=89=88=E6=9C=AC=E8=A7=A3=E6=9E=90=E5=87=BA?= =?UTF-8?q?=E7=9A=84=E6=BA=90=EF=BC=8Ccoverage.Cache=20=E4=B8=80=E4=B8=AA?= =?UTF-8?q?=E8=8A=82=E7=82=B9=E9=87=8C=E5=85=B1=E7=94=A8)=EF=BC=8C?= =?UTF-8?q?=E5=89=96=E6=9E=90=E4=BA=8B=E5=AE=9E=E4=B8=80=E5=BE=8B=E7=94=A8?= =?UTF-8?q?=20analysis.probe.profile=5Ffacts=EF=BC=88=E6=9F=A5=E8=AF=A2?= =?UTF-8?q?=E5=B7=A5=E5=85=B7=E7=9A=84=E8=A6=86=E7=9B=96=E6=A3=80=E6=9F=A5?= =?UTF-8?q?=E4=B9=9F=E6=94=B9=E7=94=A8=E5=AE=83=EF=BC=8C=E5=8E=9F=E6=9D=A5?= =?UTF-8?q?=E9=82=A3=E6=AE=B5=E5=90=8C=E6=A0=B7=E7=9A=84=E5=8F=96=E6=B3=95?= =?UTF-8?q?=E5=88=A0=E6=8E=89=EF=BC=89=E3=80=82=E5=88=86=E6=9E=90=E6=AC=A1?= =?UTF-8?q?=E6=95=B0=E7=94=A8=E5=AE=8C=E4=BA=A4=E5=9B=9E=20analysis.exhaus?= =?UTF-8?q?ted=20=E7=9A=84=E9=82=A3=E5=8F=A5=EF=BC=9B=E5=8F=A3=E5=BE=84?= =?UTF-8?q?=E5=8D=A1=E7=9A=84=E5=8F=AF=E6=AF=94=E6=80=A7=E5=86=99=E6=88=90?= =?UTF-8?q?=20{=E5=B7=A5=E4=BB=B6,=20=E5=88=A4=E5=AE=9A=E7=A0=81,=20?= =?UTF-8?q?=E5=88=A4=E5=AE=9A=E8=AF=AD,=20analysis.comparable=5Freasons=20?= =?UTF-8?q?=E6=8B=86=E5=87=BA=E7=9A=84=E5=8E=9F=E5=9B=A0}=EF=BC=8C?= =?UTF-8?q?=E5=88=A0=E6=8E=89=E8=87=AA=E5=B7=B1=E6=8B=86=E5=8E=9F=E5=9B=A0?= =?UTF-8?q?=E7=9A=84=E9=82=A3=E6=AE=B5=E3=80=82=E5=8A=A0=E7=9C=9F=E7=AE=97?= =?UTF-8?q?=E5=AD=90=E7=9A=84=E7=AB=AF=E5=88=B0=E7=AB=AF=E6=B5=8B=E8=AF=95?= =?UTF-8?q?=EF=BC=9Aagent=20=E5=8F=98=E5=8C=96=E6=8B=86=E8=A7=A3=E5=B9=B6?= =?UTF-8?q?=E4=B8=8B=E9=92=BB=EF=BC=88=E7=88=B6=E7=BB=93=E8=AE=BA=E6=8C=89?= =?UTF-8?q?=E5=86=85=E5=AE=B9=E5=93=88=E5=B8=8C=E8=AE=A4=EF=BC=89=E3=80=81?= =?UTF-8?q?=E5=AE=A1=E6=89=B9=E6=81=A2=E5=A4=8D=E9=87=8D=E6=94=BE=E4=B8=8D?= =?UTF-8?q?=E5=86=8D=E7=AE=97=E3=80=81=E5=88=86=E6=9E=90=E5=8D=A1=E7=9A=84?= =?UTF-8?q?=E6=B3=A2=E5=8A=A8=E5=9F=BA=E7=BA=BF=E5=92=8C=E5=8F=98=E5=8C=96?= =?UTF-8?q?=E6=8B=86=E8=A7=A3=EF=BC=88=E6=9C=AC=E6=9C=9F=E5=AE=8C=E6=95=B4?= =?UTF-8?q?=E6=8C=89=E7=8E=B0=E6=9F=A5=E7=9A=84=E8=A1=A8=E8=8C=83=E5=9B=B4?= =?UTF-8?q?=E5=88=A4=EF=BC=89=E3=80=81=E5=8F=A3=E5=BE=84=E5=8D=A1=E8=B7=91?= =?UTF-8?q?=E7=9C=9F=E5=8F=AF=E6=AF=94=E6=80=A7=E6=A3=80=E6=9F=A5=EF=BC=88?= =?UTF-8?q?=E5=8F=AF=E6=AF=94=E3=80=81=E6=9C=89=E6=9D=A1=E4=BB=B6=E5=8F=AF?= =?UTF-8?q?=E6=AF=94=E5=B8=A6=E5=8E=9F=E5=9B=A0=EF=BC=89=EF=BC=8C=E6=AF=8F?= =?UTF-8?q?=E4=BB=BD=E7=BB=93=E8=AE=BA=E9=83=BD=E6=8C=89=E5=B0=81=E5=AD=98?= =?UTF-8?q?=E7=9A=84=E5=B7=A5=E4=BB=B6=E5=A4=8D=E7=AE=97=E3=80=81=E9=80=90?= =?UTF-8?q?=E5=AD=97=E6=AE=B5=E7=9B=B8=E5=90=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/app/engine/nodes/analysis.py | 7 +- backend/app/engine/nodes/metrics.py | 32 +-- backend/app/tools/analysis.py | 164 +++++------ backend/app/tools/builtin/analysis.py | 36 ++- backend/app/tools/datasource.py | 24 +- backend/tests/test_analysis_card.py | 3 + backend/tests/test_analysis_e2e.py | 314 +++++++++++++++++++++ backend/tests/test_analysis_tools.py | 37 ++- backend/tests/test_metric_comparability.py | 40 +-- 9 files changed, 470 insertions(+), 187 deletions(-) create mode 100644 backend/tests/test_analysis_e2e.py diff --git a/backend/app/engine/nodes/analysis.py b/backend/app/engine/nodes/analysis.py index f6a2a584..382104a7 100644 --- a/backend/app/engine/nodes/analysis.py +++ b/backend/app/engine/nodes/analysis.py @@ -63,7 +63,7 @@ async def run_analysis(state: GraphState, ctx: NodeContext) -> dict[str, Any]: inputs[node_id] = await _load_input(state, ctx, node_id, queries) context = await context_of(list(inputs.values()), tool_ctx=tool_context(ctx.run, ctx.node.id, ctx.emit)) - import app.analysis as analysis_ops # 按模块取 run:测试、合并前的替身都只换模块上的这一个函数 + import app.analysis as analysis_ops # 按模块取 run:测试换替身时只换模块上的这一个函数 exec_no = next_exec(state, ctx.node.id) done: dict[str, AnalysisResult] = {} @@ -158,9 +158,8 @@ async def _load_input(state: GraphState, ctx: NodeContext, node_id: str, raise NodeError(ctx.node.id, f"{who}的查询快照与哈希不一致,疑似被修改,不能分析") from None if not isinstance(content, dict): raise NodeError(ctx.node.id, f"{who}的查询快照已不存在,无法分析。请重新运行它") - snapshot = input_snapshot(content, ref=node_id, artifact=artifact, node_id=node_id) - snapshot["masked"] = await masked_of(content, artifact) - return snapshot + return input_snapshot(content, ref=node_id, artifact=artifact, node_id=node_id, + masked=await masked_of(content, artifact)) __all__ = ["REFUSED_CODE", "card_view", "run_analysis"] diff --git a/backend/app/engine/nodes/metrics.py b/backend/app/engine/nodes/metrics.py index 7e90092e..77aea26e 100644 --- a/backend/app/engine/nodes/metrics.py +++ b/backend/app/engine/nodes/metrics.py @@ -539,7 +539,7 @@ class _Skipped(Exception): async def _check_comparable(pair: tuple[str, str], inputs: list[dict[str, Any]], ctx: NodeContext, comparing: _Comparing, snapshots: dict[str, Any]) -> dict[str, Any]: - import app.analysis as analysis_ops # 按模块取 run:测试、合并前的替身都只换模块上的这一个函数 + import app.analysis as analysis_ops # 按模块取 run:测试换替身时只换模块上的这一个函数 from app.analysis.contract import VERDICTS from app.data.sql_semantics import normalize_sql @@ -573,10 +573,9 @@ async def _check_comparable(pair: tuple[str, str], inputs: list[dict[str, Any]], refs[artifact] = producer if producer and producer not in refs.values() else f"{producer or 'query'}·{role}" snaps = {} for artifact in order: - content = snapshots[artifact] - snap = input_snapshot(content, ref=refs[artifact], artifact=artifact, node_id=refs[artifact].split("·")[0]) - snap["masked"] = await masked_of(content, artifact) - snaps[refs[artifact]] = snap + snaps[refs[artifact]] = input_snapshot(snapshots[artifact], ref=refs[artifact], artifact=artifact, + node_id=refs[artifact].split("·")[0], + masked=await masked_of(snapshots[artifact], artifact)) settings = await settings_of(run_snapshot(ctx.run)) context = await context_of(list(snaps.values()), tool_ctx=tool_context(ctx.run, ctx.node.id, ctx.emit)) request = {"op": "comparable", "mode": None, @@ -586,8 +585,9 @@ async def _check_comparable(pair: tuple[str, str], inputs: list[dict[str, Any]], if result.get("status") != "ok" or verdict not in VERDICTS["comparable"]: raise _Skipped(str(result.get("reason") or "检查没有给出判定")) artifact = await store(result, run_id=ctx.run.run_id, node_id=ctx.node.id) - return {"artifact": artifact, "verdict": verdict, "phrase": str(result.get("phrase") or ""), - "reasons": comparability_reasons(result)} + # 原因的拆法只有算子那一处(analysis.comparable_reasons,按检查表的说明拆) + return {"artifact": artifact, "verdict": result["verdict"]["code"], "phrase": result["phrase"], + "reasons": analysis_ops.comparable_reasons(result)} async def _dialect_of(source: str, comparing: _Comparing) -> str | None: @@ -625,24 +625,6 @@ def _period_order(a: tuple[str, ...], b: tuple[str, ...]) -> tuple[int, int]: return 1, 0 -def comparability_reasons(result: dict[str, Any]) -> list[str]: - """可比性结论里 fail、warn 的原因,一句一个(contract.MetricComparability.reasons)。结果表里有 status 一列和一列说明 - (reason / text / detail / message)的照表取;没有的按判定语「两期不可比:原因;原因」拆。可比的没有原因。""" - table = result.get("table") or {} - columns = [str(c) for c in table.get("columns") or []] - text_col = next((c for c in ("reason", "text", "detail", "message") if c in columns), None) - if "status" in columns and text_col is not None: - si, ti = columns.index("status"), columns.index(text_col) - found = [str(row[ti]) for row in table.get("rows") or [] - if isinstance(row, list) and len(row) > max(si, ti) and row[si] in ("fail", "warn") and row[ti]] - if found: - return found - phrase = str(result.get("phrase") or "") - if (result.get("verdict") or {}).get("code") == "comparable" or ":" not in phrase: - return [] - return [part.strip() for part in phrase.split(":", 1)[1].split(";") if part.strip()] - - #: 清单里输入取自「查询区间里这张表没有数据」的列的指标后面那句:直接读清单的模型节点不能把 0 当成真的是 0 LISTED_NO_DATA = "(这段时间表里没有数据:不要当成 0 解读)" #: 指标的 no_data_reason 打头那半句(后面接查询记下的说明) diff --git a/backend/app/tools/analysis.py b/backend/app/tools/analysis.py index a969a0e3..14566090 100644 --- a/backend/app/tools/analysis.py +++ b/backend/app/tools/analysis.py @@ -10,8 +10,9 @@ 那一步按 checkpoint 里的结果照样放一遍,编号还是同一套。 - **阈值**:发起时的快照(run.options["analysis"],engine/launch.py)← 分析卡上写的覆盖;没有快照的现读设置。agent 改不了: 分析工具的参数里没有阈值(契约 D4)。 -- **运行期依赖**(contract.Context):运行当天(服务端时区)、现查表的日期范围(W1-a 的 make_prober,没有这个模块时不给)、 - 数据目录(按运行的目录视图读,和 db_schema 给模型看的同一份)、剖析事实。 +- **运行期依赖**(contract.Context):运行当天(服务端时区)、现查表的日期范围(analysis.make_prober,按这次运行固定的数据 + 版本解析数据源)、数据目录(按运行的目录视图读,和 db_schema 给模型看的同一份)、剖析事实(analysis.probe.profile_facts, + 和查询工具的覆盖检查同一个取法)。 - **遮罩列**:数据源设的遮罩列、查询当时记下的、按 SQL 认出改了名字的(和证据面板、运行对比同一个函数,api/evidence 的 _mask_sets 的 hidden,宁可多遮),加上遮罩运行里查询时就整列遮掉的。算子不拿它们当维度、度量。 - **结论**:整份落成内容寻址的工件(contract.ARTIFACT_KIND),台账记 contract.ledger_entry;交回 agent 的是一段短的 @@ -64,9 +65,10 @@ def call_error_text(error: CallError) -> str: class AnalysisScope: """一次节点执行里分析工具读的容器(agent 节点、协作成员的一次执行各一个)。 - queries:编号 Qn → 输入快照(contract.InputSnapshot,masked 留空,用到时由 prepare 补)。results:编号 An → 整份 - 结论;artifacts:An → 结论的工件 id。snapshot:发起时的分析设置快照(没有为 None,现读设置)。mask_cache:快照工件 → - 遮罩列;source_cache:数据源和表 → 目录、剖析事实(一次节点执行里各读一次,只在工具执行时用,不影响编号)。""" + queries:编号 Qn → 输入快照(contract.InputSnapshot;记台账时先放交回模型的那份 payload,分析前 prepare 换成工件库里 + 那份完整快照,补上遮罩列)。results:编号 An → 整份结论;artifacts:An → 结论的工件 id。snapshot:发起时的分析设置快照 + (没有为 None,现读设置)。mask_cache:快照工件 → 遮罩列;source_cache:数据源和表 → 目录、查询用的源;probe_cache: + 数据源名 → coverage.Cache(一次节点执行里各读、各查一次,只在工具执行时用,不影响编号)。""" node_id: str snapshot: dict[str, Any] | None = None @@ -75,6 +77,9 @@ class AnalysisScope: artifacts: dict[str, str] = field(default_factory=dict) mask_cache: dict[str, list[str]] = field(default_factory=dict) source_cache: dict[str, Any] = field(default_factory=dict) + probe_cache: dict[str, Any] = field(default_factory=dict) + #: 已经换成工件库里那份完整快照的编号(prepare 每份只取回一次) + prepared: set[str] = field(default_factory=set) def add_query(self, alias: str, snapshot: InputSnapshot) -> None: self.queries[alias] = snapshot @@ -130,25 +135,18 @@ async def settings_of(snapshot: Mapping[str, Any] | None, # -------------------------------------------------------------------------- -def input_snapshot(content: Mapping[str, Any], *, ref: str, artifact: str, node_id: str) -> InputSnapshot: - """一份查询快照(数据源查询工具交回的、工件库里的)→ 算子的一份输入(masked 留空,prepare 时补)。列的类型对齐成和 - columns 同序的列表(快照里记的是 {列: 类型},没记的为 None);行一律写成和 columns 同序的列表。""" - columns = [str(c) for c in content.get("columns") or []] - types = content.get("column_types") - rows: list[list[Any]] = [] - for row in content.get("rows") or []: - if isinstance(row, (list, tuple)): - rows.append(list(row)) - elif isinstance(row, Mapping): - rows.append([row.get(c) for c in columns]) - else: - rows.append([row]) - return { - "ref": ref, "artifact": artifact, "node_id": node_id, "source": str(content.get("source") or ""), - "sql": str(content.get("sql") or ""), "columns": columns, - "column_types": [types.get(c) if isinstance(types, Mapping) else None for c in columns], - "rows": rows, "truncated": bool(content.get("truncated")), "masked": [], - } +def input_snapshot(content: Mapping[str, Any], *, ref: str, artifact: str, node_id: str, + masked: Iterable[str] = ()) -> InputSnapshot: + """一份查询快照 → 算子的一份输入:查询工具的 payload(工件库里那份)原样——列、行、类型、SQL、数据源,连同 SQL 检查结果 + (checks)、「查询区间里这张表没有数据」(no_data)、查询当时记下的遮罩(mask_columns、masked_at_query),可比性检查按 + 它们判数据问题——再加上出处(ref、artifact、node_id)和不能当维度、度量的列(masked,见 masked_of)。合并查询在 SQLite + 里执行,方言写明;数据源的方言算子按探查器认(prober.dialect)。""" + from app.engine.merge_query import MERGE_SOURCE + + out: dict[str, Any] = {**content, "ref": ref, "artifact": artifact, "node_id": node_id, "masked": list(masked)} + if content.get("source") == MERGE_SOURCE and not content.get("dialect"): + out["dialect"] = "sqlite" + return out # type: ignore[return-value] async def masked_of(content: Mapping[str, Any] | None, artifact: str | None) -> list[str]: @@ -171,23 +169,28 @@ async def masked_of(content: Mapping[str, Any] | None, artifact: str | None) -> async def prepare(scope: AnalysisScope) -> None: - """补上容器里每份输入快照的遮罩列(每份工件只认一次)。快照取不回来、和哈希对不上的照实报错:查询明明交回过, - 分析读不到它的快照,不是 agent 写错了参数。""" + """分析前把容器里的每份输入换成工件库里那份完整的查询快照(取回时复验哈希),补上遮罩列(每份工件只认一次)。快照 + 取不回来、和哈希对不上的照实报错:查询明明交回过,分析读不到它的快照,不是 agent 写错了参数。""" import asyncio from app.core import artifact_store - for alias, snapshot in scope.queries.items(): - artifact = snapshot.get("artifact") or "" + for alias, snapshot in list(scope.queries.items()): + if alias in scope.prepared: + continue + artifact = str(snapshot.get("artifact") or "") + try: + content = await asyncio.to_thread(artifact_store.load, artifact) + except ValueError as e: + raise RuntimeError(f"{alias} 的查询快照与哈希不一致,疑似被修改,不能分析") from e + if not isinstance(content, Mapping): + raise RuntimeError(f"{alias} 的查询快照已不存在,不能分析") if artifact not in scope.mask_cache: - try: - content = await asyncio.to_thread(artifact_store.load, artifact) - except ValueError as e: - raise RuntimeError(f"{alias} 的查询快照与哈希不一致,疑似被修改,不能分析") from e - if content is None: - raise RuntimeError(f"{alias} 的查询快照已不存在,不能分析") scope.mask_cache[artifact] = await masked_of(content, artifact) - snapshot["masked"] = list(scope.mask_cache[artifact]) + scope.queries[alias] = input_snapshot(content, ref=alias, artifact=artifact, + node_id=str(snapshot.get("node_id") or scope.node_id), + masked=scope.mask_cache[artifact]) + scope.prepared.add(alias) # -------------------------------------------------------------------------- @@ -214,21 +217,26 @@ def _tables_of(snapshots: Iterable[Mapping[str, Any]]) -> dict[str, set[str]]: async def context_of(snapshots: Iterable[Mapping[str, Any]], *, tool_ctx: Any, - cache: dict[str, Any] | None = None) -> contract.Context: - """算子要的运行期依赖(contract.Context)。 + cache: dict[str, Any] | None = None, + probe_cache: dict[str, Any] | None = None) -> contract.Context: + """算子要的运行期依赖(contract.Context)。每次分析调用一份(探查器有总时限,一次调用建一个)。 - today:运行当天,服务端时区(算子据此判本期完不完整) - catalogs:数据源名 → {表名: 这张表的目录(app/data/catalog 的 notes)},按运行的目录视图读(tool_ctx.catalog_view, 和 db_schema 给模型看的、冻结进表结构快照的同一份),只给快照的 SQL 里用到的表 - - facts:数据源名 → {表名: 剖析事实(catalog_profile_jobs.last_facts)};上传表格的剖析不是这次查的那一版的不给 - - probe:现查一列的实际范围(W1-a 的 make_prober,按这次运行固定的数据版本解析数据源;没有这个模块时为 None) + - facts:数据源名 → 取剖析事实的函数(analysis.probe.profile_facts:上传表格的剖析不是这次查的那一版的不给) + - probe:analysis.make_prober(数据源名 → 查询用的源):按这次运行固定的数据版本解析数据源(上传表格查的是固定的那一版), + 遮罩列按数据源设的不探,探不成由算子退回剖析事实;一个源都解析不出来时为 None + + tool_ctx 是查询用的那个 ToolContext(固定的数据版本、目录视图都从它取)。cache 是调用方的缓存(同一个节点里分析好几次, + 目录、数据源读一次);probe_cache 是数据源名 → coverage.Cache(同一张表的范围一个节点里只查一次)。读不到的源当没有: + 目录、剖析、探查都是附加的,不能挡住分析。""" + from app.analysis.probe import profile_facts - tool_ctx 是查询用的那个 ToolContext(固定的数据版本、目录视图都从它取)。cache 是调用方的缓存(同一个节点里分析 - 好几次,目录、剖析事实读一次)。读不到的源当没有:目录、剖析是附加说明,不能挡住分析。""" cache = cache if cache is not None else {} catalogs: dict[str, Any] = {} facts: dict[str, Any] = {} - found: dict[str, Any] = {} + sources: dict[str, Any] = {} for name, tables in _tables_of(snapshots).items(): key = f"{name}\n" + "\n".join(sorted(tables)) if key not in cache: @@ -236,17 +244,16 @@ async def context_of(snapshots: Iterable[Mapping[str, Any]], *, tool_ctx: Any, entry = cache[key] if entry is None: continue - found[name] = entry["source"] - catalogs[name], facts[name] = entry["catalog"], entry["facts"] - return {"today": date.today(), "probe": _prober(found, facts), "catalogs": catalogs, "facts": facts} + sources[name] = entry["source"] + catalogs[name] = entry["catalog"] + facts[name] = profile_facts(entry["source"]) + return {"today": date.today(), "probe": _prober(sources, probe_cache), "catalogs": catalogs, "facts": facts} async def _source_context(name: str, tables: set[str], tool_ctx: Any) -> dict[str, Any] | None: - """一个数据源这几张表的目录、剖析事实,和查询用的那个源(探查用)。读不到为 None。""" + """一个数据源这几张表的目录,和查询用的那个源(探查、剖析事实都按它)。读不到为 None。""" from app.data import catalog as data_catalog from app.data import catalog_view - from app.data.catalog_profile_jobs import last_facts - from app.db.base import SessionLocal from app.tools.datasource import resolved_source try: @@ -255,61 +262,24 @@ async def _source_context(name: str, tables: set[str], tool_ctx: Any) -> dict[st return None row, source = resolved entries = await catalog_view.read(catalog_view.CatalogView.parse(getattr(tool_ctx, "catalog_view", None)), row) - names = {data_catalog.resolve_table_name(getattr(source, "schema_cache", None), t) or t for t in tables} - async with SessionLocal() as session: - profiled = await last_facts(session, str(row.id), sorted(names)) if names else {} - except Exception: # noqa: BLE001 - 目录、剖析读不到当没有(目录视图指定的快照不在了另由查询工具照实报) - logger.warning("分析读取数据源 %s 的目录、剖析事实失败,这次不附", name, exc_info=True) + except Exception: # noqa: BLE001 - 目录读不到当没有(目录视图指定的快照不在了另由查询工具照实报) + logger.warning("分析读取数据源 %s 的目录失败,这次不附", name, exc_info=True) return None - snapshot = getattr(source, "snapshot_id", None) - return { - "source": source, - "catalog": {t: dict(entries[t].notes or {}) for t in sorted(names) if t in entries}, - # 上传表格:剖析时是另一版数据,范围对不上这次读的这一版(同 tools/datasource 的 _coverage) - "facts": {t: f for t, f in profiled.items() if not (snapshot and f.get("snapshot_id") != snapshot)}, - } + names = {data_catalog.resolve_table_name(getattr(source, "schema_cache", None), t) or t for t in tables} + return {"source": source, "catalog": {t: dict(entries[t].notes or {}) for t in sorted(names) if t in entries}} -def _probe_factory() -> Any: - """W1-a 的 app.analysis.probe.make_prober;还没有这个模块时为 None(算子只用剖析事实,核对不了的写「核对不了」)。""" - try: - from app.analysis.probe import make_prober # type: ignore[import-not-found] - except ModuleNotFoundError as e: - if e.name not in ("app.analysis.probe", "app.analysis"): - raise - return None - return make_prober - - -def _prober(sources: Mapping[str, Any], facts: Mapping[str, Any]) -> contract.Prober | None: - """按数据源名分派的探查(contract.Prober)。每个源第一次被问到时才建它自己的探查: - - make_prober(查询用的源, masked=遮罩列, cache=coverage.Cache(), facts=按表取剖析事实的协程函数) - - 签名以 W1-a 的交付为准(合并时由编排者对齐这一处)。遮罩列上的不探(data/coverage 的规矩),探查失败由它自己退回 - 剖析事实。""" - make = _probe_factory() - if make is None or not sources: +def _prober(sources: Mapping[str, Any], probe_cache: dict[str, Any] | None) -> contract.Prober | None: + """这次分析调用的探查器(analysis.make_prober)。遮罩列、剖析事实按缺省(数据源设的遮罩列;profile_facts 现读); + coverage.Cache 按数据源名放在调用方给的 probe_cache 里,同一个节点里的几次分析共用。""" + if not sources: return None + from app import analysis from app.data import coverage - from app.data.engine import masked_columns - built: dict[str, Any] = {} - - async def probe(source: str, table: str, column: str) -> contract.Probe | None: - if source not in built: - obj = sources.get(source) - known = (facts.get(source) or {}) if isinstance(facts, Mapping) else {} - - async def by_table(name: str, known: Mapping[str, Any] = known) -> Mapping[str, Any] | None: - return known.get(name) - - built[source] = None if obj is None else make( - obj, masked=masked_columns(getattr(obj, "options", None)), cache=coverage.Cache(), facts=by_table) - found = built[source] - return await found(source, table, column) if found is not None else None - - return probe + shared = probe_cache if probe_cache is not None else {} + return analysis.make_prober(dict(sources), cache={name: shared.setdefault(name, coverage.Cache()) + for name in sources}) def tool_context(run_ctx: Any, node_id: str, emit: Any = None) -> Any: diff --git a/backend/app/tools/builtin/analysis.py b/backend/app/tools/builtin/analysis.py index 830bbbea..0dfd9dfa 100644 --- a/backend/app/tools/builtin/analysis.py +++ b/backend/app/tools/builtin/analysis.py @@ -42,9 +42,6 @@ NO_SCOPE = ("分析工具只能由 Agent 在本节点查过数之后调用:它 "模板里要固定做的分析,请改用「分析卡」节点") #: 交回 agent 的说法:本节点还没有成功的查询 NO_QUERIES = "本节点还没有成功的查询可以分析:先用数据库查询工具查出要分析的数,再按交回的编号(Q1、Q2……)调用分析工具" -#: 这个节点的分析次数用完 -MAX_CALLS = ("这个节点已经做了 {done} 次分析,达到「{label}」的上限({limit} 次),不能再分析了。" - "请基于已有的分析结论给出结论,没查清的照实说") _QUERY = "要分析的查询结果:本节点交回过的编号,如 Q3(和「【证据 Q3】」同一个)" _LABEL = "度量的叫法,系统写判定语时用(如「销售额」);不要写数字、百分号和单位,留空就用列名" @@ -118,20 +115,20 @@ class DifferArgs(BaseModel): description="rate:比率(转化率、退款率),给分子、分母两列;mean:均值,给每组的均值、标准差、样本数三列," "或合计、平方和、样本数三列;rows:逐行的值,算子自己算均值和方差(结果不能截断)") query: str = Field(description=_QUERY) - by: list[str] | None = Field(default=None, description="分组列的列名") - reference: str | None = Field(default=None, description="对照组:分组列里的一个值;留空和整体比") + by: list[str] | None = Field(default=None, description="按组比时:分组列的列名(一列)") pairs: list[list[str]] | None = Field(default=None, description="只比这几对:每对写两个组在分组列里的值") - period: str | None = Field(default=None, description="按期次比同一组的两期时:期次列") + reference: str | None = Field(default=None, description="对照组:分组列里的一个值,其余各组各和它比") + period: str | None = Field(default=None, description="按期次比同一组的两期时:期次列(SQL 里先筛好那一组)") base: str | None = Field(default=None, description="按期次比时:基期") target: str | None = Field(default=None, description="按期次比时:本期") numerator: str | None = Field(default=None, description="分子列:成功数(rate 用,如退款单数)") denominator: str | None = Field(default=None, description="分母列:试验数(rate 用,如订单数)") - mean: str | None = Field(default=None, description="每组的均值列(mean 用)") - std: str | None = Field(default=None, description="每组的标准差列(mean 用)") - sum: str | None = Field(default=None, description="每组的合计列(mean 用,没有标准差时和平方和一起给)") - sum_sq: str | None = Field(default=None, description="每组的平方和列(mean 用)") + mean: str | None = Field(default=None, description="每组的均值列(mean 用,和标准差、样本数一起给)") + sd: str | None = Field(default=None, description="每组的标准差列(mean 用)") + sum: str | None = Field(default=None, description="每组的合计列(mean 用,没有标准差时和平方和、样本数一起给)") + sumsq: str | None = Field(default=None, description="每组的平方和列(mean 用)") n: str | None = Field(default=None, description="每组的样本数列(mean 用)") - measure: str | None = Field(default=None, description="逐行的值列(rows 用)") + value: str | None = Field(default=None, description="逐行的值列(rows 用)") label: str | None = Field(default=None, description=_LABEL) hypothesis: str | None = Field(default=None, description=_HYPOTHESIS) @@ -143,7 +140,8 @@ class ComparableArgs(BaseModel): period: str | None = Field(default=None, description="同一份查询时:期次列") base: str | None = Field(default=None, description="同一份查询时:基期") target: str | None = Field(default=None, description="同一份查询时:本期") - label: str | None = Field(default=None, description=_LABEL) + measure: str | None = Field(default=None, description="度量列:按它找时间条件、认它是不是可累加的量;只有一列数值列时可以不写") + grain: Literal["day", "week", "month", "quarter"] | None = Field(default=None, description=_GRAIN) hypothesis: str | None = Field(default=None, description=_HYPOTHESIS) @@ -179,10 +177,10 @@ async def run_tool(op: str, ctx: ToolContext, args: BaseModel) -> dict[str, Any] """一次 agent 分析:读本节点的查询容器、按设置快照取阈值、调算子、结论落成工件。交回 {analysis_artifact, result}: 记台账、编号 An、给模型看的那一段由节点在记台账时做(engine/nodes/llm.py 的 _record_call),重放时照样做一遍。 - 调用写错(含这个节点的分析次数用完)抛 contract.CallError,节点作为工具错误交回模型、不记台账。""" - import app.analysis as analysis_ops # 按模块取 run:测试、合并前的替身都只换模块上的这一个函数 + 调用写错(含这个节点的分析次数用完,analysis.exhausted)抛 contract.CallError,节点作为工具错误交回模型、不记台账。 + 结论原样存成工件、一个字段都不加:下钻时算子按上一层结论的内容哈希认 parent 的工件。""" + import app.analysis as analysis_ops # 按模块取 run:测试换替身时只换模块上的这一个函数 - from app.analysis.contract import LABELS from app.tools.analysis import context_of, prepare, scope_of, settings_of, store scope = scope_of(ctx.extra) @@ -192,11 +190,11 @@ async def run_tool(op: str, ctx: ToolContext, args: BaseModel) -> dict[str, Any] raise CallError("no_queries", NO_QUERIES) settings = await settings_of(scope.snapshot) if len(scope.results) >= settings["max_calls"]: - raise CallError("max_calls", MAX_CALLS.format(done=len(scope.results), label=LABELS["max_calls"], - limit=settings["max_calls"])) + raise analysis_ops.exhausted(settings) # 次数用完的说法只有算子那一处 request = request_of(op, args) await prepare(scope) - context = await context_of(list(scope.queries.values()), tool_ctx=ctx, cache=scope.source_cache) + context = await context_of(list(scope.queries.values()), tool_ctx=ctx, cache=scope.source_cache, + probe_cache=scope.probe_cache) result = await analysis_ops.run(request, scope.resolve, settings=settings, context=context, parents=scope.parent) artifact = await store(result, run_id=ctx.run_id, node_id=scope.node_id) return {"analysis_artifact": artifact, "result": result} @@ -242,7 +240,7 @@ async def analysis_trend(ctx: ToolContext, **kwargs: Any) -> dict[str, Any]: name=contract.TOOL_NAMES["differ"], category=CATEGORY, description="差异检验:判断两组(或同一组的两期)的差异站得住,还是可能只是样本波动。比率给分子、分母两列(rate)," - "均值给均值、标准差、样本数(mean),也可以给逐行的值(rows);样本小时照实标出。", + "均值给均值、标准差、样本数或合计、平方和、样本数(mean),也可以给逐行的值(rows);样本小时照实标出。", args_schema=DifferArgs, guide=METHOD_GUIDE, ) diff --git a/backend/app/tools/datasource.py b/backend/app/tools/datasource.py index 871ef28d..85abed0e 100644 --- a/backend/app/tools/datasource.py +++ b/backend/app/tools/datasource.py @@ -804,29 +804,15 @@ def _unchecked_tables(sql: str, source: Any, outside: frozenset[str]) -> list[st async def _coverage(source: Any, sql: str, payload: Mapping[str, Any], mask: list[str]) -> list[dict[str, Any]]: """查询区间里这张表没有数据的那几列(data/coverage.result_gaps)。只在结果里有整列是 0 或空的数值列时才查库;遮罩列 - 不探查;现查表的范围不成时退回剖析、取值观察记下的范围(上传表格的快照对不上就不用)。从不抛异常。""" + 不探查;现查表的范围不成时退回剖析、取值观察记下的范围(上传表格的快照对不上就不用)。从不抛异常。 + + 剖析事实的取法只有一处(analysis.probe.profile_facts):分析的可比性检查现查不成时退回的也是它。""" + from app.analysis.probe import profile_facts from app.data import coverage if not coverage.zero_columns(payload): return [] - - async def facts(table: str) -> Mapping[str, Any] | None: - from app.data.catalog_profile_jobs import last_facts - from app.db.base import SessionLocal - - source_id = getattr(source, "id", None) - if not source_id: - return None - name = data_catalog.resolve_table_name(getattr(source, "schema_cache", None), table) or table - async with SessionLocal() as session: - found = (await last_facts(session, str(source_id), [name])).get(name) - snapshot = getattr(source, "snapshot_id", None) - if found and snapshot and found.get("snapshot_id") != snapshot: - # 上传表格:剖析时是另一版数据,范围对不上这次读的这一版 - return None - return found - - return await coverage.result_gaps(source, sql, payload, facts=facts, masked=mask) + return await coverage.result_gaps(source, sql, payload, facts=profile_facts(source), masked=mask) async def _sql_checks(checker: SqlChecker, sql: str) -> list[dict[str, Any]]: diff --git a/backend/tests/test_analysis_card.py b/backend/tests/test_analysis_card.py index 0aae7593..23773219 100644 --- a/backend/tests/test_analysis_card.py +++ b/backend/tests/test_analysis_card.py @@ -158,6 +158,9 @@ def test_a_well_formed_card_validates(): "输入里"), ([{**WOW, "params": {**WOW["params"], "bogus": 1}}], "items[0].params.bogus", "analysis.params_invalid", "没有「bogus」"), + # 运行当天、目录事实、下钻层数由系统填(算子自己记),分析项里写了不收 + ([{**WOW, "params": {**WOW["params"], "today": "2026-10-21"}}], "items[0].params.today", + "analysis.params_invalid", "没有「today」"), ([{**WHERE, "params": {**WHERE["params"], "headline": {"base": "weekly.gmv", "target": "weekly.r1.gmv"}}}], "items[0].params.headline.base", "analysis.params_invalid", "节点 id.r行号.列名"), ([{**WHERE, "params": {**WHERE["params"], "headline": "weekly.r0.gmv"}}], "items[0].params.headline", diff --git a/backend/tests/test_analysis_e2e.py b/backend/tests/test_analysis_e2e.py new file mode 100644 index 00000000..c7933b95 --- /dev/null +++ b/backend/tests/test_analysis_e2e.py @@ -0,0 +1,314 @@ +"""分析入口接真算子跑一遍(不用替身):agent 分析工具(含下钻)、分析卡(波动基线 + 变化拆解)、口径卡的可比性挂钩、 +审批恢复时的重放。断言工件、台账、交回模型的那一段、节点产出,并用 analysis.recompute 按封存的工件复算、逐字段相同。 + +虚构零售库 shop:orders(day, region, channel, amount),2026-03-02 起 12 个整周,每周每个地区、渠道两笔(周三、周日), +最后一周华东掉三成。模型换成脚本(MockChatModel._decide),不调用任何模型。 +""" +from __future__ import annotations + +import asyncio +import json +import sqlite3 +from datetime import date, timedelta + +import pytest +from langchain_core.messages import AIMessage, ToolMessage +from sqlalchemy import select + +import app.analysis as analysis_ops +from app.analysis import contract +from app.core import artifact_store +from app.core.artifact_store import canonical_json, content_hash +from app.data.engine import engines +from app.db.base import SessionLocal +from app.db.models import DataSource, Run, RunEvent +from app.engine.runner import run_manager + +TOOL = "db_query__shop" +DECOMPOSE = contract.TOOL_NAMES["decompose"] +WEEK = "date(day, '-6 days', 'weekday 1')" +BY_REGION = (f"SELECT region, {WEEK} AS week, SUM(amount) AS gmv FROM orders WHERE day >= '2026-05-11' " + "GROUP BY region, week ORDER BY region, week") +TOTALS = f"SELECT {WEEK} AS week, SUM(amount) AS gmv FROM orders WHERE day >= '2026-05-11' GROUP BY week ORDER BY week" +EAST = (f"SELECT channel, {WEEK} AS week, SUM(amount) AS gmv FROM orders WHERE region = '华东' AND day >= '2026-05-11' " + "GROUP BY channel, week ORDER BY channel, week") +WEEKLY = f"SELECT {WEEK} AS week, SUM(amount) AS gmv FROM orders GROUP BY week ORDER BY week" +TWO_WEEKS = {"period": "week", "base": "2026-05-11", "target": "2026-05-18", "measure": "gmv"} +A1_ARGS = {"mode": "additive", "query": "Q1", "by": ["region"], **TWO_WEEKS, "label": "销售额", + "headline": {"base": "Q2.r0.gmv", "target": "Q2.r1.gmv"}, "hypothesis": "下降是不是集中在某个地区"} +A2_ARGS = {"mode": "additive", "query": "Q3", "by": ["channel"], **TWO_WEEKS, "parent": {"of": "A1", "key": ["华东"]}, + "hypothesis": "华东是哪个渠道掉的"} + + +def _orders(): + out, i = [], 0 + base = {"华东": 400, "华南": 300, "华北": 250, "西南": 50} + for w in range(12): + monday = date(2026, 3, 2) + timedelta(weeks=w) + for region, amount in base.items(): + for channel, share in (("线上", 3), ("线下", 2)): + value = amount * share // 5 + (w % 3) * 2 + if w == 11 and region == "华东": + value = value * 7 // 10 + for offset in (2, 6): + i += 1 + out.append((i, (monday + timedelta(days=offset)).isoformat(), region, channel, value)) + return out + + +@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, day TEXT, region TEXT, channel TEXT, amount INTEGER);") + db.executemany("INSERT INTO orders VALUES (?, ?, ?, ?, ?)", _orders()) + 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 chain(*nodes): + return {"nodes": list(nodes), "edges": [{"source": a["id"], "target": b["id"]} for a, b in zip(nodes, nodes[1:])]} + + +class Script: + """Agent 依次发 calls 里的工具调用,再给结论;seen 记下每次带工具的调用看到的消息。""" + + def __init__(self, monkeypatch, calls): + from app.providers import mock_model + + self.seen: list[list] = [] + + def decide(model, messages): + if not model.tools: + return AIMessage(content="收尾") + self.seen.append(list(messages)) + 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 replies(self): + return [m.content for m in self.seen[-1] if isinstance(m, ToolMessage)] + + +class Counting: + """真算子外面包一层只数次数(重放时不该再算)。""" + + def __init__(self, monkeypatch): + self.calls = 0 + real = analysis_ops.run + + async def run(*args, **kwargs): + self.calls += 1 + return await real(*args, **kwargs) + + monkeypatch.setattr(analysis_ops, "run", run) + + +async def wait(run_id, statuses=("succeeded", "failed")): + for _ in range(600): + 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, etype=None, node_id=None): + 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 [e.data for e in (await session.execute(q.order_by(RunEvent.seq))).scalars()] + + +def agent_graph(approval="never"): + return chain(node("start", "input"), + node("bot", "agent", prompt="销售额为什么下降", tools=[TOOL, DECOMPOSE], approval=approval), + node("out", "output", fields=[{"name": "answer", "value": "{{ nodes.bot.text }}"}])) + + +def recomputes(stored): + """按封存的工件复算(查询快照、上一层结论都按 sha256 取回),和封存的逐字段相同。""" + again = analysis_ops.recompute(stored, artifact_store.load, load_result=artifact_store.load) + assert contract.same(stored, again), {k: (stored.get(k), again.get(k)) for k in stored if stored.get(k) != again.get(k)} + + +AGENT_CALLS = [(TOOL, {"sql": BY_REGION}), (TOOL, {"sql": TOTALS}), (TOOL, {"sql": EAST}), + (DECOMPOSE, A1_ARGS), (DECOMPOSE, A2_ARGS)] + + +# -------------------------------------------------------------------------- +# agent 分析工具:拆解、下钻 +# -------------------------------------------------------------------------- + + +async def test_an_agent_decomposes_and_drills_down_with_the_real_operator(monkeypatch): + script = Script(monkeypatch, AGENT_CALLS) + run = await run_manager.start(graph=agent_graph(), input_payload={}) + row = await wait(run.id) + assert row.status == "succeeded", row.error + + ends = await events(row.id, "tool.end", "bot") + q1, q2, q3 = (ends[i]["query_artifact"] for i in range(3)) + a1_artifact, a2_artifact = ends[3]["analysis_artifact"], ends[4]["analysis_artifact"] + a1, a2 = artifact_store.load(a1_artifact), artifact_store.load(a2_artifact) + + # 结论:华东是主要来源,下钻到渠道是线上;总数闭合(headline 是 Q2 的两格) + assert a1["status"] == "ok" and a1["verdict"] == {"code": "driver", "subjects": [["华东"]], "direction": "down"} + assert "主要来自华东" in a1["phrase"] and a1["hypothesis"] == "下降是不是集中在某个地区" + assert {i["ref"] for i in a1["inputs"]} >= {"Q1", "Q2.r0.gmv", "Q2.r1.gmv"} + assert a2["verdict"]["code"] == "driver" and a2["verdict"]["subjects"] == [["线上"]] + # 下钻:算子按上一层结论的内容哈希认 parent,就是存下的那份工件 + [parent] = [i for i in a2["inputs"] if i.get("role") == "parent"] + assert parent == {"ref": "A1", "artifact": a1_artifact, "role": "parent"} + assert content_hash(canonical_json(a1)) == a1_artifact + + # 台账:两条 kind analysis,带 call_id,输入是那几份查询快照(下钻的另有上一层结论) + [finished] = await events(row.id, "node.finished", "bot") + analyses = [e for e in finished["evidence"] if e["kind"] == "analysis"] + assert [(e["call_id"], e["artifact"]) for e in analyses] == [("call_3", a1_artifact), ("call_4", a2_artifact)] + assert set(analyses[0]["inputs"]) == {q1, q2} and set(analyses[1]["inputs"]) == {q3, a1_artifact} + assert analyses[0] == contract.ledger_entry(a1, node_id="bot", exec_no=1, artifact=a1_artifact, call_id="call_3") + + # 交回模型的:编号、判定语、关键数 + replies = script.replies() + assert [r.split("\n", 1)[0] for r in replies] == ["【证据 Q1】", "【证据 Q2】", "【证据 Q3】", "【分析 A1】", "【分析 A2】"] + body = json.loads(replies[3].split("\n", 1)[1]) + assert body["phrase"] == a1["phrase"] and body["verdict"] == "driver" and body["values"] == a1["values"] + assert body["table"]["rows"][0][0] == "华东" + + # 按封存的工件复算,逐字段相同 + recomputes(a1) + recomputes(a2) + + +async def test_replaying_after_approvals_does_not_recompute(monkeypatch): + counted = Counting(monkeypatch) + script = Script(monkeypatch, AGENT_CALLS) + run = await run_manager.start(graph=agent_graph(approval="always"), input_payload={}) + for _ in AGENT_CALLS: + 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 counted.calls == 2, "重放时分析又算了一次" + ends = await events(row.id, "tool.end", "bot") + assert len(ends) == len(AGENT_CALLS) + a1_artifact, a2_artifact = ends[3]["analysis_artifact"], ends[4]["analysis_artifact"] + assert [r.split("\n", 1)[0] for r in script.replies()][-2:] == ["【分析 A1】", "【分析 A2】"] + [parent] = [i for i in artifact_store.load(a2_artifact)["inputs"] if i.get("role") == "parent"] + assert parent["artifact"] == a1_artifact + [finished] = await events(row.id, "node.finished", "bot") + assert [e["artifact"] for e in finished["evidence"] if e["kind"] == "analysis"] == [a1_artifact, a2_artifact] + + +# -------------------------------------------------------------------------- +# 分析卡:波动基线 + 变化拆解 +# -------------------------------------------------------------------------- + + +async def test_an_analysis_card_runs_the_real_operators(): + items = [ + {"id": "wow", "op": "baseline", "from": "weekly", "params": {"period": "week", "measure": "gmv", "label": "销售额"}}, + {"id": "where", "op": "decompose", "mode": "additive", "from": "by_region", + "params": {"by": ["region"], **TWO_WEEKS, "label": "销售额", + "headline": {"base": "weekly.r10.gmv", "target": "weekly.r11.gmv"}}}, + ] + graph = chain(node("start", "input"), node("weekly", "tool", tool=TOOL, args={"sql": WEEKLY}), + node("by_region", "tool", tool=TOOL, args={"sql": BY_REGION}), + node("card", "analysis", items=items), + node("out", "output", fields=[{"name": "判定", "value": "{{ nodes.card.results.wow.phrase }}"}])) + run = await run_manager.start(graph=graph, input_payload={}) + row = await wait(run.id) + assert row.status == "succeeded", row.error + + [finished] = await events(row.id, "node.finished", "card") + results = artifact_store.load(finished["artifact"])["results"] + wow, where = results["wow"], results["where"] + assert wow["status"] == "ok" and wow["verdict"] in ("outside_band", "far_outside_band") and wow["direction"] == "down" + assert where["status"] == "ok" and where["verdict"] == "driver" and "主要来自华东" in where["phrase"] + assert row.output["判定"] == wow["phrase"] + + baseline = artifact_store.load(wow["artifact"]) + # 本期完不完整按现查的表范围判(analysis.make_prober 按这次运行的数据源探 orders.day):探到了,不再说核对不了 + [probe] = baseline["probes"] + assert (probe["source"], probe["table"], probe["column"], probe["basis"]) == ("shop", "orders", "day", "live") + assert probe["max"] == "2026-05-24" + assert not [c for c in baseline["caveats"] if c["code"] == "comparability"] + decomposed = artifact_store.load(where["artifact"]) + assert decomposed["verdict"]["subjects"] == [["华东"]] + entries = finished["evidence"] + assert [(e["kind"], e["item"], e["artifact"]) for e in entries] == [ + ("analysis", "wow", wow["artifact"]), ("analysis", "where", where["artifact"])] + recomputes(baseline) + recomputes(decomposed) + + +# -------------------------------------------------------------------------- +# 口径卡的可比性挂钩:真 comparable +# -------------------------------------------------------------------------- + + +def _week_sql(start: str, end: str) -> str: + return f"SELECT SUM(amount) AS gmv FROM orders WHERE day BETWEEN '{start}' AND '{end}'" + + +@pytest.mark.parametrize("this_week, verdict, has_reasons", [ + (("2026-05-18", "2026-05-24"), "comparable", False), + (("2026-05-18", "2026-05-23"), "conditional", True), # 本期 6 天对上期 7 天:窗口长度、周末天数都不一样 +]) +async def test_a_caliber_card_checks_two_periods_with_the_real_operator(this_week, verdict, has_reasons): + metrics = [{"id": "wow", "name": "销售额环比", "decimals": 4, + "expression": "(cell(nodes.this_week, 0, 'gmv') - cell(nodes.last_week, 0, 'gmv'))" + " / cell(nodes.last_week, 0, 'gmv')"}] + graph = chain(node("start", "input"), node("this_week", "tool", tool=TOOL, args={"sql": _week_sql(*this_week)}), + node("last_week", "tool", tool=TOOL, args={"sql": _week_sql("2026-05-11", "2026-05-17")}), + node("card", "metrics", caliber="周报口径", metrics=metrics), + node("out", "output", fields=[{"name": "r", "value": "{{ nodes.card.text }}"}])) + run = await run_manager.start(graph=graph, input_payload={}) + row = await wait(run.id) + assert row.status == "succeeded", row.error + [finished] = await events(row.id, "node.finished", "card") + [metric] = artifact_store.load(finished["artifact"])["metrics"] + found = metric["comparability"] + stored = artifact_store.load(found["artifact"]) + assert stored["op"] == "comparable" and stored["params"]["base_query"] == "last_week" + assert stored["params"]["target_query"] == "this_week" + assert found == {"artifact": found["artifact"], "verdict": verdict, "phrase": stored["phrase"], + "reasons": analysis_ops.comparable_reasons(stored)} + assert bool(found["reasons"]) is has_reasons + # 整期按现查的表范围判 + assert any(p.get("table") == "orders" and p.get("basis") == "live" for p in stored["probes"]) + recomputes(stored) + assert not [e for e in await events(row.id, "log", "card") if e.get("code") == "metric_comparability_skipped"] diff --git a/backend/tests/test_analysis_tools.py b/backend/tests/test_analysis_tools.py index 88a96815..4f22c8a7 100644 --- a/backend/tests/test_analysis_tools.py +++ b/backend/tests/test_analysis_tools.py @@ -23,7 +23,7 @@ from app.db.base import SessionLocal from app.db.models import Artifact, DataSource, Run, RunEvent from app.engine.runner import run_manager from app.tools.analysis import CALL_ERROR_HEAD, REFUSED_CODE -from app.tools.builtin.analysis import ANALYSIS_METHOD_PROMPT, ARGS, MAX_CALLS, METHOD_GUIDE, NO_SCOPE +from app.tools.builtin.analysis import ANALYSIS_METHOD_PROMPT, ARGS, METHOD_GUIDE, NO_SCOPE from tests.fixtures.analysis_ops import FakeOps TOOL = "db_query__shop" @@ -173,8 +173,11 @@ def test_the_five_tools_take_no_thresholds_and_share_one_method(): assert {"mode", "by", "period", "base", "target", "measure", "numerator", "denominator", "product", "factors", "headline", "parent", "label", "hypothesis"} <= set(ARGS["decompose"].model_fields) assert {"basis", "grain", "lag"} <= set(ARGS["baseline"].model_fields) - assert {"reference", "pairs", "mode"} <= set(ARGS["differ"].model_fields) - assert {"base_query", "target_query"} <= set(ARGS["comparable"].model_fields) + # 参数名以算子为准(app/analysis 各算子文件头):差异检验的均值是 mean / sd / n 或 sum / sumsq / n,逐行是 value + assert {"reference", "pairs", "mode", "mean", "sd", "sum", "sumsq", "n", "value"} <= set(ARGS["differ"].model_fields) + assert {"base_query", "target_query", "measure", "grain"} <= set(ARGS["comparable"].model_fields) + # 运行当天、目录事实、下钻层数由系统填,哪个工具的参数里都没有 + assert not any({"today", "catalog", "depth"} & set(model.model_fields) for model in ARGS.values()) def test_unknown_parameters_and_thresholds_are_rejected_like_any_tool(): @@ -211,6 +214,22 @@ async def test_calling_it_outside_an_agent_says_where_it_belongs(): assert found.value.message == NO_SCOPE +def test_input_snapshots_carry_the_payload_as_is(): + """交给算子的一份输入:查询工具的 payload 原样(SQL 检查结果、「查询区间里这张表没有数据」、查询当时记下的遮罩,可比性 + 检查按它们判数据问题),加上出处和遮罩列;合并查询写明方言(在 SQLite 里执行)。""" + from app.engine.merge_query import MERGE_SOURCE + from app.tools.analysis import input_snapshot + + content = {"columns": ["region", "gmv"], "rows": [["华东", 1]], "sql": "SELECT …", "source": "shop", + "column_types": {"region": "text", "gmv": "number"}, "truncated": False, + "checks": [{"level": "error", "code": "fanout_sum"}], "no_data": [{"columns": ["gmv"]}], + "mask_columns": ["phone"], "masked_at_query": ["tel"]} + snap = input_snapshot(content, ref="Q1", artifact="a" * 64, node_id="bot", masked=["tel", "phone"]) + assert snap == {**content, "ref": "Q1", "artifact": "a" * 64, "node_id": "bot", "masked": ["tel", "phone"]} + merged = input_snapshot({**content, "source": MERGE_SOURCE}, ref="m", artifact="b" * 64, node_id="m") + assert merged["dialect"] == "sqlite" and merged["masked"] == [] + + # -------------------------------------------------------------------------- # 方法说明:绑了分析工具的 Agent 注入一次,模型调用节点、没绑的 Agent 不注入 # -------------------------------------------------------------------------- @@ -265,11 +284,15 @@ async def test_an_analysis_reads_this_nodes_query_and_is_recorded(monkeypatch, s assert snap["ref"] == "Q1" and snap["artifact"] == query_artifact and snap["node_id"] == "bot" assert snap["source"] == "shop" and snap["sql"] == SQL and snap["truncated"] is False assert snap["columns"] == ["region", "week", "gmv"] and snap["rows"][0] == ["华东", "2026-W41", 820.0] - assert snap["column_types"] == ["text", "text", "number"] and snap["masked"] == [] + # 查询工具的 payload 原样(工件库里那份),加上出处和遮罩列 + assert snap["column_types"] == {"region": "text", "week": "text", "gmv": "number"} and snap["masked"] == [] + assert snap["row_count"] == 6 and snap == {**artifact_store.load(query_artifact), "ref": "Q1", + "artifact": query_artifact, "node_id": "bot", "masked": []} # 阈值:没有发起时的快照,现读设置(没存过就是缺省) assert call["settings"] == contract.DEFAULTS assert set(call["context"]) == {"today", "probe", "catalogs", "facts"} - assert call["context"]["probe"] is None, "还没有 W1-a 的 make_prober 时不给探查" + # 探查器按这次运行固定的数据版本解析数据源(analysis.make_prober),剖析事实是按表取的函数(probe.profile_facts) + assert call["context"]["probe"].dialect("shop") == "sqlite" and callable(call["context"]["facts"]["shop"]) # 结论落成工件,tool.end 带着它;台账记一条 kind analysis(带 call_id),在那次查询之后 analysis_artifact = ends[1]["analysis_artifact"] @@ -353,7 +376,9 @@ async def test_the_number_of_analyses_per_node_is_capped(monkeypatch, shop): await run_graph(agent_graph(), options={contract.SETTINGS_KEY: {"max_calls": 1}}) assert len(ops.calls) == 1 capped = tool_messages(script)[2] - assert capped == CALL_ERROR_HEAD + MAX_CALLS.format(done=1, label=contract.LABELS["max_calls"], limit=1) + import app.analysis as analysis_ops + + assert capped == CALL_ERROR_HEAD + analysis_ops.exhausted({"max_calls": 1}).message # 阈值来自发起时的快照 assert ops.calls[0]["settings"]["max_calls"] == 1 diff --git a/backend/tests/test_metric_comparability.py b/backend/tests/test_metric_comparability.py index ad33d65b..2fa604ea 100644 --- a/backend/tests/test_metric_comparability.py +++ b/backend/tests/test_metric_comparability.py @@ -1,9 +1,12 @@ """口径卡的可比性挂钩(docs/design/analysis-round/00-总方案.md §3.4 第 3 条):一个指标的输入落在两份不同的查询快照上、 两份 SQL 同形只差字面量(本周对上周)时,口径卡执行时自动做一次可比性检查,结论落成工件、记在指标的 comparability 上。 -- 同形只差日期 → 有 comparability,早的那期是基期(和表达式里谁先写无关);同一对快照只查一次 +- 同形只差日期 → 有 comparability,早的那期是基期(和表达式里谁先写无关);同一对快照只查一次;原因按算子的 + analysis.comparable_reasons 拆 - SQL 结构不同(退款额 / 销售额)、两次是同一条 SQL → 不查、不记日志 -- 算子出错、算不了、还没有算子(第 1 波的 run 抛 NotImplementedError)→ 不挡口径卡,记一条 metric_comparability_skipped +- 算子出错、算不了、算子没有实现(NotImplementedError)→ 不挡口径卡,记一条 metric_comparability_skipped + +这里的算子是替身(tests/fixtures/analysis_ops.FakeOps);真算子跑一遍的见 tests/test_analysis_e2e.py。 """ from __future__ import annotations @@ -18,7 +21,7 @@ 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.nodes.metrics import COMPARABILITY_SKIPPED, comparability_reasons +from app.engine.nodes.metrics import COMPARABILITY_SKIPPED from app.engine.runner import run_manager from tests.fixtures.analysis_ops import FakeOps @@ -118,12 +121,23 @@ async def test_two_periods_of_the_same_query_get_a_comparability_check(monkeypat assert not [log for log in logs if log.get("code") == COMPARABILITY_SKIPPED] +#: 可比性结论的检查表(照算子的 comparable.table_of:检查项、结果、基期、本期、说明) +CHECKS = {"columns": ["check", "status", "base", "target", "note"], "labels": {}, + "rows": [["同口径", "通过", None, None, None], ["窗口长度", "通过", "7 天", "7 天", None], + ["整期", "不通过", None, None, "本期数据只到 2026-10-15,还差 3 天"], + ["星期构成", "提醒", None, None, "两段的周末天数不同:2 天对 1 天"]]} + + async def test_an_incomparable_pair_lists_its_reasons(monkeypatch): - FakeOps(monkeypatch, verdicts=["incomparable"]) + import app.analysis as analysis_ops + + FakeOps(monkeypatch, verdicts=["incomparable"], table=CHECKS) _, card, _ = await run_card(graph([{"id": "wow", "name": "销售额环比", "expression": WOW}])) found = by_id(card)["wow"]["comparability"] - assert found["verdict"] == "incomparable" and found["phrase"] == "两期不可比:本期只有 3 天数据" - assert found["reasons"] == ["本期只有 3 天数据"] + stored = artifact_store.load(found["artifact"]) + assert found == {"artifact": found["artifact"], "verdict": "incomparable", "phrase": stored["phrase"], + "reasons": analysis_ops.comparable_reasons(stored)} + assert found["reasons"] == ["本期数据只到 2026-10-15,还差 3 天", "两段的周末天数不同:2 天对 1 天"] async def test_different_structures_and_the_same_query_are_not_checked(monkeypatch): @@ -136,8 +150,9 @@ async def test_different_structures_and_the_same_query_are_not_checked(monkeypat assert not [log for log in [*logs, *logs2] if log.get("code") == COMPARABILITY_SKIPPED] -async def test_without_the_operator_the_card_still_runs(): - """第 1 波的 app.analysis.run 还抛 NotImplementedError:口径卡照常算,照实记一条没做成。""" +async def test_an_operator_that_is_not_there_does_not_block_the_card(monkeypatch): + """算子没有实现(合并前的骨架抛 NotImplementedError):口径卡照常算,照实记一条没做成。""" + FakeOps(monkeypatch, errors=[NotImplementedError("analysis.run 还没有实现")]) _, card, logs = await run_card(graph([{"id": "wow", "name": "销售额环比", "expression": WOW, "decimals": 4}])) wow = by_id(card)["wow"] assert "comparability" not in wow and wow["value"] == pytest.approx((450 + 300 - 900) / 900, abs=1e-4) @@ -157,12 +172,3 @@ async def test_failures_do_not_block_the_card(monkeypatch, ops_kw): assert "comparability" not in by_id(card)["wow"] [log] = [log for log in logs if log.get("code") == COMPARABILITY_SKIPPED] assert log["reason"] - - -def test_reasons_come_from_the_check_table_when_there_is_one(): - result = {"verdict": {"code": "conditional"}, "phrase": "两期有条件可比:两段的周末天数不同", - "table": {"columns": ["check", "status", "reason"], "labels": {}, - "rows": [["同口径", "pass", None], ["窗口长度", "warn", "窗口长度不同:7 天对 6 天"], - ["星期构成", "warn", "两段的周末天数不同:2 天对 1 天"], ["整期", "unknown", "核对不了"]]}} - assert comparability_reasons(result) == ["窗口长度不同:7 天对 6 天", "两段的周末天数不同:2 天对 1 天"] - assert comparability_reasons({"verdict": {"code": "comparable"}, "phrase": "两期可比"}) == []