Files
AgentLab/backend/tests/test_ledger_draftplan.py
T
ThearchyHelios 9d8b41430e 完善口径管理、主动调查与后台任务,修复通用问题
- 完善口径编辑、只读试运行、统一质量验证与执行语义指纹
- 增加可执行指标约束、有预算的主动调查和过程评测
- 提取发布检查、证据读取与起草服务,支持任务停止、重试和检查点恢复
- 增加版本传递影响分析,修复并发覆盖、敏感值遮罩、异常评分及页面状态问题
- 修复 SQLite 复合查询追溯和 microVM 可选依赖缺失处理
- 纳入当前设计文档及部署配置调整

验证:相关主回归 3822 项通过,4 项跳过、8 项预期失败;补充回归通过。
前端构建、文案检查及 git diff --check 通过。
全量检查仍有环境权限、PostgreSQL 可选依赖和既有部署配置、SQLite 文件基线差异导致的未通过项。
2026-11-06 17:14:45 +08:00

394 lines
20 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""口径一次起草的纯逻辑(app/ledger/draftplan.py)。
- SQL 只收单条只读 SELECT:多语句、注释后藏的第二条、写操作、目录外的表一律拒;
- 指标表达式必须是 cell(nodes.fetch, 0, '列名');引用核不上的整条丢弃;
- build_graph 两次调用逐字一致,且能过 GraphSpec / validate_graph;
- plan 注入假模型(不发真实请求),材料里有表目录和原文。
"""
from __future__ import annotations
import json
from types import SimpleNamespace
import pytest
from app.engine.schema import GraphSpec, validate_graph
from app.ledger import draftplan
CHUNK = "销售额按已支付订单的支付时间归期统计,退款订单不计入销售额。"
CHUNKS = {"c1": {"ordinal": 1, "content": CHUNK, "document_id": "d1"}}
TABLES = {"orders", "customers"}
METRIC = {
"id": "m1",
"name": "销售额",
"synonyms": ["gmv"],
"unit": "元",
"decimals": 2,
"expression": "cell(nodes.fetch, 0, 'gmv')",
"definition": {"text": "已支付订单的销售额", "scope": "全部", "exclusions": [], "owner": "运营",
"effective_from": "2026-01-01",
"citations": [{"chunk": "c1", "quote": "销售额按已支付订单的支付时间归期统计"}]},
"open_questions": [],
}
def raw(**extra):
body = {
"entry_fields": [{"name": "start", "type": "string", "required": True, "default": "2026-09-01",
"description": "开始日期"}],
"sql": "SELECT SUM(amount) AS gmv FROM orders WHERE paid_at >= '{{ input.start }}'",
"metrics": [dict(METRIC)],
"open_questions": [],
}
body.update(extra)
return body
def sql_problem(sql: str) -> str | None:
plan, problems = draftplan.normalize_plan(raw(sql=sql), CHUNKS, allowed_tables=TABLES)
assert plan["sql"] == ""
reasons = [p["reason"] for p in problems if p["name"] == "sql"]
assert reasons, problems
return reasons[0]
# ==========================================================================
# normalize_plan:SQL 的硬规则
# ==========================================================================
def test_a_read_only_select_over_the_catalog_is_kept():
plan, problems = draftplan.normalize_plan(raw(), CHUNKS, allowed_tables=TABLES)
assert plan["sql"].endswith("'{{ input.start }}'")
assert [m["id"] for m in plan["metrics"]] == ["m1"]
assert plan["entry_fields"][0]["name"] == "start"
assert not [p for p in problems if p["name"] == "sql"]
def test_multiple_statements_and_a_hidden_second_statement_are_refused():
assert "只能是一条语句" in sql_problem(
"SELECT SUM(amount) AS gmv FROM orders; DROP TABLE orders")
# 注释后藏的第二条也解析成第二条语句
assert "只能是一条语句" in sql_problem(
"SELECT SUM(amount) AS gmv FROM orders; -- 收尾\nDELETE FROM orders")
def test_writes_and_ddl_are_refused():
assert "只读 SELECT" in sql_problem("DELETE FROM orders")
assert "只读 SELECT" in sql_problem("UPDATE orders SET amount = 0")
assert "只读 SELECT" in sql_problem("DROP TABLE orders")
def test_tables_outside_the_catalog_are_refused():
reason = sql_problem("SELECT * FROM secrets")
assert "secrets" in reason and "数据目录" in reason
# CTE 名字不是表;真正的表没在目录里照样拒
reason = sql_problem("WITH x AS (SELECT * FROM secrets) SELECT * FROM x")
assert "secrets" in reason
def test_a_cte_over_a_catalog_table_is_allowed():
plan, problems = draftplan.normalize_plan(
raw(sql="WITH paid AS (SELECT amount FROM orders) SELECT SUM(amount) AS gmv FROM paid"),
CHUNKS, allowed_tables=TABLES)
assert plan["sql"].startswith("WITH paid")
assert not [p for p in problems if p["name"] == "sql"]
def test_an_undeclared_template_field_invalidates_the_sql():
plan, problems = draftplan.normalize_plan(
raw(sql="SELECT SUM(amount) AS gmv FROM orders WHERE paid_at >= '{{ input.nope }}'"),
CHUNKS, allowed_tables=TABLES)
assert plan["sql"] == ""
assert any("nope" in p["reason"] for p in problems)
# ==========================================================================
# normalize_plan:指标
# ==========================================================================
def test_a_bad_expression_drops_only_that_metric():
plan, problems = draftplan.normalize_plan(
raw(metrics=[{**METRIC, "expression": "SUM(amount)"}, {**dict(METRIC), "id": "m2"}]),
CHUNKS, allowed_tables=TABLES)
assert [m["id"] for m in plan["metrics"]] == ["m2"]
assert any(p["name"] == "销售额" and "cell(nodes.fetch, 0" in p["reason"] for p in problems)
def test_a_metric_whose_quotes_do_not_match_the_source_is_dropped():
plan, problems = draftplan.normalize_plan(
raw(metrics=[{**METRIC, "definition": {**METRIC["definition"],
"citations": [{"chunk": "c1", "quote": "原文里没有这句"}]}}]),
CHUNKS, allowed_tables=TABLES)
assert plan["metrics"] == []
assert any(p["name"] == "销售额" and "引用" in p["reason"] for p in problems)
assert any(p["name"] == "metrics" for p in problems)
def test_metric_ids_are_unique_and_entry_fields_are_normalized():
plan, _problems = draftplan.normalize_plan(
raw(entry_fields=[{"name": "start", "type": "date", "required": 1, "default": "2026-09-01"}],
metrics=[dict(METRIC), {**dict(METRIC), "name": "订单数"}]),
CHUNKS, allowed_tables=TABLES)
assert [m["id"] for m in plan["metrics"]] == ["m1", "m2"]
assert plan["entry_fields"][0]["type"] == "string"
assert plan["entry_fields"][0]["required"] is True
def test_document_drafting_fields_are_kept_and_normalized():
"""文档起草里的粒度、时间字段、分子分母、维度、过滤条件不能在校验时丢掉:草稿页要逐行展示。"""
metric = {**METRIC, "grain": " 日 ", "time_field": " 支付时间 ", "numerator": "退款订单数",
"denominator": "已支付订单数", "dimensions": ["门店", "", 3, " 区域 "],
"filters": ["只算已支付", " ", None]}
plan, problems = draftplan.normalize_plan(raw(metrics=[metric]), CHUNKS, allowed_tables=TABLES)
got = plan["metrics"][0]
assert got["grain"] == "日" and got["time_field"] == "支付时间"
assert got["numerator"] == "退款订单数" and got["denominator"] == "已支付订单数"
assert got["dimensions"] == ["门店", "区域"] and got["filters"] == ["只算已支付"]
# METRIC_FIELDS 要列全:校验后的指标带着这些业务字段(前端按它画)
assert set(draftplan.METRIC_FIELDS) >= {"grain", "time_field", "numerator", "denominator",
"dimensions", "filters"}
assert set(got) >= set(draftplan.METRIC_FIELDS)
assert not [p for p in problems if p["name"] == "销售额"]
def test_empty_document_drafting_fields_do_not_leave_empty_string_keys():
"""没写的业务字段不留空字符串:没有内容就没有这个键(dimensions、filters 里的空项也丢掉)。"""
metric = {**METRIC, "grain": " ", "time_field": "", "numerator": None, "denominator": [],
"dimensions": ["", " "], "filters": []}
plan, _problems = draftplan.normalize_plan(raw(metrics=[metric]), CHUNKS, allowed_tables=TABLES)
got = plan["metrics"][0]
assert not [key for key in ("grain", "time_field", "numerator", "denominator", "dimensions", "filters")
if key in got]
# ==========================================================================
# build_graph
# ==========================================================================
def test_build_graph_is_deterministic_and_passes_validation():
plan, _ = draftplan.normalize_plan(raw(), CHUNKS, allowed_tables=TABLES)
plan["datasource"] = "shop"
first = draftplan.build_graph(plan, name="销售额口径", question="上周销售额")
second = draftplan.build_graph(plan, name="销售额口径", question="上周销售额")
assert first == second
assert [node["id"] for node in first["nodes"]] == ["start", "fetch", "card", "write", "out"]
fetch = next(node for node in first["nodes"] if node["id"] == "fetch")
assert fetch["data"]["config"] == {"tool": "db_query__shop", "args": {"sql": plan["sql"]}}
card = next(node for node in first["nodes"] if node["id"] == "card")
assert card["data"]["config"]["metrics"][0]["id"] == "m1"
contract = next(node for node in first["nodes"] if node["id"] == "out")["data"]["config"]["contract"]
assert contract["metrics_from"] == "card" and contract["required"] == ["m1"]
report = next(node for node in first["nodes"] if node["id"] == "write")["data"]["config"]
assert report["numbers"] == "strict" and report["metrics_from"] == ["card"]
result = validate_graph(GraphSpec.model_validate(first))
assert result.ok, [issue.message for issue in result.issues if issue.level == "error"]
# ==========================================================================
# build_graph:多组
# ==========================================================================
START = {"name": "start", "type": "string", "required": True, "default": "2026-09-01",
"description": "开始日期"}
def block(sql: str, metrics: list[dict], fields: list[dict] | None = None) -> dict:
return {"sql": sql, "entry_fields": [dict(f) for f in ([START] if fields is None else fields)],
"metrics": [dict(m) for m in metrics]}
def test_build_graph_gives_each_group_its_own_fetch_node_and_rewrites_expressions():
"""一张草稿图挂多个取数节点:第 i 组指标读 fetch_i,指标 id 跨组重排,出口要求全部指标。"""
first = block("SELECT SUM(amount) AS gmv FROM orders",
[dict(METRIC), {**METRIC, "id": "m2", "name": "订单数",
"expression": "cell(nodes.fetch, 0, 'cnt')"}])
second = block("SELECT COUNT(DISTINCT customer_id) AS buyers FROM orders",
[{**METRIC, "id": "m1", "name": "买家数",
"expression": "cell(nodes.fetch, 0, 'buyers')"}])
graph = draftplan.build_graph([first, second], name="销售口径", question="上周", datasource="shop")
assert draftplan.build_graph([first, second], name="销售口径", question="上周",
datasource="shop") == graph
assert [node["id"] for node in graph["nodes"]] == ["start", "fetch_1", "fetch_2", "card",
"write", "out"]
assert [edge["source"] for edge in graph["edges"]] == ["start", "fetch_1", "fetch_2", "card",
"write"]
nodes = {node["id"]: node for node in graph["nodes"]}
assert nodes["fetch_1"]["data"]["config"] == {
"tool": "db_query__shop", "args": {"sql": first["sql"]}}
assert nodes["fetch_2"]["data"]["config"]["args"]["sql"] == second["sql"]
metrics = nodes["card"]["data"]["config"]["metrics"]
assert [m["id"] for m in metrics] == ["m1", "m2", "m3"]
assert [m["expression"] for m in metrics] == ["cell(nodes.fetch_1, 0, 'gmv')",
"cell(nodes.fetch_1, 0, 'cnt')",
"cell(nodes.fetch_2, 0, 'buyers')"]
contract = nodes["out"]["data"]["config"]["contract"]
assert contract == {"report_from": "write", "metrics_from": "card",
"required": ["m1", "m2", "m3"]}
result = validate_graph(GraphSpec.model_validate(graph))
assert result.ok, [issue.message for issue in result.issues if issue.level == "error"]
def test_build_graph_leaves_expressions_that_already_carry_an_index_alone():
"""已经写成 nodes.fetch_2 的式子不能再被改坏(改成了 nodes.fetch_1_2 这类)。"""
first = block("SELECT SUM(amount) AS gmv FROM orders",
[{**METRIC, "expression": "cell(nodes.fetch_2, 0, 'gmv')"}])
second = block("SELECT 1 AS gmv FROM orders", [dict(METRIC)])
graph = draftplan.build_graph([first, second], name="x")
metrics = next(node for node in graph["nodes"] if node["id"] == "card")["data"]["config"]["metrics"]
assert metrics[0]["expression"] == "cell(nodes.fetch_2, 0, 'gmv')"
assert metrics[1]["expression"] == "cell(nodes.fetch_2, 0, 'gmv')"
def test_build_graph_accepts_one_block_as_a_single_group():
"""老调用传一份草案(含 datasource)也要能建图:取数节点仍叫 fetch。"""
graph = draftplan.build_graph({**block("SELECT 1 AS gmv FROM orders", [dict(METRIC)]),
"datasource": "shop"}, name="销售额口径")
assert [node["id"] for node in graph["nodes"]] == ["start", "fetch", "card", "write", "out"]
fetch = next(node for node in graph["nodes"] if node["id"] == "fetch")
assert fetch["data"]["config"]["tool"] == "db_query__shop"
card = next(node for node in graph["nodes"] if node["id"] == "card")["data"]["config"]
assert card["metrics"][0]["expression"] == "cell(nodes.fetch, 0, 'gmv')"
def test_build_graph_merges_entry_fields_and_widens_conflicts():
"""同名字段在各组里声明不一致时按更保守的一边合并,不抛错:一个字段的差异不该让整份草稿作废。"""
same = block("SELECT 1 AS gmv FROM orders", [dict(METRIC)])
merged = draftplan.build_graph([same, block("SELECT 2 AS cnt FROM orders", [{**METRIC, "id": "m2"}])],
name="x")
fields = next(node for node in merged["nodes"] if node["id"] == "start")["data"]["config"]["fields"]
assert [f["name"] for f in fields] == ["start"]
not_required = block("SELECT 1 AS gmv FROM orders", [dict(METRIC)],
fields=[{**START, "required": False}])
fields, notes = draftplan.merge_entry_fields([same, not_required])
assert fields[0]["required"] is True
assert any("start" in note and "必填" in note for note in notes)
typed = block("SELECT 1 AS gmv FROM orders", [dict(METRIC)],
fields=[{**START, "type": "integer"}])
fields, notes = draftplan.merge_entry_fields([same, typed])
assert fields[0]["type"] == "string"
assert any("start" in note and "类型" in note for note in notes)
assert draftplan.build_graph([same, typed], name="x")["nodes"]
# ==========================================================================
# plan:注入假模型
# ==========================================================================
class FakeModel:
model_name = "fake-plan-model"
def __init__(self, text: str) -> None:
self.text = text
self.messages: list = []
async def ainvoke(self, messages):
self.messages = messages
return SimpleNamespace(content=self.text)
async def test_plan_hands_the_model_the_table_catalog_and_the_source_text():
model = FakeModel(json.dumps({**raw(), "model_id": "ignored"}, ensure_ascii=False))
result = await draftplan.plan(
None, docs=[{"document_id": "d1", "title": "销售口径.md"}], chunks=CHUNKS,
guidance="只看已支付", datasource={"name": "shop", "type": "sqlite"},
tables_text="表 orders(订单)\n amount 金额", model=model)
assert result["model_id"] == "fake-plan-model"
assert result["sql"].startswith("SELECT SUM(amount)")
assert result["metrics"][0]["name"] == "销售额"
human = model.messages[-1].content
assert "表 orders" in human and "amount 金额" in human
assert CHUNK in human and "只看已支付" in human
async def test_plan_refuses_when_no_draft_model_is_available(monkeypatch):
from app.data import recipe_ai
from app.ledger import docdraft
async def resolve(_session):
return None, "", SimpleNamespace(available=False, reason="还没有接入可用的起草模型")
monkeypatch.setattr(recipe_ai, "resolve_draft_model", resolve)
with pytest.raises(docdraft.DraftUnavailable, match="起草模型"):
await draftplan.plan(None, docs=[], chunks={})
async def test_plan_salvages_metrics_when_the_reply_is_cut_off():
"""SQL 把输出额度吃光、JSON 在指标数组里断掉:抢回完整的指标和 SQL,并照实说「回复被截断」。"""
text = ('{"entry_fields": [], "sql": "SELECT SUM(amount) AS gmv FROM orders", "metrics": ['
+ json.dumps(raw()["metrics"][0], ensure_ascii=False) + ', {"id": "m2", "name": "写到这里就断了')
result = await draftplan.plan(None, docs=[], chunks={}, model=FakeModel(text))
assert result["salvaged"] is True
assert len(result["metrics"]) == 1 and result["metrics"][0]["name"] == "销售额"
assert result["sql"].startswith("SELECT SUM(amount)")
assert "截断" in result["diagnostics"]
async def test_plan_explains_what_came_back_when_metrics_is_not_a_list():
"""指标为空时不能只说「没有一条通过校验」:要说出模型到底交回了什么形状。"""
model = FakeModel(json.dumps({"sql": "SELECT 1", "metrics": {}}, ensure_ascii=False))
result = await draftplan.plan(None, docs=[], chunks={}, model=model)
assert result["metrics"] == []
assert "metrics 是对象" in result["diagnostics"]
assert "sql" in result["diagnostics"]
async def test_plan_passes_uncovered_through():
"""一次只起草能共用一条 SQL 的一组,其余的写进 uncovered,下一轮再起草。"""
model = FakeModel(json.dumps({**raw(), "uncovered": ["月活买家"]}, ensure_ascii=False))
result = await draftplan.plan(None, docs=[], chunks={}, model=model)
assert result["uncovered"] == ["月活买家"]
async def test_plan_tells_the_model_which_metrics_were_already_drafted():
"""续跑时把已经起草过的指标写进提示词,模型才会换下一组,不会重复起草前几轮。"""
model = FakeModel(json.dumps(raw(), ensure_ascii=False))
await draftplan.plan(None, docs=[], chunks={}, model=model, covered=["销售额", "订单数"])
human = model.messages[-1].content
assert "已经起草过的指标" in human
assert "销售额、订单数" in human
assert "不要重复起草" in model.messages[0].content
async def test_plan_reports_document_and_catalog_truncation_separately():
"""文档被截(doc_truncated)和表目录被截(catalog_truncated)要分开说:改文档、改目录是两件事。"""
text = json.dumps(raw(), ensure_ascii=False)
long_tables = "t" * (draftplan.MAX_TABLES_CHARS + 100)
result = await draftplan.plan(None, docs=[], chunks={}, model=FakeModel(text), tables_text=long_tables)
assert result["catalog_truncated"] is True
assert result["doc_truncated"] is False
assert result["truncated"] is True
chunks = {"c1": {"ordinal": 1, "content": CHUNK, "document_id": "d1"},
"c2": {"ordinal": 2, "content": "长" * (draftplan.MAX_INPUT_CHARS + 100), "document_id": "d1"}}
result = await draftplan.plan(None, docs=[], chunks=chunks, model=FakeModel(text), tables_text="表 orders")
assert result["doc_truncated"] is True
assert result["catalog_truncated"] is False
assert result["truncated"] is True
result = await draftplan.plan(None, docs=[], chunks=CHUNKS, model=FakeModel(text), tables_text="表 orders")
assert result["doc_truncated"] is False
assert result["catalog_truncated"] is False
assert result["truncated"] is False
async def test_plan_tells_the_model_which_conditions_are_fixed():
"""固定条件要原样交到模型手里,并明确说「写死在 SQL 里、不要做成入口」。"""
model = FakeModel(json.dumps(raw(), ensure_ascii=False))
await draftplan.plan(None, docs=[], chunks={}, model=model, fixed="line_code = 'FOAM01'")
human = model.messages[-1].content
assert "固定条件" in human and "FOAM01" in human
assert "不要" in human and "入口" in human
def test_too_many_entry_fields_is_reported_not_silently_trimmed():
"""入口超过 4 个说明把不常变的条件也做成了参数:照实报出来,但不悄悄删(删了 SQL 还引用着)。"""
fields = [{"name": "start", "type": "string", "required": True, "default": "2026-09-01"}]
fields += [{"name": f"dim{i}", "type": "string", "required": False} for i in range(4)]
plan, problems = draftplan.normalize_plan(raw(entry_fields=fields), CHUNKS, allowed_tables={"orders"})
assert len(plan["entry_fields"]) == 5
assert any(p["name"] == "入口字段" and "上限" in p["reason"] for p in problems)