mirror of
https://github.com/ThearchyHelios/AgentLab.git
synced 2026-11-15 04:21:57 +00:00
9d8b41430e
- 完善口径编辑、只读试运行、统一质量验证与执行语义指纹 - 增加可执行指标约束、有预算的主动调查和过程评测 - 提取发布检查、证据读取与起草服务,支持任务停止、重试和检查点恢复 - 增加版本传递影响分析,修复并发覆盖、敏感值遮罩、异常评分及页面状态问题 - 修复 SQLite 复合查询追溯和 microVM 可选依赖缺失处理 - 纳入当前设计文档及部署配置调整 验证:相关主回归 3822 项通过,4 项跳过、8 项预期失败;补充回归通过。 前端构建、文案检查及 git diff --check 通过。 全量检查仍有环境权限、PostgreSQL 可选依赖和既有部署配置、SQLite 文件基线差异导致的未通过项。
394 lines
20 KiB
Python
394 lines
20 KiB
Python
"""口径一次起草的纯逻辑(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)
|