From 690e855ea5257e47944ad02058e22de36bd96f26 Mon Sep 17 00:00:00 2001 From: ThearchyHelios Date: Wed, 21 Oct 2026 18:08:59 +0800 Subject: [PATCH] =?UTF-8?q?=E5=86=85=E7=BD=AE=E6=A8=A1=E6=9D=BF=20?= =?UTF-8?q?=E2=91=AA=E3=80=8C=E5=BC=82=E5=8A=A8=E5=88=86=E6=9E=90=EF=BC=88?= =?UTF-8?q?=E5=8F=AF=E8=BF=BD=E6=BA=AF=EF=BC=89=E3=80=8D=EF=BC=9A=E4=B8=A4?= =?UTF-8?q?=E4=B8=AA=E8=B0=83=E7=94=A8=E5=B7=A5=E5=85=B7=E8=8A=82=E7=82=B9?= =?UTF-8?q?=E5=8F=96=E6=95=B0=EF=BC=88=E6=95=B4=E4=BD=93=E6=8C=89=E5=91=A8?= =?UTF-8?q?=2026=20=E5=91=A8=E3=80=81=E6=8C=89=E5=9C=B0=E5=8C=BA=E4=B8=A4?= =?UTF-8?q?=E5=91=A8=EF=BC=8C=E6=9C=9F=E6=AC=A1=E5=9C=A8=20SQL=20=E9=87=8C?= =?UTF-8?q?=E6=8C=89=E6=AF=8F=E5=91=A8=E7=AC=AC=E4=B8=80=E5=A4=A9=E7=9A=84?= =?UTF-8?q?=E6=97=A5=E6=9C=9F=E7=AE=97=E5=87=BA=E6=9D=A5=E3=80=81=E6=95=B4?= =?UTF-8?q?=E4=BD=93=E6=8C=89=E5=91=A8=E5=80=92=E5=BA=8F=E6=8E=92=EF=BC=89?= =?UTF-8?q?=EF=BC=8C=E5=88=86=E6=9E=90=E5=8D=A1=E5=9B=BA=E5=AE=9A=E5=81=9A?= =?UTF-8?q?=E4=B8=A4=E9=A1=B9=EF=BC=88=E6=9C=AC=E5=91=A8=E7=9A=84=E5=8F=98?= =?UTF-8?q?=E5=8C=96=E6=94=BE=E5=9B=9E=E5=8E=86=E5=8F=B2=E7=9C=8B=E7=9A=84?= =?UTF-8?q?=E6=B3=A2=E5=8A=A8=E5=9F=BA=E7=BA=BF=E3=80=81=E6=8C=89=E5=9C=B0?= =?UTF-8?q?=E5=8C=BA=E7=9A=84=E5=8F=AF=E5=8A=A0=E5=8F=98=E5=8C=96=E6=8B=86?= =?UTF-8?q?=E8=A7=A3=EF=BC=8C=E6=80=BB=E6=95=B0=E5=8F=96=E6=95=B4=E4=BD=93?= =?UTF-8?q?=E6=8C=89=E5=91=A8=E7=9A=84=E7=AC=AC=200=E3=80=811=20=E8=A1=8C?= =?UTF-8?q?=E6=A0=B8=E5=AF=B9=E9=97=AD=E5=90=88=EF=BC=9B=E9=98=88=E5=80=BC?= =?UTF-8?q?=E4=B8=8D=E5=86=99=E6=AD=BB=E3=80=81=E5=8F=96=E8=AE=BE=E7=BD=AE?= =?UTF-8?q?=EF=BC=89=EF=BC=8C=E6=8A=A5=E5=91=8A=E6=92=B0=E5=86=99=E8=8A=82?= =?UTF-8?q?=E7=82=B9=E6=8C=89=E5=8F=97=E7=AE=A1=E7=BA=A7=E5=88=AB=E5=86=99?= =?UTF-8?q?=E6=98=8E=E6=9C=80=E4=B8=A5=E7=9A=84=E5=87=A0=E9=A1=B9=EF=BC=88?= =?UTF-8?q?=E5=90=AB=E5=88=86=E6=9E=90=E8=AF=B4=E6=B3=95=E6=A0=B8=E5=AF=B9?= =?UTF-8?q?=E4=B8=A5=E6=A0=BC=EF=BC=89=EF=BC=8C=E5=87=BA=E5=8F=A3=E6=8C=89?= =?UTF-8?q?=E6=8A=A5=E5=91=8A=E6=9D=A5=E8=87=AA=20+=20=E5=8D=95=E5=85=83?= =?UTF-8?q?=E6=A0=BC=E5=BC=95=E7=94=A8=E7=9A=84=E5=A5=91=E7=BA=A6=E5=87=BA?= =?UTF-8?q?=E5=85=B7=E3=80=82=E6=8B=BF=E6=9D=A5=E5=B0=B1=E8=83=BD=E6=8C=89?= =?UTF-8?q?=E5=8F=97=E7=AE=A1=E7=BA=A7=E5=88=AB=E5=8F=91=E5=B8=83=E3=80=82?= =?UTF-8?q?=E7=95=8C=E9=9D=A2=E6=96=87=E6=A1=88=E7=99=BD=E5=90=8D=E5=8D=95?= =?UTF-8?q?=E5=8A=A0=E4=B8=80=E6=9D=A1=EF=BC=9A=E6=A8=A1=E6=9D=BF=20SQL=20?= =?UTF-8?q?=E9=87=8C=E4=B8=AD=E6=96=87=E5=88=97=E5=88=AB=E5=90=8D=E6=8C=89?= =?UTF-8?q?=20SQLite=20=E5=8A=A0=E7=9A=84=E5=8F=8C=E5=BC=95=E5=8F=B7?= =?UTF-8?q?=E6=98=AF=20SQL=20=E8=AF=AD=E6=B3=95=E3=80=82=E6=B5=8B=E8=AF=95?= =?UTF-8?q?=EF=BC=9A=E7=94=BB=E5=B8=83=E6=A0=A1=E9=AA=8C=E3=80=81=E5=8F=97?= =?UTF-8?q?=E7=AE=A1=E5=8F=91=E5=B8=83=E6=A3=80=E6=9F=A5=E6=B2=A1=E6=9C=89?= =?UTF-8?q?=E9=94=99=E8=AF=AF=EF=BC=88=E6=A8=A1=E6=9D=BF=20=E2=91=A8=20?= =?UTF-8?q?=E4=B8=80=E5=B9=B6=E6=A0=B8=EF=BC=89=E3=80=81=E6=8E=A5=E4=B8=80?= =?UTF-8?q?=E4=BB=BD=E8=99=9A=E6=9E=84=E7=9A=84=E5=9B=9B=E5=9C=B0=E5=8C=BA?= =?UTF-8?q?=2026=20=E5=91=A8=E9=9B=B6=E5=94=AE=E5=BA=93=E4=BB=8E=E5=A4=B4?= =?UTF-8?q?=E8=BF=90=E8=A1=8C=EF=BC=8C=E4=B8=A4=E9=A1=B9=E9=83=BD=E5=81=9A?= =?UTF-8?q?=E6=88=90=EF=BC=88=E6=98=8E=E6=98=BE=E8=B6=85=E5=87=BA=E5=B8=B8?= =?UTF-8?q?=E8=A7=84=E6=B3=A2=E5=8A=A8=E3=80=81=E4=B8=BB=E8=A6=81=E6=9D=A5?= =?UTF-8?q?=E8=87=AA=E5=8D=8E=E4=B8=9C=EF=BC=89=E3=80=81=E5=AE=8C=E6=95=B4?= =?UTF-8?q?=E5=87=BA=E5=85=B7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/app/seed.py | 58 +++++++++ backend/tests/test_template_anomaly.py | 156 +++++++++++++++++++++++++ scripts/copy-allow.json | 6 + 3 files changed, 220 insertions(+) create mode 100644 backend/tests/test_template_anomaly.py diff --git a/backend/app/seed.py b/backend/app/seed.py index 213bfba2..478a6fb3 100644 --- a/backend/app/seed.py +++ b/backend/app/seed.py @@ -338,6 +338,64 @@ TEMPLATES: list[dict[str, Any]] = [ ], "edges": [_e("start", "review"), _e("review", "approved", "approved"), _e("review", "rejected", "rejected")], }, + { + # 分析能力一轮(docs/design/analysis-round/00-总方案.md §3.7):模板定骨架——分析卡固定做哪几项分析写在图里,判定由 + # 系统按平台的方法和阈值算、系统写判定语,报告撰写节点只引用。照模板 ⑨ 的写法,拿来就能按受管级别发布 + "name": "⑪ 异动分析(可追溯)", + "description": "周销售额的异动分析,每个判断都可追溯:两个「调用工具」节点取数(整体按周、按地区两周),分析卡固定做" + "两项分析——这一周的变化在不在常规波动范围内、变化主要来自哪个地区(拆开后各段之和和总数核对闭合),报告撰写节点只引用" + "判定语和数,判断词逐句核对。使用前请接入名为 shop 的数据源(含 orders 表的 order_date、region、amount 列),或上传" + "同名表格。SQL 按 SQLite 的写法,按周汇总时期次取每周第一天(周一)的日期;接别的库时换成那个库的写法。报告撰写节点已按" + "受管级别设置核对规则。", + "tags": ["分析", "出具", "周报", "证据"], + "nodes": [ + _n("start", "input", "周期", fields=[ + {"name": "week", "default": "2026-10-12", "description": "本周的第一天(周一)"}, + {"name": "prev_week", "default": "2026-10-05", "description": "上周的第一天(周一)"}]), + # 期次在 SQL 里由日期列算出来(每周第一天的日期):分析卡据此认出每一期覆盖哪几天,本期过没过完核对得了。 + # 倒序排:第 0 行是本周、第 1 行是上周,变化拆解按这两格核对闭合 + _n("weekly", "tool", "整体按周(26 周)", tool="db_query__shop", args={"sql": ( + "SELECT date(order_date, '-6 days', 'weekday 1') AS \"周\", SUM(amount) AS \"销售额\"\n" + "FROM orders\n" + "WHERE order_date >= date('{{ input.week }}', '-175 days')" + " AND order_date < date('{{ input.week }}', '+7 days')\n" + "GROUP BY date(order_date, '-6 days', 'weekday 1')\n" + "ORDER BY \"周\" DESC")}), + _n("by_region", "tool", "按地区两周", tool="db_query__shop", args={"sql": ( + "SELECT region AS \"地区\", date(order_date, '-6 days', 'weekday 1') AS \"周\", SUM(amount) AS \"销售额\"\n" + "FROM orders\n" + "WHERE order_date >= '{{ input.prev_week }}' AND order_date < date('{{ input.week }}', '+7 days')\n" + "GROUP BY region, date(order_date, '-6 days', 'weekday 1')\n" + "ORDER BY \"地区\", \"周\"")}), + # 阈值不写在图里:取设置里的分析一组(发起时快照);要固定阈值时在这张卡上写「阈值覆盖」 + _n("check", "analysis", "异动分析卡", items=[ + {"id": "wow", "op": "baseline", "from": "weekly", + "params": {"period": "周", "measure": "销售额", "target": "{{ input.week }}", "grain": "week", + "label": "销售额"}, + "hypothesis": "本周销售额的变化在不在常规波动范围内"}, + {"id": "where", "op": "decompose", "mode": "additive", "from": "by_region", + "params": {"by": ["地区"], "period": "周", "base": "{{ input.prev_week }}", "target": "{{ input.week }}", + "measure": "销售额", "headline": {"base": "weekly.r1.销售额", "target": "weekly.r0.销售额"}, + "label": "销售额"}, + "hypothesis": "本周销售额的变化主要来自哪个地区"}, + ]), + # 受管出具要求报告撰写节点写明最严的几项(发布门禁 G3,含分析说法核对设为严格),模板照着写 + _n("write", "report", "报告撰写", numbers="strict", on_violation="fail", claims="require_citation", + analysis_claims="strict", + system="你是经营分析撰写人:结论先行,判断照分析结论写,只写有数据支撑的内容。", + instructions="为 {{ input.week }} 开始的这一周写一份销售额异动分析:先用一句话说本周销售额和上周比变了多少," + "再按分析卡的两项结论说这个变化在不在常规波动范围内、主要来自哪个地区,最后用一张表列出各地区的" + "变化。判断照分析结论写、直接引用判定语,没有分析结论的判断不下;有一项没有做成的,照实写原因。"), + # 图里没有口径卡:报告直接引用查询和分析结论的格,契约开「单元格引用」(受管级别要写明) + _n("done", "output", "出具", + fields=[{"name": "异动分析", "value": "{{ nodes.write.text }}"}], + contract={"report_from": "write", "cells": True, "strict": True}), + ], + "edges": [ + _e("start", "weekly"), _e("weekly", "by_region"), _e("by_region", "check"), + _e("check", "write"), _e("write", "done"), + ], + }, ] diff --git a/backend/tests/test_template_anomaly.py b/backend/tests/test_template_anomaly.py new file mode 100644 index 00000000..7877941e --- /dev/null +++ b/backend/tests/test_template_anomaly.py @@ -0,0 +1,156 @@ +"""内置模板「⑪ 异动分析(可追溯)」:取数(整体按周 26 周、按地区两周)→ 分析卡(波动基线 + 带总数的变化拆解)→ 报告撰写 +(分析说法严格核对)→ 出具(引用模式,单元格引用)。 + +模板既是示范也是活文档(同 tests/test_template_traceable.py 守模板 ⑨):要通过画布校验、能直接按受管级别发布(发布检查 +没有错误),接上一份虚构的零售库 shop(orders(order_date, region, amount):华东、华南、华北、西南四个地区,26 周每天一笔) +要能从头到尾运行、两项分析都做成、完整出具。期次是每周第一天的日期,分析卡认得出每一期覆盖哪几天,本期过没过完核对得了。 +""" +from __future__ import annotations + +import asyncio +import copy +import sqlite3 +from datetime import date, timedelta + +import pytest +from langchain_core.messages import AIMessage +from sqlalchemy import select + +from app.db.base import SessionLocal +from app.db.models import Run, RunEvent +from app.engine.governance import lint_for_publish, publish_issues +from app.engine.runner import run_manager +from app.engine.schema import GraphSpec, validate_graph +from app.seed import TEMPLATES + +NAME = "⑪ 异动分析(可追溯)" +REGIONS = {"华东": 400, "华南": 300, "华北": 200, "西南": 100} + + +def template() -> dict: + return next(t for t in TEMPLATES if t["name"] == NAME) + + +def graph() -> dict: + t = template() + return copy.deepcopy({"nodes": t["nodes"], "edges": t["edges"]}) + + +def test_the_template_is_listed_once_after_the_others(): + names = [t["name"] for t in TEMPLATES] + assert names.count(NAME) == 1 and names[-1] == NAME + assert any(n.startswith("⑨ 可追溯周报") for n in names) + + +def test_the_template_passes_validation(): + report = validate_graph(GraphSpec.model_validate(graph())) + assert report.ok, [i.message for i in report.issues if i.level == "error"] + types = {n["id"]: n["type"] for n in template()["nodes"]} + assert list(types.values()).count("analysis") == 1 and "metrics" not in types.values() + card = next(n for n in template()["nodes"] if n["type"] == "analysis")["data"]["config"] + assert [(i["id"], i["op"], i.get("mode")) for i in card["items"]] == [("wow", "baseline", None), + ("where", "decompose", "additive")] + assert card["items"][1]["params"]["headline"] == {"base": "weekly.r1.销售额", "target": "weekly.r0.销售额"} + assert "overrides" not in card, "阈值取设置,模板不写死" + + +def test_the_template_publishes_as_governed(): + """拿来就能按受管级别发布:发布检查(和真正发布同一套)没有错误,证据门禁 G1–G5 和分析卡的受管规则都过。""" + spec = GraphSpec.model_validate(graph()) + errors = [i for i in publish_issues(spec, level="governed") if i.level == "error"] + assert errors == [], [i.message for i in errors] + report = next(n for n in template()["nodes"] if n["type"] == "report")["data"]["config"] + assert {k: report[k] for k in ("numbers", "on_violation", "claims", "analysis_claims")} == { + "numbers": "strict", "on_violation": "fail", "claims": "require_citation", "analysis_claims": "strict"} + out = next(n for n in template()["nodes"] if n["type"] == "output")["data"]["config"] + assert out["contract"] == {"report_from": "write", "cells": True, "strict": True} + assert out["fields"] == [{"name": "异动分析", "value": "{{ nodes.write.text }}"}] + + +def test_template_nine_also_publishes_as_governed(): + """模板 ⑨ 在第 1 波补了分析说法核对设为严格:同样直接按受管级别发布,没有错误。""" + nine = next(t for t in TEMPLATES if t["name"].startswith("⑨")) + spec = GraphSpec.model_validate(copy.deepcopy({"nodes": nine["nodes"], "edges": nine["edges"]})) + errors = [i for i in publish_issues(spec, level="governed") if i.level == "error"] + assert errors == [], [i.message for i in errors] + assert not [i for i in lint_for_publish(spec, level="governed").issues if i.code == "governed.report_policy"] + + +# -------------------------------------------------------------------------- +# 接一份虚构的零售库从头到尾运行 +# -------------------------------------------------------------------------- + + +def _last_week() -> date: + """最近一个已经过完的整周的周一(运行当天往前,本周没过完)。""" + today = date.today() + return today - timedelta(days=today.weekday() + 7) + + +@pytest.fixture +async def shop(tmp_path, scratch_source): + """26 周、每个地区每天一笔:各周略有起伏;最后一周(最近一个过完的整周)华东掉了四成,别的地区照常。""" + monday = _last_week() + first = monday - timedelta(weeks=25) + path = tmp_path / "shop.db" + db = sqlite3.connect(path) + db.executescript("CREATE TABLE orders (id INTEGER PRIMARY KEY, order_date TEXT, region TEXT, amount REAL);") + rows, n = [], 0 + for week in range(26): + for day in range(7): + when = first + timedelta(weeks=week, days=day) + for k, (region, base) in enumerate(REGIONS.items()): + amount = base + (week * 7 + day * 3 + k * 5) % 11 + if week == 25 and region == "华东": + amount = round(amount * 0.6, 2) + n += 1 + rows.append((n, when.isoformat(), region, float(amount))) + db.executemany("INSERT INTO orders VALUES (?, ?, ?, ?)", rows) + db.commit() + db.close() + await scratch_source("shop", path) + return monday + + +@pytest.fixture +async def engine_up(): + await run_manager.setup() + yield + await run_manager.shutdown() + + +async def _wait(run_id: str) -> Run: + 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 ("succeeded", "failed") and row.manifest_seq is not None: + return row + raise AssertionError(f"run {run_id} 没有结束:{row.status}") + + +async def test_the_template_runs_end_to_end_and_issues_formally(engine_up, shop, monkeypatch): + from app.providers import mock_model + + text = ("## 本周异动\n\n本周销售额 [[v:Q1.r0.销售额]],上周 [[v:Q1.r1.销售额]]。[[see:Q1]]\n\n" + "[[a:A1]]。[[see:A1]]\n\n[[a:A2]]。[[see:A2]]\n\n[[table:A2 cols=地区,base,target,delta]]") + monkeypatch.setattr(mock_model.MockChatModel, "_decide", lambda self, messages: AIMessage(content=text)) + monday = shop + run = await run_manager.start(graph=graph(), input_payload={ + "week": monday.isoformat(), "prev_week": (monday - timedelta(weeks=1)).isoformat()}) + row = await _wait(run.id) + assert row.status == "succeeded", row.error + async with SessionLocal() as session: + ends = [e.data for e in (await session.execute( + select(RunEvent).where(RunEvent.run_id == run.id, RunEvent.type == "analysis.end") + .order_by(RunEvent.seq))).scalars()] + by_item = {e["item"]: e for e in ends} + # 两项都做成了:本周的变化放回 25 周的历史里看(期次认得出、本期过完了),华东主导了这次下降(各段之和和总数闭合) + assert by_item["wow"]["status"] == "ok", by_item["wow"] + assert by_item["wow"]["verdict"] in ("outside_band", "far_outside_band"), by_item["wow"] + assert (by_item["where"]["status"], by_item["where"]["verdict"]) == ("ok", "driver"), by_item["where"] + assert "主要来自华东" in by_item["where"]["phrase"] + issuance = row.output["_issuance"] + assert issuance["mode"] == "citations" and issuance["tier"] == "formal", issuance + report = row.output["异动分析"] + assert by_item["where"]["phrase"] in report and by_item["wow"]["phrase"] in report diff --git a/scripts/copy-allow.json b/scripts/copy-allow.json index ca847547..f6339f03 100644 --- a/scripts/copy-allow.json +++ b/scripts/copy-allow.json @@ -55,6 +55,12 @@ "rule": "半角标点", "reason": "模板里「写代码」节点的系统提示(写给模型),print(json.dumps(...)) 是代码示例,半角括号和省略号是 Python 语法" }, + { + "file": "backend/app/seed.py", + "text": "AS \"周\"", + "rule": "半角标点", + "reason": "模板 ⑪「异动分析(可追溯)」两个取数节点的 SQL:结果列按节点手册的规矩起中文别名、SQLite 的写法加双引号(AS \"周\"),引号和逗号是 SQL 语法;中文列名让分析卡的判定语写「分散在各地区」、报告的表头读得懂" + }, { "file": "backend/app/api/copilot.py", "text": "报告按 [[i:字段名]]",