"""蓝图和关节的生成(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"(? 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 "")