mirror of
https://github.com/ThearchyHelios/AgentLab.git
synced 2026-11-15 02:21:58 +00:00
数据目录阶段 2B:发布门禁接上 SQL 检查——受管级别被 error 挡住、warning 进提示,发布前检查、自动修复和发布同一个口径
This commit is contained in:
@@ -1109,16 +1109,32 @@ def _blocking_issues(
|
||||
**({"defaults": defaults} if isinstance(defaults, dict) else {})})
|
||||
except Exception: # noqa: BLE001 - 结构不合法留给收尾那一步报
|
||||
return None
|
||||
# 带发布级别时 SQL 检查交给门禁(查全图、按级别定挡不挡),自查这边就不再查一遍,免得同一处报两条
|
||||
authored = authored_issues(spec, sources or [], baseline, checkers=None if level else checkers)
|
||||
out = ([i.model_dump() for i in validate_graph(spec).issues if i.level == "error"]
|
||||
+ _issue_dicts(scope_issues(list(nodes.values()), scope), "datasource_out_of_scope")
|
||||
+ [i for i in authored_issues(spec, sources or [], baseline, checkers=checkers) if i["level"] == "error"])
|
||||
+ [i for i in authored if i["level"] == "error"])
|
||||
if level:
|
||||
from app.engine.governance import lint_for_publish
|
||||
|
||||
out += [i.model_dump() for i in lint_for_publish(spec, level=level).issues if i.level == "error"]
|
||||
gate = [i.model_dump() for i in lint_for_publish(spec, level=level, checkers=checkers).issues
|
||||
if i.level == "error"]
|
||||
out += _sql_model_hints(gate, spec, checkers)
|
||||
return out
|
||||
|
||||
|
||||
def _sql_model_hints(issues: list[dict[str, Any]], spec: GraphSpec, checkers: dict[str, Any] | None,
|
||||
) -> list[dict[str, Any]]:
|
||||
"""门禁报的 SQL 问题只有给人看的那句(ValidationIssue 不带 for_model);交回模型改时补上给模型的改法。"""
|
||||
if not checkers or not any(i.get("code") in CHECK_CODES for i in issues):
|
||||
return issues
|
||||
from app.data.sqlcheck import graph_checks
|
||||
|
||||
hints = {(node.id, c.code, c.message): c.for_model for node, c in graph_checks(spec.nodes, checkers)}
|
||||
return [{**i, "for_model": hints[key]} if (key := (i.get("node_id"), i.get("code"), i.get("message"))) in hints
|
||||
else i for i in issues]
|
||||
|
||||
|
||||
def _changed(spec: GraphSpec, baseline: list[dict[str, Any]] | None) -> set[str]:
|
||||
"""这一轮新加或改过配置的节点。改图时只查这些:用户自己写的、这一轮没碰的 SQL 不替他改。"""
|
||||
before = {n.get("id"): (n.get("data") or {}).get("config") for n in baseline or [] if isinstance(n, dict)}
|
||||
@@ -1887,13 +1903,17 @@ async def assist_publish_fix(
|
||||
"""
|
||||
from app.engine.autofix import forbidden_changes, judge
|
||||
|
||||
from app.data.sqlcheck import load_checkers
|
||||
|
||||
sources = await _sources(session, None)
|
||||
# 和发布接口同一套 SQL 检查:受管级别里 SQL 的 error 也是挡住发布的问题,一并交给助手改
|
||||
checkers = await load_checkers(session, sources)
|
||||
base = copy.deepcopy(graph)
|
||||
before_nodes = {str(n["id"]): n for n in copy.deepcopy(base.get("nodes") or []) if isinstance(n, dict)}
|
||||
before_edges = [e for e in copy.deepcopy(base.get("edges") or []) if isinstance(e, dict)]
|
||||
baseline = list(copy.deepcopy(before_nodes).values())
|
||||
errors = _blocking_issues(before_nodes, before_edges, sources=sources, baseline=baseline, level=level,
|
||||
defaults=base.get("defaults")) or []
|
||||
defaults=base.get("defaults"), checkers=checkers) or []
|
||||
out: dict[str, Any] = {"accepted": False, "reason": None, "summary": "", "questions": [], "graph": graph,
|
||||
"ops": []}
|
||||
if not errors:
|
||||
@@ -1949,7 +1969,7 @@ async def assist_publish_fix(
|
||||
proposed = _proposed_graph(base, nodes, edges)
|
||||
reasons = forbidden_changes(base, proposed, tried)
|
||||
after = _blocking_issues(_nodes_by_id(proposed), proposed.get("edges") or [], sources=sources,
|
||||
baseline=baseline, level=level, defaults=proposed.get("defaults"))
|
||||
baseline=baseline, level=level, defaults=proposed.get("defaults"), checkers=checkers)
|
||||
if reasons:
|
||||
out["reason"] = "助手的修改降低了要求,已作废:" + ";".join(reasons)
|
||||
elif after is None:
|
||||
@@ -2217,7 +2237,11 @@ async def upgrade_evidence(payload: UpgradeIn, session: AsyncSession = Depends(g
|
||||
out["notes"] = [n for n in out["notes"] if n["rule"] != "R5" or n["node_id"] in still]
|
||||
elif helped["reason"]:
|
||||
out["rejected"].append({"fix_id": "assist", "reason": helped["reason"]})
|
||||
issues = publish_issues(GraphSpec.model_validate(out["graph"]), level=payload.level)
|
||||
from app.data.sqlcheck import graph_sources, load_checkers_by_name
|
||||
|
||||
upgraded = GraphSpec.model_validate(out["graph"])
|
||||
checkers = await load_checkers_by_name(session, graph_sources(upgraded.nodes))
|
||||
issues = publish_issues(upgraded, level=payload.level, checkers=checkers)
|
||||
out["issues"] = [i.model_dump() for i in issues]
|
||||
out["ok"] = not any(i.level == "error" for i in issues)
|
||||
return out
|
||||
|
||||
@@ -324,7 +324,8 @@ async def publish_workflow(
|
||||
except Exception as e: # noqa: BLE001
|
||||
return {"ok": False, "issues": [{"level": "error", "message": f"无法解析工作流结构:{graph_error(e)}"}]}
|
||||
|
||||
issues = [i.model_dump() for i in publish_issues(spec, level=payload.level)]
|
||||
checkers = await _sql_checkers(session, spec)
|
||||
issues = [i.model_dump() for i in publish_issues(spec, level=payload.level, checkers=checkers)]
|
||||
if any(i["level"] == "error" for i in issues):
|
||||
return {"ok": False, "level": payload.level, "version": version, "issues": issues}
|
||||
|
||||
@@ -400,6 +401,14 @@ async def _gate_context(session: AsyncSession, spec: GraphSpec) -> tuple[dict[st
|
||||
return versions, cards
|
||||
|
||||
|
||||
async def _sql_checkers(session: AsyncSession, spec: GraphSpec) -> dict[str, Any]:
|
||||
"""图里调用工具节点写死的 SQL 用到的数据源,各建一个基于数据目录的 SQL 检查器。发布、发布前检查、
|
||||
自动修复都传这一份给门禁(governance.lint_for_publish):门禁本身不查库,三处口径才一致。"""
|
||||
from app.data.sqlcheck import graph_sources, load_checkers_by_name
|
||||
|
||||
return await load_checkers_by_name(session, graph_sources(spec.nodes))
|
||||
|
||||
|
||||
def _unreadable(level: str, e: Exception) -> dict[str, Any]:
|
||||
return {"level": level, "ok": False, "fixes": [], "issues": [
|
||||
{"level": "error", "node_id": None, "edge_id": None, "field": None, "code": None, "fix": None,
|
||||
@@ -422,7 +431,8 @@ async def publish_check(
|
||||
except Exception as e: # noqa: BLE001
|
||||
return _unreadable(payload.level, e)
|
||||
versions, cards = await _gate_context(session, spec)
|
||||
return check(spec, level=payload.level, versions=versions, cards=cards)
|
||||
return check(spec, level=payload.level, versions=versions, cards=cards,
|
||||
checkers=await _sql_checkers(session, spec))
|
||||
|
||||
|
||||
@router.post("/{workflow_id}/autofix")
|
||||
@@ -446,7 +456,9 @@ async def autofix(
|
||||
{"fix_id": fid, "reason": "无法解析工作流结构,不能应用修复"} for fid in payload.apply],
|
||||
"remaining": bad["issues"], "fixes": [], "handoff": [], "assist": None, "ok": False}
|
||||
versions, cards = await _gate_context(session, spec)
|
||||
out = apply_fixes(graph, payload.apply, payload.choices, level=payload.level, versions=versions, cards=cards)
|
||||
checkers = await _sql_checkers(session, spec)
|
||||
out = apply_fixes(graph, payload.apply, payload.choices, level=payload.level, versions=versions, cards=cards,
|
||||
checkers=checkers)
|
||||
out["assist"] = None
|
||||
# 人在选项里选了「交给 Copilot」(handoff),和点「交给 Copilot」是一回事
|
||||
if payload.assist or out["handoff"]:
|
||||
@@ -464,7 +476,8 @@ async def autofix(
|
||||
out["changes"] += diff_changes(out["graph"], fixed, fix_id="assist", label="助手的修改")
|
||||
out["ops"] += helped["ops"]
|
||||
out["applied"].append("assist")
|
||||
after = apply_fixes(fixed, [], level=payload.level, versions=versions, cards=cards)
|
||||
after = apply_fixes(fixed, [], level=payload.level, versions=versions, cards=cards,
|
||||
checkers=await _sql_checkers(session, GraphSpec.model_validate(fixed)))
|
||||
out.update(graph=fixed, remaining=after["remaining"], fixes=after["fixes"], ok=after["ok"])
|
||||
elif helped["reason"]:
|
||||
out["rejected"].append({"fix_id": "assist", "reason": helped["reason"]})
|
||||
|
||||
@@ -559,9 +559,13 @@ def annotate(issues: list[Any], fixes: list[dict[str, Any]]) -> list[dict[str, A
|
||||
|
||||
|
||||
def check(spec: GraphSpec, *, level: str, versions: dict[str, Any] | None = None,
|
||||
cards: dict[str, list[dict[str, Any]]] | None = None) -> dict[str, Any]:
|
||||
"""发布前检查:和真正发布同一套口径的问题,以及能怎么修。只读。"""
|
||||
issues = publish_issues(spec, level=level)
|
||||
cards: dict[str, list[dict[str, Any]]] | None = None,
|
||||
checkers: dict[str, Any] | None = None) -> dict[str, Any]:
|
||||
"""发布前检查:和真正发布同一套口径的问题,以及能怎么修。只读。
|
||||
|
||||
checkers:基于数据目录的 SQL 检查器(data/sqlcheck),和发布接口传的是同一份(governance.lint_for_publish)。
|
||||
"""
|
||||
issues = publish_issues(spec, level=level, checkers=checkers)
|
||||
fixes = plan_fixes(spec, issues, level=level, versions=versions, cards=cards)
|
||||
return {"level": level, "ok": not any(i.level == "error" for i in issues),
|
||||
"issues": annotate(issues, fixes), "fixes": [public(f) for f in fixes]}
|
||||
@@ -702,7 +706,8 @@ def _op(graph: dict[str, Any], node_id: str | None, edits: list[tuple[str | None
|
||||
|
||||
def apply_fixes(graph: dict[str, Any] | GraphSpec, fix_ids: list[str], choices: dict[str, Any] | None = None, *,
|
||||
level: str, versions: dict[str, Any] | None = None,
|
||||
cards: dict[str, list[dict[str, Any]]] | None = None) -> dict[str, Any]:
|
||||
cards: dict[str, list[dict[str, Any]]] | None = None,
|
||||
checkers: dict[str, Any] | None = None) -> dict[str, Any]:
|
||||
"""在副本上逐个应用修复,每一个都复核:降低要求、错误没变少或者冒出新错误的,丢弃并写明原因。
|
||||
|
||||
返回 {graph, changes, ops, applied, rejected, handoff, remaining, fixes, ok}。graph 只是预览,不落库。
|
||||
@@ -711,7 +716,7 @@ def apply_fixes(graph: dict[str, Any] | GraphSpec, fix_ids: list[str], choices:
|
||||
raw = graph.model_dump(mode="json") if isinstance(graph, GraphSpec) else graph
|
||||
current = copy.deepcopy(raw)
|
||||
spec = GraphSpec.model_validate(current)
|
||||
issues = publish_issues(spec, level=level)
|
||||
issues = publish_issues(spec, level=level, checkers=checkers)
|
||||
plan = plan_fixes(spec, issues, level=level, versions=versions, cards=cards)
|
||||
known = {f["id"] for f in plan}
|
||||
rank = {code: i for i, code in enumerate(_ORDER)}
|
||||
@@ -743,7 +748,7 @@ def apply_fixes(graph: dict[str, Any] | GraphSpec, fix_ids: list[str], choices:
|
||||
except Exception: # noqa: BLE001 - 修复规则把图改坏了:丢弃,不交给人一张存不回去的图
|
||||
rejected.append({"fix_id": fid, "reason": "修复后的工作流结构无法解析,已放弃这项修复"})
|
||||
continue
|
||||
trial_issues = publish_issues(trial_spec, level=level)
|
||||
trial_issues = publish_issues(trial_spec, level=level, checkers=checkers)
|
||||
# choice 里人亲手选的值不算「替人决定」(比如把沙箱代码标成取数);别的降低要求照样拦
|
||||
chosen = {(str(nid), path) for nid, path, _, _ in edits} if fix["kind"] == "choice" else set()
|
||||
why = ";".join(forbidden_changes(current, trial, chosen=chosen)) or judge(
|
||||
|
||||
@@ -2,6 +2,7 @@ from __future__ import annotations
|
||||
|
||||
import re
|
||||
from collections import defaultdict
|
||||
from collections.abc import Mapping
|
||||
from typing import Any, Callable
|
||||
|
||||
from sqlalchemy import func, select
|
||||
@@ -29,7 +30,7 @@ UPGRADE_POLICIES = {"recompute", "dual", "incomparable"}
|
||||
UPGRADE_POLICY_LABELS = {k: option_label("upgrade_policy", k) for k in ("recompute", "dual", "incomparable")}
|
||||
|
||||
|
||||
def lint_for_publish(spec: GraphSpec, *, level: str) -> ValidationResult:
|
||||
def lint_for_publish(spec: GraphSpec, *, level: str, checkers: Mapping[str, Any] | None = None) -> ValidationResult:
|
||||
"""发布门禁。
|
||||
|
||||
published:基础可运行性之外只给警告。
|
||||
@@ -38,6 +39,9 @@ def lint_for_publish(spec: GraphSpec, *, level: str) -> ValidationResult:
|
||||
|
||||
每条问题带机器编号(code):发布前自动修复(engine/autofix.py)按它认问题、出修复,
|
||||
文案可以改,编号不能随便改。证据门禁 G1–G5 见 _lint_evidence。
|
||||
|
||||
checkers 是各数据源基于数据目录的 SQL 检查器(data/sqlcheck.load_checkers_by_name),由调用方读好传进来:
|
||||
门禁本身不查库。给了的话,调用工具节点里写死的 SQL 对照目录查一遍(_lint_sql)。
|
||||
"""
|
||||
result = ValidationResult()
|
||||
strict = level == "governed"
|
||||
@@ -100,6 +104,7 @@ def lint_for_publish(spec: GraphSpec, *, level: str) -> ValidationResult:
|
||||
"(那里会保存取数快照)", code="governed.code_network", node_id=node.id, field="network")
|
||||
|
||||
_lint_evidence(spec, flag, strict=strict)
|
||||
_lint_sql(spec, flag, checkers)
|
||||
|
||||
if strict and not has_contract_output:
|
||||
result.add(
|
||||
@@ -109,15 +114,34 @@ def lint_for_publish(spec: GraphSpec, *, level: str) -> ValidationResult:
|
||||
return result
|
||||
|
||||
|
||||
def publish_issues(spec: GraphSpec, *, level: str) -> list[Any]:
|
||||
def publish_issues(spec: GraphSpec, *, level: str, checkers: Mapping[str, Any] | None = None) -> list[Any]:
|
||||
"""发布时拦的全部问题:validate_graph 和 lint_for_publish 的并集,顺序也照发布接口。
|
||||
|
||||
发布、发布前检查、自动修复复核都走这一个口径——检查说能发的,真发布时不会被拦。
|
||||
发布、发布前检查、自动修复复核都走这一个口径——检查说能发的,真发布时不会被拦。checkers 见 lint_for_publish:
|
||||
这几处都要传同一份,否则检查和发布对 SQL 的判断不一致。
|
||||
"""
|
||||
gate = lint_for_publish(spec, level=level).issues
|
||||
gate = lint_for_publish(spec, level=level, checkers=checkers).issues
|
||||
return [*_not_repeated(validate_graph(spec).issues, gate, spec), *gate]
|
||||
|
||||
|
||||
def _lint_sql(spec: GraphSpec, flag: Callable[..., None], checkers: Mapping[str, Any] | None) -> None:
|
||||
"""调用工具节点里写死的 SQL 对照数据目录检查(data/sqlcheck.py)。
|
||||
|
||||
error 级(依据有确证的重复计算、存量跨期求和)按硬性问题报:受管级别挡住发布,已发布级别只警告——和别的
|
||||
受管要求同一个口径。warning 两档都只提示。info 的依据只是推断(模型起草、命名推断),不进门禁:发布时
|
||||
摆一排「推断,未确认」的提示,只会让人学会无视门禁。编号就是规则编号(fanout_sum…),没有确定性的修复,
|
||||
改 SQL 交给人或助手。Agent 节点的 SQL 要到运行时才写出来,不在这里查。
|
||||
"""
|
||||
if not checkers:
|
||||
return
|
||||
from app.data.sqlcheck import graph_checks
|
||||
|
||||
for node, check in graph_checks(spec.nodes, checkers):
|
||||
if check.level == "info":
|
||||
continue
|
||||
flag(check.message, code=check.code, node_id=node.id, hard=check.level == "error", field="args.sql")
|
||||
|
||||
|
||||
#: validate 里和门禁 G4、G5 说同一件事的两条提示(schema.evidence_issues)
|
||||
_REPEATS = frozenset({"evidence.caliber_compute_input", "evidence.cite_fields_off"})
|
||||
|
||||
|
||||
@@ -0,0 +1,212 @@
|
||||
"""发布门禁接上基于数据目录的 SQL 检查(data/sqlcheck.py)。
|
||||
|
||||
- 受管级别:调用工具节点里写死的 SQL 有 error 级问题(一对多关联后重复计算、存量跨期求和)就挡住发布;
|
||||
- 已发布级别:同样的问题只是警告,不挡;
|
||||
- warning 级的问题两档都进提示,info(依据只是推断)不进门禁;
|
||||
- 发布前检查、自动修复和真正发布同一个口径:检查说能发的,真发布时不会被拦。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import copy
|
||||
import json
|
||||
import uuid
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
from httpx import ASGITransport, AsyncClient
|
||||
|
||||
from app.data import catalog
|
||||
from app.data.engine import engines
|
||||
from app.data.introspect import introspect
|
||||
from app.data.sqlcheck import SqlChecker
|
||||
from app.engine.governance import lint_for_publish, publish_issues
|
||||
from app.engine.schema import GraphSpec
|
||||
from app.main import app
|
||||
from tests.fixtures.catalog import scenic_notes
|
||||
|
||||
FANOUT_SQL = ("SELECT SUM(o.total_amount) AS gmv FROM orders o JOIN order_items i ON i.order_id = o.id "
|
||||
"WHERE o.status = 1")
|
||||
CLEAN_SQL = "SELECT SUM(i.amount) AS gmv FROM orders o JOIN order_items i ON i.order_id = o.id WHERE o.status = 1"
|
||||
WARN_SQL = "SELECT SUM(i.amount) AS gmv FROM orders o JOIN order_items i ON i.order_id = o.id"
|
||||
|
||||
_CACHE: dict[str, dict] = {}
|
||||
|
||||
|
||||
async def _schema(path: str) -> dict:
|
||||
if path not in _CACHE:
|
||||
probe = SimpleNamespace(id=f"probe-{uuid.uuid4().hex[:6]}", name="scenic", kind="sqlite", database=path,
|
||||
host=None, port=None, username=None, password=None, options={}, readonly=True,
|
||||
description="", schema_cache={}, origin="manual")
|
||||
try:
|
||||
_CACHE[path] = await introspect(probe)
|
||||
finally:
|
||||
await engines.invalidate(probe.id)
|
||||
return _CACHE[path]
|
||||
|
||||
|
||||
def node(nid, ntype, label=None, **config):
|
||||
return {"id": nid, "type": ntype, "position": {"x": 10, "y": 20}, "data": {"label": label or nid, "config": config}}
|
||||
|
||||
|
||||
def weekly(source: str, sql: str) -> dict:
|
||||
"""input → 取数(调用工具,写死的 SQL)→ 口径卡 → 报告撰写 → 出具,其余受管要求都满足。"""
|
||||
nodes = [
|
||||
node("start", "input", "周期"),
|
||||
node("fetch", "tool", "取数", tool=f"db_query__{source}", args={"sql": sql}),
|
||||
node("card", "metrics", "周报口径卡", caliber="周报口径", metrics=[
|
||||
{"id": "gmv", "name": "订单金额", "unit": "元", "expression": "cell(nodes.fetch, 0, 'gmv')"}]),
|
||||
node("write", "report", "报告撰写", instructions="写周报",
|
||||
numbers="strict", on_violation="fail", claims="require_citation"),
|
||||
node("done", "output", "出具", fields=[{"name": "周报", "value": "{{ nodes.write.text }}"}],
|
||||
contract={"report_from": "write", "metrics_from": ["card"], "required": ["gmv"], "strict": True}),
|
||||
]
|
||||
edges = [{"id": f"e{i}", "source": a["id"], "target": b["id"]} for i, (a, b) in enumerate(zip(nodes, nodes[1:]))]
|
||||
return {"nodes": nodes, "edges": edges, "defaults": {"approval": "dangerous"}}
|
||||
|
||||
|
||||
async def _checkers(scenic_db, status="confirmed"):
|
||||
cache = await _schema(scenic_db)
|
||||
return {"scenic": SqlChecker(kind="sqlite", schema_cache=cache, notes=scenic_notes.notes(status),
|
||||
source_name="scenic")}
|
||||
|
||||
|
||||
def _sql_issues(issues):
|
||||
return [(i.code, i.level, i.node_id, i.field) for i in issues if i.field == "args.sql"]
|
||||
|
||||
|
||||
async def test_governed_level_blocks_error_level_sql_problems(scenic_db):
|
||||
checkers = await _checkers(scenic_db)
|
||||
spec = GraphSpec.model_validate(weekly("scenic", FANOUT_SQL))
|
||||
assert not [i for i in publish_issues(spec, level="governed") if i.level == "error"], "其余受管要求都满足"
|
||||
issues = publish_issues(spec, level="governed", checkers=checkers)
|
||||
assert _sql_issues(issues) == [("fanout_sum", "error", "fetch", "args.sql")]
|
||||
[issue] = [i for i in issues if i.code == "fanout_sum"]
|
||||
assert issue.message.startswith("「订单」关联「订单明细」是一对多")
|
||||
# 已发布级别:同一个问题只警告
|
||||
assert _sql_issues(publish_issues(spec, level="published", checkers=checkers)) == [
|
||||
("fanout_sum", "warning", "fetch", "args.sql")]
|
||||
# 依据只是推断:info 不进门禁
|
||||
assert _sql_issues(lint_for_publish(spec, level="governed", checkers=await _checkers(scenic_db, "proposed"))
|
||||
.issues) == []
|
||||
# 没有检查器(数据源没有目录):和以前一样
|
||||
assert _sql_issues(lint_for_publish(spec, level="governed").issues) == []
|
||||
|
||||
|
||||
async def test_warnings_are_hints_on_both_levels(scenic_db):
|
||||
checkers = await _checkers(scenic_db)
|
||||
spec = GraphSpec.model_validate(weekly("scenic", WARN_SQL))
|
||||
for level in ("governed", "published"):
|
||||
assert _sql_issues(publish_issues(spec, level=level, checkers=checkers)) == [
|
||||
("missing_valid_filter", "warning", "fetch", "args.sql")]
|
||||
clean = GraphSpec.model_validate(weekly("scenic", CLEAN_SQL))
|
||||
assert _sql_issues(publish_issues(clean, level="governed", checkers=checkers)) == []
|
||||
|
||||
|
||||
async def test_autofix_check_and_apply_use_the_same_gate(scenic_db):
|
||||
from app.engine.autofix import apply_fixes, check
|
||||
|
||||
checkers = await _checkers(scenic_db)
|
||||
graph = weekly("scenic", FANOUT_SQL)
|
||||
body = check(GraphSpec.model_validate(graph), level="governed", checkers=checkers)
|
||||
assert body["ok"] is False and "fanout_sum" in {i["code"] for i in body["issues"]}
|
||||
out = apply_fixes(copy.deepcopy(graph), [], level="governed", checkers=checkers)
|
||||
assert out["ok"] is False and "fanout_sum" in {i["code"] for i in out["remaining"]}
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# 接口:发布、发布前检查、自动修复
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def scenic_source(scenic_db):
|
||||
from app.db.base import SessionLocal
|
||||
from app.db.models import DataSource
|
||||
|
||||
name = f"scenic_{uuid.uuid4().hex[:8]}"
|
||||
async with SessionLocal() as session:
|
||||
row = DataSource(name=name, kind="sqlite", database=scenic_db, readonly=True)
|
||||
row.schema_cache = await _schema(scenic_db)
|
||||
session.add(row)
|
||||
await session.commit()
|
||||
for table, notes in scenic_notes.notes().items():
|
||||
await catalog.write_entry(session, row.id, table, notes, if_version=0, actor="王敏")
|
||||
source_id = row.id
|
||||
yield name
|
||||
async with SessionLocal() as session:
|
||||
row = await session.get(DataSource, source_id)
|
||||
row.enabled = False
|
||||
await session.commit()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def client():
|
||||
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as c:
|
||||
yield c
|
||||
|
||||
|
||||
async def test_publish_endpoints_block_governed_fanout(client, scenic_source):
|
||||
r = await client.post("/api/workflows", json={"name": f"订单周报-{scenic_source}",
|
||||
"graph": weekly(scenic_source, FANOUT_SQL)})
|
||||
assert r.status_code == 201, r.text
|
||||
wid = r.json()["id"]
|
||||
|
||||
checked = (await client.post(f"/api/workflows/{wid}/publish-check", json={"level": "governed"})).json()
|
||||
assert checked["ok"] is False
|
||||
[issue] = [i for i in checked["issues"] if i["code"] == "fanout_sum"]
|
||||
assert issue["level"] == "error" and issue["node_id"] == "fetch" and issue["field"] == "args.sql"
|
||||
assert issue["fix"] is None, "SQL 写错没有确定性的修复,交给人或助手改"
|
||||
|
||||
fixed = (await client.post(f"/api/workflows/{wid}/autofix", json={"level": "governed", "apply": []})).json()
|
||||
assert fixed["ok"] is False and "fanout_sum" in {i["code"] for i in fixed["remaining"]}
|
||||
|
||||
blocked = (await client.post(f"/api/workflows/{wid}/publish", json={"level": "governed"})).json()
|
||||
assert blocked["ok"] is False and "fanout_sum" in {i.get("code") for i in blocked["issues"]}
|
||||
# 已发布级别不挡,问题作为警告带回
|
||||
published = (await client.post(f"/api/workflows/{wid}/publish", json={"level": "published"})).json()
|
||||
assert published["ok"] is True
|
||||
assert [i["level"] for i in published["issues"] if i.get("code") == "fanout_sum"] == ["warning"]
|
||||
|
||||
# 改好 SQL:受管级别发得出去,剩下的 warning 进提示
|
||||
await client.patch(f"/api/workflows/{wid}", json={"graph": weekly(scenic_source, WARN_SQL)})
|
||||
ok = (await client.post(f"/api/workflows/{wid}/publish", json={"level": "governed"})).json()
|
||||
assert ok["ok"] is True, ok["issues"]
|
||||
assert [i["code"] for i in ok["issues"] if i.get("field") == "args.sql"] == ["missing_valid_filter"]
|
||||
|
||||
|
||||
class _Scripted:
|
||||
"""按剧本吐操作流的模型;记下每次收到的请求。"""
|
||||
|
||||
def __init__(self, ops):
|
||||
self.ops, self.calls = ops, []
|
||||
|
||||
async def astream(self, messages):
|
||||
from langchain_core.messages import AIMessageChunk
|
||||
|
||||
self.calls.append(messages)
|
||||
for op in self.ops:
|
||||
yield AIMessageChunk(content=json.dumps(op, ensure_ascii=False) + "\n")
|
||||
|
||||
|
||||
async def test_publish_fix_assistant_gets_the_sql_error_and_its_fix_is_adopted(client, scenic_source, monkeypatch):
|
||||
"""确定性修复修不了 SQL:交给助手。助手看到的是给模型的那句改法,改好的 SQL 复核通过才采纳。"""
|
||||
import app.api.copilot as copilot
|
||||
|
||||
model = _Scripted([{"op": "plan", "summary": "改 SQL"},
|
||||
{"op": "update_node", "id": "fetch",
|
||||
"config": {"tool": f"db_query__{scenic_source}", "args": {"sql": CLEAN_SQL}}},
|
||||
{"op": "done", "explanation": "改为对明细金额求和"}])
|
||||
|
||||
async def _model(*_a, **_k):
|
||||
return model, "mock-fast"
|
||||
|
||||
monkeypatch.setattr(copilot, "get_chat_model", _model)
|
||||
wid = (await client.post("/api/workflows", json={"name": f"订单周报-助手-{scenic_source}",
|
||||
"graph": weekly(scenic_source, FANOUT_SQL)})).json()["id"]
|
||||
out = (await client.post(f"/api/workflows/{wid}/autofix",
|
||||
json={"level": "governed", "apply": [], "assist": True})).json()
|
||||
human = next(text for role, text in model.calls[0] if role == "human")
|
||||
assert "orders 关联 order_items 是一对多" in human
|
||||
assert out["assist"]["ok"] is True and out["applied"] == ["assist"] and out["ok"] is True, out
|
||||
sql = next(n for n in out["graph"]["nodes"] if n["id"] == "fetch")["data"]["config"]["args"]["sql"]
|
||||
assert sql == CLEAN_SQL
|
||||
Reference in New Issue
Block a user