Files
AgentLab/backend/app/blueprints/generation.py
T

392 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.
"""蓝图和关节的生成(N2、N3、N11):接到 api/copilot_gen.Generation 上的两对落地与自查。
- BlueprintApplier / BlueprintChecker:蓝图助手只收 bp_* 操作,落地走 blueprints/ops.apply_bp_op(来源记 assistant,
只提议:助手写的项都是「待确认」)。复用口径卡的操作按这一轮读好的口径核对并补上它读的列(look/calibers);
越界被拒的照实交回模型一次、交付时列给人看(同 AppApplier 的做法)。
- TemplateApplier / TemplateChecker:填关节只收 fill_joint {joint, value},而且只收这一组里模板声明过的生成关节;
改别的位置、别的组的关节、或者用别的操作,一律拒绝并照实交回(照 apps/ops.polish_problem 的写法)。自查跑 GraphChecker
的 SQL 检查(只读守卫、猜列、数据源范围、目录检查)、指标式的白名单(只许 cell() 取格加算术)和 engine/lineage.static_stops
(静态追到原始数据),再和骨架逐节点比对(tpl.skeleton_changed)。
"""
from __future__ import annotations
import copy
import json
import re
from typing import Any
from app.blueprints.ops import BpRefused, apply_bp_op
from app.blueprints.spec import BP_OPS
from app.look.base import Applied
#: 越界被拒的编号(交回模型、列给人看)
BP_REFUSED = "bp.refused"
JOINT_REFUSED = "tpl.joint_refused"
JOINT_UNFILLED = "tpl.joint_unfilled"
METRIC_EXPR = "tpl.metric_expr"
METRIC_COLUMN = "tpl.metric_column"
SQL_REJECTED = "tpl.sql_rejected"
#: 指标式里能用的函数:取格和算术(叙述层不做算术,算术只在口径卡里,P7)
METRIC_FUNCS = frozenset({"cell", "round", "abs", "min", "max"})
BLUEPRINT_REPAIR_PROMPT = (
"这是你刚改好的蓝图:\n{spec}\n\n检查发现下面这些问题:\n{issues}\n\n"
"只修这些问题,别的不要动。用 bp_* 操作输出修正;拿不准、要用户拿主意的用 bp_ask 问。最后一行输出 done。{looks}")
TEMPLATE_REPAIR_PROMPT = (
"这是你刚填的关节:\n{values}\n\n检查(和发布同一套规则)发现下面这些问题:\n{issues}\n\n"
"只修这些问题:用 fill_joint 重新填有问题的关节(整段重写),别的不要动。最后一行输出 done。{looks}")
REPAIR_LOOKS_PROMPT = "\n\n这一轮查看过的内容(和起草时看到的是同一份):\n{text}"
ISSUE_LINE_PROMPT = "- {where}{message}"
def _issue_lines(issues: list[dict[str, Any]]) -> str:
return "\n".join(ISSUE_LINE_PROMPT.format(where=f"{i.get('path') or i.get('joint') or i.get('node_id') or ''} ",
message=i.get("for_model") or i.get("message") or "").replace(" ", " ")
for i in issues)
# ==========================================================================
# 蓝图
# ==========================================================================
class BlueprintApplier:
"""蓝图的落地:bp_* 操作按 ops.apply_bp_op 落到 spec 上(每落一个换一份新的),来源记 assistant。
plan、done 记下说明;reply 记下(这句话不需要改蓝图);bp_ask 记进反问(Generation 读到它就结束这一轮);别的操作一概
不收。calibers 是这一轮读好的口径(look/calibers.CaliberKind):复用口径卡的操作按它核对、补上读的列。"""
def __init__(self, spec: dict[str, Any], *, calibers: Any = None) -> None:
self.spec = spec
self.calibers = calibers
self.effective: list[dict[str, Any]] = []
self.plan: dict[str, Any] | None = None
self.replied: dict[str, Any] | None = None
self.explanation = ""
#: 越界被拒的操作:[{op, path, message, code}]
self.refused: list[dict[str, Any]] = []
self._reported = 0
self._landed: list[str] = []
self._continuing = False
self._repairing = False
def apply(self, op: dict[str, Any]) -> Applied:
kind = str(op.get("op") or "")
if kind == "plan":
if self.plan is not None and self._continuing:
return Applied(False, False)
self.plan = self.plan or op
return Applied(False, True)
if kind == "done":
self.explanation = str(op.get("explanation", ""))
return Applied(False, True)
if kind == "reply":
if self.replied is None and not self._repairing:
self.replied = op
return Applied(False, False)
if kind not in BP_OPS:
return Applied(False, False)
if self._continuing and op in self.effective:
return Applied(False, False)
op = {k: v for k, v in op.items() if k in ("op", "path", "value", "question", "options", "template")}
try:
op = self._with_reuse(op)
self.spec = apply_bp_op(self.spec, op, source="assistant")
except BpRefused as e:
self.refused.append({"op": kind, "path": op.get("path"), "message": e.message, "code": e.code})
return Applied(False, False, e.message)
self.effective.append(op)
where = str(op.get("path") or op.get("op"))
if where not in self._landed:
self._landed.append(where)
return Applied(True, True)
def _with_reuse(self, op: dict[str, Any]) -> dict[str, Any]:
"""复用口径卡:按这一轮读好的口径核对,补上那张卡读的取数节点和列。没读口径(calibers 为空)时原样交给存的那一步核。"""
if self.calibers is None:
return op
path = str(op.get("path") or "")
if op.get("op") == "bp_set" and path.endswith("/reuse") and isinstance(op.get("value"), dict):
return {**op, "value": self._reuse(op["value"])}
if op.get("op") == "bp_add" and isinstance(op.get("value"), dict) and isinstance(op["value"].get("reuse"), dict):
return {**op, "value": {**op["value"], "reuse": self._reuse(op["value"]["reuse"])}}
return op
def _reuse(self, ref: dict[str, Any]) -> dict[str, Any]:
reuse, reason = self.calibers.reuse_for(ref)
if reuse is None:
raise BpRefused(f"这张口径卡不能复用:{reason}", code="bp.data_unmapped")
return reuse
def landed(self) -> list[str]:
return list(self._landed)
def continuing(self) -> None:
self._continuing = True
def repairing(self) -> None:
self._repairing = True
def unreported(self) -> list[dict[str, Any]]:
out = self.refused[self._reported:]
self._reported = len(self.refused)
return out
def refusals(self) -> list[dict[str, Any]]:
"""交付时列给人看的:助手想改什么、为什么没改(警告级)。"""
return [{"code": r["code"], "level": "warning", "path": r["path"],
"message": f"助手提的这一处没有采纳:{r['message']}"} for r in self.refused]
class BlueprintChecker:
"""蓝图的自查:越界被拒、还没交回过的操作,加上卡片内容超出模板能力的地方(bp.template_mismatch)——这些是助手提错了,
交回它改。缺的项(bp.missing、bp.data_unmapped……)不交回:缺的往往是只有用户知道的事,逼它补只会让它编,由它在
这一轮里 bp_ask 问,或者留给用户在卡片上填。"""
def __init__(self, applier: BlueprintApplier, *, template_id: str | None = None) -> None:
self.applier = applier
self.template_id = template_id
def lint(self) -> list[dict[str, Any]]:
from app.blueprints.ops import blueprint_issues
return blueprint_issues(self.applier.spec, for_="trial")
async def sources(self) -> list[dict[str, Any]]:
"""对到的数据源和表在不在(要查库,用自己的会话:流式回答时接口的会话可能已经关了)。对到一个不存在的源是助手
提错了,交回它改,不留给用户到试运行时才碰上。"""
from app.blueprints.store import data_source_problems
from app.db.base import SessionLocal
spec = self.applier.spec
template_id = (spec.get("template") or {}).get("id") or self.template_id
async with SessionLocal() as session:
return await data_source_problems(session, spec, template_id=template_id)
async def issues(self) -> list[dict[str, Any]]:
refused = [{"code": r["code"], "level": "error", "path": r["path"], "message": r["message"]}
for r in self.applier.unreported()]
return [*refused, *(i for i in self.lint() if i["code"] == "bp.template_mismatch" and i["level"] == "error"),
*await self.sources()]
def repair_request(self, issues: list[dict[str, Any]], looks_text: str) -> str:
return BLUEPRINT_REPAIR_PROMPT.format(spec=json.dumps(self.applier.spec, ensure_ascii=False),
issues=_issue_lines(issues),
looks=REPAIR_LOOKS_PROMPT.format(text=looks_text) if looks_text else "")
# ==========================================================================
# 模板的生成关节
# ==========================================================================
def joint_problem(joints: dict[str, Any], group: list[Any], op: dict[str, Any]) -> str | None:
"""填关节时这个操作有没有越界:越界返回交回模型的一句话,没越界返回 None(同 apps/ops.polish_problem 的写法)。
joints 是模板声明的全部关节(id → Joint),group 是这一组要填的生成关节。只收 fill_joint、只收这一组的关节、值是一段
非空的文字。骨架是锁住的:节点、连线、确定性关节(按卡片填的)和别的配置都不归模型管。"""
from app.blueprints.templates import GENERATED_JOINT_KINDS
kind = op.get("op") if isinstance(op, dict) else None
mine = {j.id for j in group}
if kind != "fill_joint":
return (f"填关节只能用 fill_joint 填模板留给你的位置({'、'.join(sorted(mine))}),不能用 {kind}:"
"模板的节点、连线和别的配置是锁住的")
joint_id = str(op.get("joint") or "")
joint = joints.get(joint_id)
if joint is None:
return f"模板里没有关节「{joint_id}」:这一轮只能填 {'、'.join(sorted(mine))}"
if joint.kind not in GENERATED_JOINT_KINDS:
return f"「{joint.label or joint_id}」按卡片确定地填,不归助手管:要改请回到蓝图卡片"
if joint_id not in mine:
return f"「{joint.label or joint_id}」不在这一组里(这一组只填 {'、'.join(sorted(mine))}),由别的那一组填"
value = op.get("value")
if not isinstance(value, str) or not value.strip():
return f"「{joint.label or joint_id}」要写一段非空的文字({'SQL' if joint.kind == 'sql' else '指标式'})"
if len(value) > 20_000:
return f"「{joint.label or joint_id}」太长了(超过 20000 字)"
return None
class TemplateApplier:
"""一组生成关节的落地:fill_joint 把值写到图上那个位置(graph 是这一组自己的那一份,原地改),值记在 values 里。
越界的照实交回(refused),自查时交回模型一次,交付时列给人看。"""
def __init__(self, graph: dict[str, Any], joints: list[Any], group: list[Any]) -> None:
self.graph = graph
self.joints = {j.id: j for j in joints}
self.group = list(group)
self.values: dict[str, str] = {}
self.plan: dict[str, Any] | None = None
self.replied: dict[str, Any] | None = None
self.explanation = ""
self.refused: list[dict[str, Any]] = []
self.effective: list[dict[str, Any]] = []
self._reported = 0
self._continuing = False
self._repairing = False
def apply(self, op: dict[str, Any]) -> Applied:
from app.blueprints.instantiate import node_config, write_at
kind = str(op.get("op") or "")
if kind == "plan":
if self.plan is not None and self._continuing:
return Applied(False, False)
self.plan = self.plan or op
return Applied(False, True)
if kind == "done":
self.explanation = str(op.get("explanation", ""))
return Applied(False, True)
if kind == "reply":
if self.replied is None and not self._repairing:
self.replied = op
return Applied(False, False)
if problem := joint_problem(self.joints, self.group, op):
self.refused.append({"op": kind, "joint": op.get("joint"), "message": problem})
return Applied(False, False, problem)
joint = self.joints[str(op["joint"])]
value = str(op["value"]).strip()
if self.values.get(joint.id) == value:
return Applied(False, False)
config = node_config(self.graph, joint.node_id)
if config is None:
return Applied(False, False, f"图上没有节点 {joint.node_id}")
write_at(config, joint.field, value)
self.values[joint.id] = value
self.effective.append(op)
return Applied(True, True)
def landed(self) -> list[str]:
return list(self.values)
def continuing(self) -> None:
self._continuing = True
def repairing(self) -> None:
self._repairing = True
def unreported(self) -> list[dict[str, Any]]:
out = self.refused[self._reported:]
self._reported = len(self.refused)
return out
def refusals(self) -> list[dict[str, Any]]:
return [{"code": JOINT_REFUSED, "level": "warning", "joint": r.get("joint"),
"message": f"助手想改的这一处没有采纳:{r['message']}"} for r in self.refused]
class TemplateChecker:
"""一组生成关节的自查:越界被拒的、还没填的、SQL 检查(GraphChecker:只读守卫、猜列、目录检查)、指标式白名单和
静态追到原始数据(engine/lineage.static_stops)、骨架比对。只报这一组的节点上的问题:别的组在并行地填,它们的占位
不归这一组管。"""
def __init__(self, applier: TemplateApplier, *, skeleton: dict[str, Any], sources: list[Any] | None = None,
checkers: dict[str, Any] | None = None) -> None:
self.applier = applier
self.skeleton = skeleton
self.sources = sources or []
self.checkers = checkers
def _nodes(self) -> dict[str, dict[str, Any]]:
return {n["id"]: n for n in self.applier.graph.get("nodes") or [] if isinstance(n, dict) and n.get("id")}
async def issues(self) -> list[dict[str, Any]]:
from app.blueprints.instantiate import skeleton_changes
out = [{"code": JOINT_REFUSED, "level": "error", "joint": r.get("joint"), "message": r["message"]}
for r in self.applier.unreported()]
out += [{"code": JOINT_UNFILLED, "level": "error", "joint": j.id, "node_id": j.node_id,
"message": f"「{j.label or j.id}」还没填:用 fill_joint 填上"}
for j in self.applier.group if j.id not in self.applier.values]
out += self._sql_issues()
out += self._metric_issues()
out += skeleton_changes(self.skeleton, self.applier.graph, list(self.applier.joints.values()))
return out
def _sql_issues(self) -> list[dict[str, Any]]:
"""这一组 SQL 关节所在的取数节点:只读守卫、再按 GraphChecker(和改图助手同一套:猜列、目录检查、画布校验)。"""
from app.api.copilot_gen import GraphApplier, GraphChecker
from app.data.guard import SqlRejected, check
nodes = self._nodes()
mine = {j.node_id for j in self.applier.group if j.kind == "sql" and j.id in self.applier.values}
if not mine:
return []
out: list[dict[str, Any]] = []
kinds = {getattr(s, "name", None): getattr(s, "kind", None) for s in self.sources}
for node_id in sorted(mine):
config = (nodes[node_id].get("data") or {}).get("config") or {}
sql = str((config.get("args") or {}).get("sql") or "")
source = str(config.get("tool") or "").removeprefix("db_query__")
try:
# 模板写法({{ input.period_start }})换成一个字面量再查:守卫按 SQL 本身判,不按渲染出来的值判
check(re.sub(r"\{\{.*?\}\}", "0", sql, flags=re.S), readonly=True, source_name=source,
dialect=kinds.get(source))
except SqlRejected as e:
out.append({"code": SQL_REJECTED, "level": "error", "node_id": node_id, "field": "args.sql",
"message": f"取数的 SQL 过不了只读检查:{e}"})
graph = GraphApplier(copy.deepcopy(nodes), copy.deepcopy(self.applier.graph.get("edges") or []))
checker = GraphChecker(graph, sources=self.sources, checkers=self.checkers)
out += [i for i in checker.blocking() or [] if i.get("node_id") in mine]
return out
def _metric_issues(self) -> list[dict[str, Any]]:
"""指标式:只许 cell(nodes.<这一组的取数节点>, 0, '列名') 取格加算术、比较;静态追得到原始数据;取的列在那条 SQL 里。"""
import ast
from app.engine.expressions import ExpressionError, cell_parts, leaf_refs, parse_expression
from app.engine.lineage import static_stops
nodes = self._nodes()
fetches = {j.node_id for j in self.applier.group if j.kind == "sql"}
flat = {nid: {"type": n.get("type"), "title": (n.get("data") or {}).get("label") or nid,
"config": (n.get("data") or {}).get("config") or {}} for nid, n in nodes.items()}
out: list[dict[str, Any]] = []
for j in self.applier.group:
if j.kind != "metric_expr" or j.id not in self.applier.values:
continue
expr = self.applier.values[j.id]
def bad(message: str, code: str = METRIC_EXPR) -> None:
out.append({"code": code, "level": "error", "joint": j.id, "node_id": j.node_id,
"message": f"「{j.label or j.id}」{message}"})
try:
tree = parse_expression(expr)[0]
except ExpressionError as e:
bad(f"写得不对:{e}")
continue
calls = {n.func.id for n in ast.walk(tree) if isinstance(n, ast.Call) and isinstance(n.func, ast.Name)}
if extra := sorted(calls - METRIC_FUNCS):
bad(f"只能用 cell() 取格再做算术(可以用 {'、'.join(sorted(METRIC_FUNCS))}),不能用 {'、'.join(extra)}")
continue
refs = leaf_refs(tree)
if not refs:
bad("没有取任何数:要用 cell() 从取数的结果里取格")
continue
for path in refs:
parts = cell_parts(path)
node_id = parts[0][len("nodes."):] if parts and parts[0].startswith("nodes.") else ""
if parts is None or node_id not in fetches:
bad(f"只能用 cell(nodes.{'/'.join(sorted(fetches))}, 0, '列名') 取这一组取数的格,不能写 {path}")
break
if parts[1].strip() != "0":
bad("取数只出一行:行号写 0")
break
column = parts[2].strip().strip("'\"")
sql = str(((flat[node_id]["config"].get("args") or {}).get("sql")) or "")
if not re.search(rf"(?<![\w]){re.escape(column)}(?![\w])", sql, re.I):
bad(f"取的列「{column}」在取数的 SQL 里找不到:SQL 要交出这一列(列名照写)", METRIC_COLUMN)
break
else:
if stops := static_stops(flat, expr):
bad(f"的输入没有追到原始数据({'、'.join(s.path for s in stops[:3])}):要用 cell() 取查询结果的格")
return out
def repair_request(self, issues: list[dict[str, Any]], looks_text: str) -> str:
values = "\n".join(f"{jid}:{value}" for jid, value in self.applier.values.items()) or "(还没填)"
return TEMPLATE_REPAIR_PROMPT.format(values=values, issues=_issue_lines(issues),
looks=REPAIR_LOOKS_PROMPT.format(text=looks_text) if looks_text else "")