From 9d8b41430e030cf90de97e52aa08a15eca5db596 Mon Sep 17 00:00:00 2001 From: ThearchyHelios Date: Fri, 6 Nov 2026 17:14:45 +0800 Subject: [PATCH] =?UTF-8?q?=E5=AE=8C=E5=96=84=E5=8F=A3=E5=BE=84=E7=AE=A1?= =?UTF-8?q?=E7=90=86=E3=80=81=E4=B8=BB=E5=8A=A8=E8=B0=83=E6=9F=A5=E4=B8=8E?= =?UTF-8?q?=E5=90=8E=E5=8F=B0=E4=BB=BB=E5=8A=A1=EF=BC=8C=E4=BF=AE=E5=A4=8D?= =?UTF-8?q?=E9=80=9A=E7=94=A8=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 完善口径编辑、只读试运行、统一质量验证与执行语义指纹 - 增加可执行指标约束、有预算的主动调查和过程评测 - 提取发布检查、证据读取与起草服务,支持任务停止、重试和检查点恢复 - 增加版本传递影响分析,修复并发覆盖、敏感值遮罩、异常评分及页面状态问题 - 修复 SQLite 复合查询追溯和 microVM 可选依赖缺失处理 - 纳入当前设计文档及部署配置调整 验证:相关主回归 3822 项通过,4 项跳过、8 项预期失败;补充回归通过。 前端构建、文案检查及 git diff --check 通过。 全量检查仍有环境权限、PostgreSQL 可选依赖和既有部署配置、SQLite 文件基线差异导致的未通过项。 --- backend/app/analysis/comparable.py | 4 +- backend/app/analysis/contract.py | 18 +- backend/app/analysis/phrases.py | 3 +- backend/app/api/evidence.py | 180 +- backend/app/api/ledger.py | 1008 +++++----- backend/app/api/workflows.py | 59 +- backend/app/apps/impact.py | 53 + backend/app/apps/validation.py | 24 + backend/app/apps/versions.py | 39 +- backend/app/core/artifact_store.py | 20 + backend/app/core/job_runtime.py | 2 + backend/app/data/caliber_identity.py | 40 +- backend/app/data/provenance_db.py | 10 +- backend/app/data/provenance_types.py | 3 +- backend/app/db/models.py | 14 + backend/app/engine/compare.py | 2 +- backend/app/engine/evidence_reader.py | 188 ++ backend/app/engine/investigation.py | 102 ++ backend/app/engine/metric_spec.py | 131 ++ backend/app/engine/nodes/io.py | 13 +- backend/app/engine/nodes/llm.py | 29 + backend/app/engine/nodes/metrics.py | 162 +- backend/app/engine/review.py | 3 +- backend/app/engine/schema.py | 25 +- backend/app/engine/validation.py | 65 + backend/app/eval/cases.py | 21 +- backend/app/eval/runner.py | 5 +- backend/app/eval/score.py | 30 +- backend/app/ledger/authoring.py | 504 +++++ backend/app/ledger/descriptor.py | 5 +- backend/app/ledger/docdraft.py | 26 +- backend/app/ledger/draftplan.py | 232 ++- backend/app/ledger/jobs.py | 99 + backend/app/look/calibers.py | 22 +- backend/app/main.py | 5 + backend/app/sandbox/microvm_sandbox.py | 4 +- backend/tests/test_analysis_bundle.py | 4 +- backend/tests/test_analysis_comparable.py | 13 +- backend/tests/test_analysis_decompose.py | 2 +- backend/tests/test_analysis_differ.py | 6 +- backend/tests/test_analysis_e2e_claims.py | 3 +- backend/tests/test_analysis_e2e_evidence.py | 6 +- backend/tests/test_analysis_evidence.py | 20 +- backend/tests/test_analysis_evidence_api.py | 14 +- backend/tests/test_analysis_judge_ledger.py | 2 +- backend/tests/test_analysis_pages.py | 4 +- backend/tests/test_architecture_contracts.py | 154 ++ backend/tests/test_architecture_impact.py | 18 + .../tests/test_architecture_investigation.py | 52 + backend/tests/test_architecture_lifecycle.py | 176 ++ backend/tests/test_caliber_from_nodes.py | 2 +- backend/tests/test_caliber_regressions.py | 337 ++++ .../tests/test_contract_analysis_claims.py | 10 +- backend/tests/test_eval_analysis.py | 5 + backend/tests/test_job_runtime.py | 25 + backend/tests/test_ledger_descriptor.py | 12 + backend/tests/test_ledger_docdraft_api.py | 252 ++- backend/tests/test_ledger_draftplan.py | 27 + backend/tests/test_ledger_trial.py | 39 + backend/tests/test_metric_definition.py | 20 + backend/tests/test_metric_value_artifact.py | 5 + backend/tests/test_microvm_kvm.py | 10 + backend/tests/test_provenance_contract.py | 2 +- backend/tests/test_provenance_db.py | 7 +- backend/tests/test_review.py | 2 +- backend/tests/test_sandbox_errors.py | 5 + deploy/agentlab.env | 10 +- docs/design/analysis-round/00-总方案.md | 234 +++ docs/design/analysis-round/01-算子与判定.md | 303 ++++ docs/design/app-platform-and-look.md | 1614 +++++++++++++++++ docs/design/caliber-authoring-optimization.md | 48 + docs/design/catalog-profiling-jobs.md | 200 ++ .../design/copilot-look-and-caliber-ledger.md | 897 +++++++++ docs/design/next-round/00-总方案.md | 179 ++ .../next-round/01-效果度量与回归评测.md | 318 ++++ docs/design/next-round/02-口径账本.md | 169 ++ .../next-round/03-数据接入与目录维护.md | 166 ++ docs/design/next-round/04-对外集成与触达.md | 217 +++ docs/design/next-round/05-已知遗留.md | 163 ++ docs/design/normal-user-apps.md | 927 ++++++++++ docs/design/orchestration-round/00-总方案.md | 82 + frontend/src/api/apps.ts | 3 + frontend/src/api/caliberLedger.ts | 2 + frontend/src/canvas/Inspector.tsx | 262 ++- frontend/src/canvas/nodeDefs.ts | 11 +- frontend/src/canvas/versionUsage.tsx | 17 +- frontend/src/lib/termsEval.ts | 2 +- frontend/src/lib/termsLedger.ts | 44 +- frontend/src/pages/evals/parts.tsx | 1 + frontend/src/pages/ledger/CaliberCatalog.tsx | 18 +- frontend/src/pages/ledger/CaliberDrafts.tsx | 218 ++- frontend/src/pages/ledger/TrialResult.tsx | 95 + frontend/src/typesEval.ts | 6 +- frontend/src/typesLedger.ts | 97 +- 94 files changed, 9730 insertions(+), 957 deletions(-) create mode 100644 backend/app/apps/impact.py create mode 100644 backend/app/apps/validation.py create mode 100644 backend/app/engine/evidence_reader.py create mode 100644 backend/app/engine/investigation.py create mode 100644 backend/app/engine/metric_spec.py create mode 100644 backend/app/engine/validation.py create mode 100644 backend/app/ledger/authoring.py create mode 100644 backend/app/ledger/jobs.py create mode 100644 backend/tests/test_architecture_contracts.py create mode 100644 backend/tests/test_architecture_impact.py create mode 100644 backend/tests/test_architecture_investigation.py create mode 100644 backend/tests/test_architecture_lifecycle.py create mode 100644 backend/tests/test_caliber_regressions.py create mode 100644 docs/design/analysis-round/00-总方案.md create mode 100644 docs/design/analysis-round/01-算子与判定.md create mode 100644 docs/design/app-platform-and-look.md create mode 100644 docs/design/caliber-authoring-optimization.md create mode 100644 docs/design/catalog-profiling-jobs.md create mode 100644 docs/design/copilot-look-and-caliber-ledger.md create mode 100644 docs/design/next-round/00-总方案.md create mode 100644 docs/design/next-round/01-效果度量与回归评测.md create mode 100644 docs/design/next-round/02-口径账本.md create mode 100644 docs/design/next-round/03-数据接入与目录维护.md create mode 100644 docs/design/next-round/04-对外集成与触达.md create mode 100644 docs/design/next-round/05-已知遗留.md create mode 100644 docs/design/normal-user-apps.md create mode 100644 docs/design/orchestration-round/00-总方案.md create mode 100644 frontend/src/pages/ledger/TrialResult.tsx diff --git a/backend/app/analysis/comparable.py b/backend/app/analysis/comparable.py index bc428e89..84627219 100644 --- a/backend/app/analysis/comparable.py +++ b/backend/app/analysis/comparable.py @@ -20,7 +20,7 @@ - 数据问题:两边查询结果上「查询区间里这张表没有数据」(快照的 no_data)fail,SQL 检查的错误级结果(快照的 checks,level error)warn。 -判定:有 fail → incomparable;只有 warn → conditional;其余 comparable(有核对不了的项照实写在判定语后面)。 +判定:有 fail → incomparable;有 warn 或 unknown → conditional;全部核对通过才是 comparable。 """ from __future__ import annotations @@ -437,7 +437,7 @@ def _decide(checks: list[Check], base: Span | None, target: Span | None) -> Outc fails = [r for c in checks if c.status == "fail" for r in c.reasons] warns = [r for c in checks if c.status == "warn" for r in c.reasons] unknown = [CHECK_NAMES[c.id] for c in checks if c.status == "unknown"] - verdict = "incomparable" if fails else "conditional" if warns else "comparable" + verdict = "incomparable" if fails else "conditional" if warns or unknown else "comparable" reasons = fails + warns structure = next((c.reasons[0] for c in checks if c.id == "same_query" and c.status == "fail" and c.reasons), None) return Outcome(checks, verdict, reasons, unknown, ph.comparable_phrase(verdict, reasons, unknown), structure, diff --git a/backend/app/analysis/contract.py b/backend/app/analysis/contract.py index 72ced36c..1da558d8 100644 --- a/backend/app/analysis/contract.py +++ b/backend/app/analysis/contract.py @@ -41,15 +41,15 @@ MODES: dict[str, tuple[str, ...]] = { } #: 每个算子(和模式)的方法名与版本:改算法就升版本号,结论里记下实际用的那一版 METHODS: dict[tuple[str, str | None], str] = { - ("decompose", "additive"): "decompose.additive@1", - ("decompose", "ratio"): "decompose.ratio@1", - ("decompose", "factor"): "decompose.factor@1", - ("baseline", None): "baseline.robust@1", - ("trend", None): "trend.theil_sen_mk@1", - ("differ", "rate"): "differ.rate@1", - ("differ", "mean"): "differ.welch@1", - ("differ", "rows"): "differ.welch_rows@1", - ("comparable", None): "comparable@1", + ("decompose", "additive"): "decompose.additive@2", + ("decompose", "ratio"): "decompose.ratio@2", + ("decompose", "factor"): "decompose.factor@2", + ("baseline", None): "baseline.robust@2", + ("trend", None): "trend.theil_sen_mk@2", + ("differ", "rate"): "differ.rate@2", + ("differ", "mean"): "differ.welch@2", + ("differ", "rows"): "differ.welch_rows@2", + ("comparable", None): "comparable@2", } #: agent 分析工具的名字(tools/builtin 里注册),一个算子一个工具:参数各自聚焦,模型少写错 TOOL_NAMES: dict[str, str] = {op: f"analysis_{op}" for op in OPS} diff --git a/backend/app/analysis/phrases.py b/backend/app/analysis/phrases.py index 36c24488..9bf16d65 100644 --- a/backend/app/analysis/phrases.py +++ b/backend/app/analysis/phrases.py @@ -523,7 +523,8 @@ def comparable_phrase(verdict: str, reasons: Sequence[str], unknown: Sequence[st if verdict == "incomparable": return "两期不可比:" + ";".join(reasons) if verdict == "conditional": - return "两期有条件可比:" + ";".join(reasons) + details = [*reasons, *([f"{'、'.join(unknown)}核对不了"] if unknown else [])] + return "两期有条件可比:" + ";".join(details) return "两期可比" + (f",{'、'.join(unknown)}核对不了" if unknown else "") diff --git a/backend/app/api/evidence.py b/backend/app/api/evidence.py index 64562266..fa2bfbf5 100644 --- a/backend/app/api/evidence.py +++ b/backend/app/api/evidence.py @@ -158,6 +158,16 @@ LEGACY_NOTE = "旧版出具:按数值匹配,不是显式引用。同一个 NONE_NOTE = "本次运行没有报告文档,也没有出具契约,没有可展示的证据" NO_DOC_NOTE = "本次运行的出具契约采用引用模式核对,但报告没有产出可核对的文档" +from app.engine.evidence_reader import Sealed as _Sealed, metric_cards as _sealed_cards, read_sealed + + +async def _sealed(run_id: str) -> _Sealed: + try: + return await read_sealed(run_id) + except LookupError: + raise CodedHTTPException(404, "运行记录不存在", RUN_NOT_FOUND) from None + + MASKED = "已遮罩" #: 面板底下的遮罩说明。遮的列可能是遮罩列本身,也可能是 SQL 里给它起了别名、套了函数得来的结果列(c.phone AS tel) MASK_NOTE = "这些列取自数据源中设置了遮罩的列,面板上不显示原值。遮罩只减少暴露,不是安全边界" @@ -186,146 +196,6 @@ MISSING_RETRIEVAL = "无法读取检索快照,或其中没有这条命中" # -------------------------------------------------------------------------- -@dataclass -class _Sealed: - run_id: str - graph: dict[str, Any] - seal: dict[str, Any] - events: list[tuple[int, str, str | None, dict[str, Any]]] - #: 封存范围内 node.finished.evidence 记过的 (节点, 工件) - ledger: set[tuple[str, str]] = field(default_factory=set) - #: 节点 id → 它最后一次 node.finished 的产出工件 id - outputs: dict[str, str] = field(default_factory=dict) - #: 封存范围内交回过的查询快照:工件 id → {node_id, tool, source, via, call_id, exec} - queries: dict[str, dict[str, Any]] = field(default_factory=dict) - #: 口径卡节点 id → 封存范围内它的 caliber.upgrade 事件(升版处置) - upgrades: dict[str, dict[str, Any]] = field(default_factory=dict) - #: 封存范围内 node.finished.evidence 的台账条目,按事件顺序(给可疑实体找「最接近的名字」重建目录用) - entries: list[dict[str, Any]] = field(default_factory=list) - #: 封存范围内交回过的表结构快照:工件 id → {node_id, source} - schemas: dict[str, dict[str, Any]] = field(default_factory=dict) - #: 封存范围内交回过的检索快照:工件 id → {node_id, source} - retrievals: dict[str, dict[str, Any]] = field(default_factory=dict) - #: 封存范围内台账里记过的分析结论:工件 id → {node_id, exec, call_id / item, inputs} - analyses: dict[str, dict[str, Any]] = field(default_factory=dict) - #: 封存时记下的清单哈希(没封存的是 None):旧答案的猜测按 (run_id, 它) 缓存 - manifest_hash: str | None = None - #: 运行记录上的 manifest_seq 原值(老运行是 None):按需裁判追加判定前核对封存没换过 - run_manifest_seq: int | None = None - #: verify_manifest 的完整结论(审计表照实附上) - verdict: dict[str, Any] = field(default_factory=dict) - #: 运行记录上的几样:按需裁判只给跑完、封存了的探索运行,升级前发起的不给 - run_class: str | None = None - status: str | None = None - ledger_on: bool = False - #: 按需裁判的判定(evidence.judged 事件),按先后:(seq, 节点, 载荷)。不管落在封存前后都收—— - #: 失败后接着跑的运行会把早先追加的判定封进新的清单里,它仍然是按需裁判的批注 - judged: list[tuple[int, str | None, dict[str, Any]]] = field(default_factory=list) - #: 节点 id → node.started 上记的名字、类型。子工作流里的节点(「子工作流节点 id/…」)不在这次运行的图上,名字从这里取 - labels: dict[str, str] = field(default_factory=dict) - types: dict[str, str] = field(default_factory=dict) - #: 这一次请求里复算过的分析结论:结论工件 id → 复算的结论(_analysis_recheck)。导出页里同一份结论被判定语、具名的数、 - #: 整表的每一格、分析范围各引一处,各接一条链,复算只做一次。只活在这一次请求里:下一次请求重新取、重新验 - rechecks: dict[str, dict[str, Any]] = field(default_factory=dict) - - @property - def sealed(self) -> bool: - return bool(self.seal["sealed"]) - - @property - def trusted(self) -> bool: - """封存了、而且封存核对通过。封存范围内有事件被改过的话,范围里的哪一条都不能再 - 当「已封存」展示——只看 sealed 的消费方会把一条断掉的封存链画成绿的。""" - return self.sealed and self.seal["ok"] is True - - def nodes_of(self, node_type: str, *, nested: bool = False) -> list[str]: - """这次运行的图上这一类的节点;nested 为真时连子工作流里跑过的一起给(「子工作流节点 id/…」,类型照 node.started 记的)。""" - out = [str(n.get("id")) for n in self.graph.get("nodes") or [] - if isinstance(n, dict) and n.get("type") == node_type] - if nested: - out += [nid for nid, kind in self.types.items() if "/" in nid and kind == node_type] - return out - - def payload_of(self, node_ids: list[str]) -> dict[str, Any] | None: - """这几个节点里最后跑完的那个的产出,从封存的事件指向的工件里取。""" - for node_id in reversed([nid for _, t, nid, _ in self.events if t == "node.finished" and nid in node_ids]): - artifact = self.outputs.get(node_id) - try: - payload = load(artifact) if artifact else None - except ValueError: - payload = None - if isinstance(payload, dict): - return payload - return None - - -async def _sealed(run_id: str) -> _Sealed: - from app.engine.runner import verify_manifest - - async with SessionLocal() as session: - run = await session.get(Run, run_id) - if run is None: - raise CodedHTTPException(404, "运行记录不存在", RUN_NOT_FOUND) - rows = [tuple(r) for r in await session.execute( - select(RunEvent.seq, RunEvent.type, RunEvent.node_id, RunEvent.data) - .where(RunEvent.run_id == run_id).order_by(RunEvent.seq))] - graph = run.graph if isinstance(run.graph, dict) else {} - manifest, manifest_seq = run.manifest_hash, run.manifest_seq - run_class, status, ledger_on = run.run_class, run.status, ledger_enabled(run) - verdict = await verify_manifest(run_id) - bound = verdict.get("sealed_at") if verdict.get("sealed") else None - # 没封存(还在跑、停在审批)的运行没有「封存之后」:现有的事件都算,但每件证据都标未封存 - events = [r for r in rows if bound is None or r[0] <= bound] - out = _Sealed(run_id=run_id, graph=graph, events=events, seal={ - "sealed": bool(verdict.get("sealed")), "ok": verdict.get("ok"), "manifest_seq": bound, - "legacy": bool(verdict.get("legacy", False))}, manifest_hash=manifest, run_manifest_seq=manifest_seq, - verdict=verdict, run_class=run_class, status=status, ledger_on=ledger_on, - judged=[(seq, nid, data) for seq, etype, nid, data in rows - if etype == EventType.EVIDENCE_JUDGED and isinstance(data, dict)]) - for _, etype, node_id, data in events: - if not node_id or not isinstance(data, dict): - continue - if etype == "tool.end": - if data.get("query_artifact"): - out.queries.setdefault(str(data["query_artifact"]), { - "node_id": node_id, "tool": data.get("tool"), "via": data.get("artifact")}) - if data.get("schema_artifact"): - out.schemas.setdefault(str(data["schema_artifact"]), {"node_id": node_id}) - elif etype == "retrieve.end" and data.get("artifact"): - out.retrievals.setdefault(str(data["artifact"]), {"node_id": node_id, "source": data.get("collection")}) - elif etype == "caliber.upgrade": - out.upgrades[node_id] = data - elif etype == "node.started": - if isinstance(data.get("label"), str) and data["label"]: - out.labels.setdefault(node_id, data["label"]) - if isinstance(data.get("node_type"), str) and data["node_type"]: - out.types.setdefault(node_id, data["node_type"]) - if etype != "node.finished": - continue - if data.get("artifact"): - out.outputs[node_id] = data["artifact"] - for entry in data.get("evidence") or []: - if isinstance(entry, dict) and entry.get("artifact"): - out.ledger.add((node_id, entry["artifact"])) - # 条目里另记的节点 id(双盲复算复算的口径卡)是那个节点所在那一层图里的 id:换成这次运行里的 - out.entries.append({**entry, "node_id": node_id, - **({"card": _scoped(node_id, entry["card"])} if entry.get("card") else {})}) - artifact = str(entry["artifact"]) - if entry.get("kind") == "query": - out.queries[artifact] = { - "node_id": node_id, **{k: entry[k] for k in ("tool", "source", "via", "call_id", "exec") - if entry.get(k) is not None}} - elif entry.get("kind") == "schema": - out.schemas[artifact] = {"node_id": node_id, "source": entry.get("source")} - elif entry.get("kind") == "retrieval": - out.retrievals[artifact] = {"node_id": node_id, "source": entry.get("source")} - elif entry.get("kind") == LEDGER_KIND: - out.analyses.setdefault(artifact, { - "node_id": node_id, **{k: entry[k] for k in ("exec", "call_id", "item", "inputs") - if entry.get(k) is not None}}) - return out - - # -------------------------------------------------------------------------- # 报告文档 # -------------------------------------------------------------------------- @@ -656,32 +526,6 @@ def _legacy(sealed: _Sealed, issuance: dict[str, Any]) -> dict[str, Any]: "field": fields[0] if len(fields) == 1 else None} -def _sealed_cards(sealed: _Sealed) -> list[tuple[str, str, dict[str, Any]]]: - """封存范围内的口径卡:(节点, 工件, 指标集)。取回时复验哈希,对不上的、取不回来的不要。 - - 一期以后口径卡把指标集落成 metric_set 工件、记在台账里;一期以前没有,节点的产出(node.finished - 的 node_output 工件)本身就是指标集——只对画布上确实是口径卡的节点这样认。 - """ - out: list[tuple[str, str, dict[str, Any]]] = [] - seen: set[tuple[str, str]] = set() - calibers = set(sealed.nodes_of("metrics")) - for _, etype, node_id, data in sealed.events: - if etype != "node.finished" or not node_id or not isinstance(data, dict): - continue - arts = [str(e["artifact"]) for e in data.get("evidence") or [] - if isinstance(e, dict) and e.get("kind") == "metric_set" and e.get("artifact")] - if not arts and node_id in calibers and data.get("artifact"): - arts = [str(data["artifact"])] - for art in arts: - if (node_id, art) in seen: - continue - seen.add((node_id, art)) - content, ok = _fetch(art) - if ok is True and isinstance(content, dict) and isinstance(content.get("metrics"), list): - out.append((node_id, art, content)) - return out - - def _metric_sources(sealed: _Sealed, nodes: list[Any]) -> dict[str, list[dict[str, Any]]]: """指标 id → 它在封存的口径卡里的样子(名字、值、渲染、口径、工件、eid)。nodes 为空时不限卡。""" wanted = {str(n) for n in nodes if n} @@ -3813,7 +3657,9 @@ async def _open_and_locate(base: dict[str, Any], table_only: Callable[..., Prove return table_only(refuse("unparsed")) if {name_key(t) for t in facts.tables} != {name_key(table)}: return table_only(refuse("multi_table", "authorizer")) - if facts.result_rows != 1 or facts.yields != 0: + # UNION / INTERSECT / EXCEPT 在部分 SQLite 版本使用临时表,没有 Yield 且只有一次 ResultRow。 + # 授权回调仍会报告多个 SELECT,不能把这种复合查询当成单表直接投影。 + if facts.selects != 1 or facts.result_rows != 1 or facts.yields != 0: return table_only(refuse("multi_table", "compound")) # R16:按主键参数化回查,恰好一行,值和快照里那一格类型、值都相等 try: diff --git a/backend/app/api/ledger.py b/backend/app/api/ledger.py index 116fd1e9..f5aa1300 100644 --- a/backend/app/api/ledger.py +++ b/backend/app/api/ledger.py @@ -29,7 +29,9 @@ from __future__ import annotations import asyncio +import copy import logging +import re from typing import Any from datetime import datetime @@ -45,6 +47,10 @@ from app.db.models_process import ActionRecord logger = logging.getLogger(__name__) +from app.ledger.authoring import ( + _can_repair, _columns_digest, _draft_payload, _entry_notes, _focus_tables, _fresh_metrics, _merge_questions, _merged_fields, _problems_text, _published_metrics, _question_count, _question_key, _schema_material, _source_tables, _status_for +) + router = APIRouter(prefix="/api/ledger", tags=["ledger"]) #: 口径账本里按运行读的那几个(一个答案里每个数的口径标签等,docs/design/next-round/02-口径账本.md):挂在 #: /api/runs/{run_id}/ledger… 下,和证据接口同一道门(main.py 挂 evidence_scope:使用者只读、而且要能读这次运行) @@ -334,6 +340,7 @@ async def trial_caliber(workflow_id: str, version: int, node_id: str, payload: T from app.apps.forms import input_schema, validate_form from app.engine.runner import run_manager from app.ledger.derive import NotDerivable, caliber_probe, load_pinned + from app.tools.datasource import READ_ONLY_OPTION try: pinned = await load_pinned(session, workflow_id, int(version), node_id) @@ -357,30 +364,24 @@ async def trial_caliber(workflow_id: str, version: int, node_id: str, payload: T graph=graph, input_payload=values, run_class="exploratory", origin_kind="caliber_trial", origin={"kind": "caliber_trial", "workflow_id": workflow_id, "version": int(version), "node_id": node_id, "card": pinned.node_id}, + options={READ_ONLY_OPTION: True}, started_by=principal.username) row = None for _ in range(300): async with SessionLocal() as poll: row = await poll.get(Run, run.id) - if row is None or row.status in ("succeeded", "failed", "cancelled"): + if row is None or row.status in TERMINAL_RUN_STATUSES: break await asyncio.sleep(0.1) out: dict[str, Any] = {"run_id": run.id, "status": row.status if row is not None else "unknown", - "output": row.output if row is not None else None, - "error": row.error if row is not None else None, "metrics": []} + "output": None, + "error": row.error if row is not None else None, "metrics": [], + "queries": []} if row is not None and row.status == "succeeded": - from app.api.evidence import _sealed, _sealed_cards - - sealed = await _sealed(run.id) - cards = {nid: card for nid, _, card in _sealed_cards(sealed)} - card = cards.get(pinned.node_id) or next(iter(cards.values()), None) - if card is not None: - out["metrics"] = [ - {k: m.get(k) for k in ("id", "name", "unit", "value", "rendered", "decimals", "format") - if m.get(k) is not None} - for m in card.get("metrics") or [] if isinstance(m, dict) and m.get("id") - ] + out["metrics"] = await _trial_metrics(run.id) + out["queries"] = await _trial_queries(run.id) + out["validation"] = await _trial_validation(run.id, str(out["status"]), out["metrics"]) return out @@ -393,34 +394,12 @@ DRAFT_DISMISS_ACTION = "ledger.docdraft.dismiss" DRAFT_SUBJECT_KIND = "ledger_draft" -def _question_count(payload: dict[str, Any]) -> int: - """未决问题数:跨指标的 open_questions + 各指标自己的。status 就按它算,PATCH 之后重算也用它。""" - def count(items: Any) -> int: - return len([q for q in items if isinstance(q, str) and q.strip()]) if isinstance(items, list) else 0 - - total = count(payload.get("open_questions")) - metrics = payload.get("metrics") - for metric in metrics if isinstance(metrics, list) else []: - if isinstance(metric, dict): - total += count(metric.get("open_questions")) - return total - - -def _status_for(payload: dict[str, Any]) -> str: - """还有问题 → questions_open;没有问题且有图 → ready;没有图的老草稿 → path_pending。 - - questions_open 现在也有图和数,只是问题还没清完,不阻断试跑;path_pending 只留给这次改版之前起草、还没有取数路径的草稿。 - """ - if _question_count(payload): - return "questions_open" - if payload.get("workflow_id") and payload.get("sql"): - return "ready" - return "path_pending" - - #: 试跑最多等多久(秒)和轮询间隔:和 trial_caliber 的 300 × 0.1 一致 TRIAL_TIMEOUT_S = 30 TRIAL_POLL_S = 0.1 +#: 试跑结果集最多给几个查询、每个查询前几行:一次试跑只取数不写报告,主要就是验证这几条 SQL +TRIAL_QUERY_LIMIT = 5 +TRIAL_QUERY_ROWS = 20 #: 运行到这里就是终态了(interrupted 归到 failed,接口契约只有三种状态) TERMINAL_RUN_STATUSES = ("succeeded", "failed", "cancelled", "interrupted") @@ -434,96 +413,74 @@ def _run_status(status: Any) -> str: return "running" -def _source_tables(source: DataSource) -> dict[str, Any]: - cache = source.schema_cache if isinstance(source.schema_cache, dict) else {} - tables = cache.get("tables") - return tables if isinstance(tables, dict) else {} +def _trial_binding(version: int, graph: dict[str, Any], values: dict[str, Any]) -> dict[str, Any]: + from app.core.artifact_store import canonical_json, content_hash, execution_hash, graph_hash + + return {"version": version, "graph_hash": graph_hash(graph), "execution_hash": execution_hash(graph), + "inputs_hash": content_hash(canonical_json(values))} -def _focus_tables(tables: list[str], text: str, limit: int = 8) -> list[str]: - """文档里点到的表优先展开:按它在原文里第一次出现的位置排,没点到的按目录顺序补几张。 - - 目录动辄十万字符,全塞给模型会把文档和别的表挤掉。先给全量列名(见 _columns_digest),再把文档点到的 - 那几张表展开注释和目录——写 SQL 需要的列名一个不少,细节只给用得上的。 - """ - lowered = (text or "").lower() - hit: list[tuple[int, str]] = [] - for table in tables: - name = str(table).lower() - short = name.split(".")[-1] - at = lowered.find(name) - if at < 0: - at = lowered.find(short) - if at >= 0: - hit.append((at, table)) - hit.sort(key=lambda pair: pair[0]) - picked = [table for _, table in hit][:limit] - for table in tables: - if len(picked) >= min(limit, 5): - break - if table not in picked: - picked.append(table) - return picked +def _doc_draft_workflow(workflow: Workflow | None) -> bool: + return workflow is not None and isinstance(workflow.origin, dict) and workflow.origin.get("kind") == "doc_draft" -def _columns_digest(source: DataSource, tables: list[str]) -> str: - """每张表的列名清单(只给名字):要写 SQL,先得知道有哪些列。注释和目录留给下面展开的那几张。""" - from app.data import introspect +async def _trial_view(session: AsyncSession, trial: dict[str, Any], *, version: int, + graph: dict[str, Any]) -> dict[str, Any]: + """历史试跑只能证明当时的版本和输入;运行超时返回 running 后,读时从 Run 刷新终态。""" + from app.core.artifact_store import canonical_json, content_hash, execution_hash, graph_hash - lines = ["各表的列(只列名字;下面展开的表另有类型、注释和数据目录):"] - for table in tables: - meta = introspect.find_table(source, table) - columns = [str(c.get("name")) for c in (meta or {}).get("columns") or [] if c.get("name")] - if columns: - lines.append(f"{table}: {', '.join(columns)}") - return "\n".join(lines) + if not trial: + return {} + run = await session.get(Run, trial["run_id"]) if trial.get("run_id") else None + same_graph = (trial.get("execution_hash") == execution_hash(graph) if trial.get("execution_hash") + else trial.get("version") == version and trial.get("graph_hash") == graph_hash(graph)) + if (not same_graph + or (run is not None and trial.get("inputs_hash") != content_hash(canonical_json(run.input or {})))): + return {**trial, "status": "stale", "error": "口径已修改或试运行缺少版本记录,请重新试运行"} + if trial.get("run_id") and run is None: + return {**trial, "status": "stale", "error": "试运行记录已不存在,请重新试运行"} + if run is not None: + return {**trial, "status": _run_status(run.status), "error": str(run.error or "")} + return trial -async def _schema_material(session: AsyncSession, source: DataSource, focus_text: str = "" - ) -> tuple[str, set[str]]: - """这个数据源给模型看的表结构 + 数据目录文本,以及 SQL 允许出现的表名集合。 - - 文本按「概览 → 全量表索引 → 全量列名 → 文档点到的表展开」的顺序排:预算不够时先丢的是最后那一段, - 列名不会丢。表名一律照目录原文,模型照着抄。 - """ - from app.data import catalog, schema_view - - cache = source.schema_cache if isinstance(source.schema_cache, dict) else {} - tables = _source_tables(source) - if not tables: - return "", set() - entries = await catalog.read_catalog(session, source.id) - notes = {table: entry.notes for table, entry in entries.items()} - lines = [schema_view.overview_line(source, catalog_tables=sum( - 1 for entry in entries.values() if catalog.visible_notes(entry.notes)))] - lines.append(schema_view.table_index(source, notes)) - lines.append(_columns_digest(source, tables)) - for table in _focus_tables(tables, focus_text): - lines.append(schema_view.table_detail(source, table, catalog_notes=notes)) - schema = cache.get("schema") - allowed: set[str] = set() - for table in tables: - allowed.add(str(table)) - if isinstance(schema, str) and schema: - allowed.add(f"{schema}.{table}") - return "\n\n".join(line for line in lines if line), allowed - - -async def _save_trial(draft_id: str, trial: dict[str, str]) -> None: - """把试跑状态写进 draft.payload["trial"](用新会话:后台这一段和起草那段不是同一个事务)。""" +async def _save_trial(draft_id: str, trial: dict[str, Any], *, replace: bool = False) -> bool: + """旧版本或较早的试跑晚结束时,不能覆盖新的验证结果。""" async with SessionLocal() as fresh: - row = await fresh.get(CaliberDraft, draft_id) + row = await fresh.scalar(select(CaliberDraft).where(CaliberDraft.id == draft_id).with_for_update()) if row is None or not isinstance(row.payload, dict): - return + return False + workflow = await fresh.get(Workflow, row.workflow_id) if row.workflow_id else None + if _doc_draft_workflow(workflow): + from app.core.artifact_store import execution_hash, graph_hash + + same_graph = (trial.get("execution_hash") == execution_hash(workflow.graph) if trial.get("execution_hash") + else trial.get("version") == workflow.version and trial.get("graph_hash") == graph_hash(workflow.graph)) + if not same_graph: + return False body = dict(row.payload) + current = body.get("trial") or {} + if not replace and current.get("trial_id") and current.get("trial_id") != trial.get("trial_id"): + return False + if not replace and current.get("run_id") and current.get("run_id") != trial.get("run_id"): + return False body["trial"] = trial - row.payload = body + # SQLite 忽略 FOR UPDATE;加上读时的修订时间,避免并发完成或编辑覆盖刚写入的新内容。 + from sqlalchemy import update + + saved = await fresh.execute(update(CaliberDraft).where( + CaliberDraft.id == draft_id, CaliberDraft.updated_at == row.updated_at, + ).values(payload=body).execution_options(synchronize_session=False)) + if saved.rowcount != 1: + await fresh.rollback() + return False await fresh.commit() + return True async def _run_trial(*, draft_id: str, workflow_id: str, graph: dict[str, Any], values: dict[str, Any], - started_by: str | None, - entry_fields: list[dict[str, Any]] | tuple[dict[str, Any], ...] = ()) -> dict[str, str]: + started_by: str | None, version: int = 1, + entry_fields: list[dict[str, Any]] | tuple[dict[str, Any], ...] = ()) -> dict[str, Any]: """后台试跑草稿的取数与口径卡,返回 payload["trial"] 的形状(run_id / status / error)。 必填入口没有值时不跑:试跑是为了「看数对不对」,拿系统编的日期跑出来的数没有意义。这时状态记 needs_input, @@ -537,34 +494,47 @@ async def _run_trial(*, draft_id: str, workflow_id: str, graph: dict[str, Any], """ from app.engine.runner import run_manager from app.ledger.derive import NotDerivable, caliber_probe, load_pinned + from app.tools.datasource import READ_ONLY_OPTION + from uuid import uuid4 + + binding = {**_trial_binding(version, graph, values), "trial_id": uuid4().hex} + + async def failed(status: str, error: str, *, reserve: bool = False) -> dict[str, Any]: + trial = {**binding, "run_id": "", "status": status, "error": error} + await _save_trial(draft_id, trial, replace=reserve) + return trial missing = [str(field.get("name")) for field in entry_fields if field.get("required") and values.get(str(field.get("name"))) in (None, "")] if missing: - return {"run_id": "", "status": "needs_input", - "error": f"缺少必填输入:{'、'.join(missing)}"} + return await failed("needs_input", f"缺少必填输入:{'、'.join(missing)}", reserve=True) + + pending = {**binding, "run_id": "", "status": "running", "error": ""} + if not await _save_trial(draft_id, pending, replace=True): + return {**pending, "status": "stale", "error": "口径已修改,请重新试运行"} card_id = next((n.get("id") for n in graph.get("nodes") or [] if isinstance(n, dict) and n.get("type") == "metrics"), None) if not card_id: - return {"run_id": "", "status": "failed", "error": "这份草稿没有口径卡,没有可试运行的指标"} + return await failed("failed", "这份草稿没有口径卡,没有可试运行的指标") try: async with SessionLocal() as read: - pinned = await load_pinned(read, workflow_id, 1, str(card_id)) + pinned = await load_pinned(read, workflow_id, version, str(card_id)) target = caliber_probe(pinned).graph except NotDerivable as e: - return {"run_id": "", "status": "failed", "error": f"口径卡不能派生探针:{e.message}"} + return await failed("failed", f"口径卡不能派生探针:{e.message}") try: run = await run_manager.start( graph=target, input_payload=values, run_class="exploratory", origin_kind="caliber_trial", - origin={"kind": "caliber_trial", "draft_id": draft_id, "workflow_id": workflow_id, "version": 1, + origin={**binding, "kind": "caliber_trial", "draft_id": draft_id, "workflow_id": workflow_id, "node_id": str(card_id)}, + options={READ_ONLY_OPTION: True}, started_by=started_by) except Exception as e: # noqa: BLE001 - 试跑发起失败不该把已经生成的草稿弄没 logger.exception("草稿 %s 的试运行没有发起", draft_id) - return {"run_id": "", "status": "failed", "error": f"试运行没有发起:{e}"} - await _save_trial(draft_id, {"run_id": run.id, "status": "running", "error": ""}) + return await failed("failed", f"试运行没有发起:{e}") + await _save_trial(draft_id, {**binding, "run_id": run.id, "status": "running", "error": ""}) row: Run | None = None for _ in range(int(TRIAL_TIMEOUT_S / TRIAL_POLL_S)): async with SessionLocal() as poll: @@ -574,45 +544,327 @@ async def _run_trial(*, draft_id: str, workflow_id: str, graph: dict[str, Any], await asyncio.sleep(TRIAL_POLL_S) status = _run_status(row.status if row is not None else "running") error = str(row.error or "") if row is not None and status == "failed" else "" - return {"run_id": run.id, "status": status, "error": error} + return {**binding, "run_id": run.id, "status": status, "error": error} async def _trial_metrics(run_id: str) -> list[dict[str, Any]]: - """试跑运行里口径卡上的指标值:复用证据接口读封存的现成函数,不另写指标集解析。""" - from app.api.evidence import _sealed, _sealed_cards + """试运行和证据面板共享封存核验、遮罩和质量判定。""" + from app.engine.evidence_reader import read_sealed, metric_cards + from app.engine.validation import metric_quality + from app.api.evidence import _Masks, _metric_masks try: - sealed = await _sealed(run_id) - cards = {node_id: card for node_id, _, card in _sealed_cards(sealed)} - except Exception: # noqa: BLE001 - 证据读不出来就只给状态,不编造指标值 - logger.exception("试跑 %s 的指标值读不出来", run_id) + sealed = await read_sealed(run_id) + if not sealed.trusted: + return [] + masks = _Masks(sealed) + out = [] + for node_id, artifact, card in metric_cards(sealed): + for metric in card.get("metrics") or []: + if not isinstance(metric, dict) or not metric.get("id"): + continue + _, hidden = await _metric_masks(metric, node_id, artifact, sealed, masks, card=card) + projected = {k: metric[k] for k in ("id", "name", "unit", "value", "rendered", "decimals", "format", "status") + if metric.get(k) is not None} + if hidden: + projected.pop("value", None) + projected.update(rendered=hidden, masked=True, quality={"status": "unknown", "checks": [ + {"code": "masked", "status": "unknown", "detail": "指标值已隐藏,不能在此验证"}]}) + else: + projected["quality"] = metric_quality(metric) + if card.get("validation_version") != 1: + projected["quality"]["checks"].append({"code": "legacy_validation", "status": "unknown", + "detail": "历史结果缺少完整质量记录,请重新试运行"}) + if projected["quality"]["status"] == "pass": + projected["quality"]["status"] = "unknown" + out.append(projected) + return out + except Exception: + logger.exception("试运行 %s 的指标证据无法验证", run_id) return [] - card = cards.get("card") or next(iter(cards.values()), None) - if not isinstance(card, dict): - return [] - return [{k: m.get(k) for k in ("id", "name", "rendered", "value") if m.get(k) is not None} - for m in card.get("metrics") or [] if isinstance(m, dict) and m.get("id")] -def _draft_summary(draft: CaliberDraft) -> dict[str, Any]: +async def _trial_validation(run_id: str, status: str, metrics: list[dict[str, Any]]) -> dict[str, Any]: + from app.engine.evidence_reader import read_sealed + from app.engine.validation import validation_report + + trusted = False + if run_id: + try: + trusted = (await read_sealed(run_id)).trusted + except (LookupError, ValueError, OSError): + pass + return validation_report(metrics, execution=status, trusted=trusted) + + +async def _trial_queries(run_id: str) -> list[dict[str, Any]]: + """试跑运行里取数节点交回的查询快照:页面据此直接看「这条 SQL 查回来什么」。 + + 只读封存范围内的查询快照(sealed.queries,和证据接口同一份认法),最多 TRIAL_QUERY_LIMIT 个、每个前 TRIAL_QUERY_ROWS + 行。遮罩列按 _Masks.sets(...).hidden 处理(数据源现在设的加查询当时记下的),格子换成「已遮罩」;masked 列出被遮的 + 列名。**不返回 SQL**:SQL 里可能写着遮罩值,试跑页面只需要列和行。 + + 返回的每一项是 {node_id, tool, source, columns, rows, total_rows, truncated, masked};读不出来的快照跳过,整个读不出 + 封存时就照实给空数组,不编造数据。import 放在函数里:api/evidence 与 api/ledger 互相引用,模块顶层导入会成环。 + """ + from app.api.evidence import MASKED, _Masks + from app.engine.evidence_reader import read_sealed + from app.core.artifact_store import load + + try: + sealed = await read_sealed(run_id) + if not sealed.trusted: + return [] + masks = _Masks(sealed) + except Exception: # noqa: BLE001 - 证据读不出来就只给状态,不编造结果集 + logger.exception("试跑 %s 的结果集读不出来", run_id) + return [] + out: list[dict[str, Any]] = [] + for artifact, info in list(sealed.queries.items())[:TRIAL_QUERY_LIMIT]: + try: + snap = load(artifact) + if not isinstance(snap, dict) or not isinstance(snap.get("rows"), list): + continue + source = (info or {}).get("source") or snap.get("source") + columns = [str(column) for column in snap.get("columns") or []] + if any(not isinstance(row, list) or len(row) != len(columns) for row in snap["rows"]): + raise ValueError("查询行与列定义不一致,不能安全展示") + hidden = (await masks.sets(artifact, source, snap)).hidden + masked = {position for position, column in enumerate(columns) if column.lower() in hidden} + # 只给前 TRIAL_QUERY_ROWS 行:查询本身被工具截断、或者这里只展示了前几行,都要在 truncated 里说清 + rows = [[MASKED if position in masked else value for position, value in enumerate(row)] + if isinstance(row, list) else row + for row in snap["rows"][:TRIAL_QUERY_ROWS]] + except Exception: # noqa: BLE001 - 这一份快照读不出来、认不出遮罩,跳过它,不影响别的查询 + logger.warning("试跑 %s 的查询快照 %s 读不出来", run_id, artifact, exc_info=True) + continue + out.append({ + "node_id": (info or {}).get("node_id"), + "tool": (info or {}).get("tool"), + "source": source, + "columns": columns, + "rows": rows, + "total_rows": len(snap["rows"]), + "truncated": bool(snap.get("truncated")) or len(snap["rows"]) > TRIAL_QUERY_ROWS, + "source_truncated": bool(snap.get("truncated")), + "preview_truncated": len(snap["rows"]) > TRIAL_QUERY_ROWS, + "masked": [columns[position] for position in sorted(masked)], + }) + return out + + +def _draft_summary(draft: CaliberDraft, *, graph: dict[str, Any] | None = None) -> dict[str, Any]: + """列表和详情共用的一份摘要:细状态照实给,另给界面用的四阶段 stage。""" + from app.ledger import docdraft + payload = draft.payload if isinstance(draft.payload, dict) else {} + if graph is not None: + from app.apps.tree import node_map + + card = next((node for node in node_map(graph).values() if node.get("type") == "metrics" + and not (node.get("config") or {}).get("caliber_from")), None) + payload = {**payload, "metrics": ((card or {}).get("config") or {}).get("metrics") or []} metrics = payload.get("metrics") return {"id": draft.id, "title": draft.title, "status": draft.status, + "stage": docdraft.stage_of(draft.status), "metric_count": len(metrics) if isinstance(metrics, list) else 0, "question_count": _question_count(payload), "workflow_id": draft.workflow_id, "created_at": draft.created_at.isoformat() if draft.created_at else None, "error": draft.error} async def _draft_view(session: AsyncSession, draft: CaliberDraft) -> dict[str, Any]: - """一份草稿的完整样子:payload 摊平在上层(界面直接读 metrics / open_questions),另加状态和关联工作流名。""" + """一份草稿的完整样子:payload 摊平在上层(界面直接读 metrics / open_questions),另加状态和关联工作流名。 + + 文档起草建了工作流和 v1 之后,图是唯一真相:画布上改过指标或取数 SQL,这里从图里取最新的那几项覆盖 payload + 里的起草快照。接上一次运行(docattach)的草稿仍看 payload。 + """ payload = draft.payload if isinstance(draft.payload, dict) else {} workflow = await session.get(Workflow, draft.workflow_id) if draft.workflow_id else None - return {**payload, **_draft_summary(draft), + view = {**payload, **_draft_summary(draft), "collection": draft.collection, "datasource_id": draft.datasource_id, "origin": draft.origin if isinstance(draft.origin, dict) else {}, "model_id": draft.model_id or payload.get("model_id"), "created_by": draft.created_by, "updated_at": draft.updated_at.isoformat() if draft.updated_at else None, "workflow_name": workflow.name if workflow is not None else None} + if _doc_draft_workflow(workflow): + live = await _draft_graph_payload(session, workflow, workflow.version, draft=draft, payload=payload) + if live is not None: + view.update(live) + view["metric_count"] = len(live["metrics"]) + view["question_count"] = _question_count(view) + from app.db.models import CaliberDraftJob + + job = await session.get(CaliberDraftJob, draft.id) + if job: + view["job"] = {"status": job.status, "attempt": job.attempt, "error": job.error, + "retryable": draft.status == "failed" and not draft.workflow_id + and job.status not in ("queued", "running")} + return view + + +def _graph_entry_fields(graph: dict[str, Any]) -> list[dict[str, Any]]: + """图里入口节点(input)的字段;草稿页和试跑用它,画布改过的入口字段不会被 payload 的旧快照盖住。""" + from app.apps.tree import node_map + + start = next((node for node in node_map(graph).values() if node.get("type") == "input"), None) + return [dict(field) for field in ((start or {}).get("config") or {}).get("fields") or [] + if isinstance(field, dict)] + + +def _allowed_tables(source: DataSource | None) -> set[str]: + """这个数据源目录里允许出现的表名;和 _sql_problem 判表在不在目录用的是同一套键。""" + if source is None: + return set() + tables = _source_tables(source) + allowed = {str(table) for table in tables} + cache = source.schema_cache if isinstance(source.schema_cache, dict) else {} + schema = cache.get("schema") + if isinstance(schema, str) and schema: + allowed |= {f"{schema}.{table}" for table in tables} + return allowed + + +async def _draft_checks(session: AsyncSession, draft: CaliberDraft, payload: dict[str, Any], graph: dict[str, Any], + metrics: list[dict[str, Any]], groups: list[dict[str, Any]]) -> list[dict[str, Any]]: + """发布前检查:只读地算一份清单,不拦人,也不发起运行。 + + 五项:图能过发布校验、取数都是单条只读 SELECT 且表在目录里、入口字段与 SQL 占位符一致、试运行成功、 + 每条指标都有引用原文。旧草稿没有图时不给 checks,页面整块不画。 + """ + from app.apps.validation import gate_issues + from app.ledger import draftplan + + checks: list[dict[str, Any]] = [] + detail, graph_ok = "", True + try: + issues = await gate_issues(session, graph, "published") + errors = [issue for issue in issues or [] if issue["level"] == "error"] + if issues is None: + raise ValueError("图结构无法解析") + except Exception as e: # noqa: BLE001 - 图读不出来也是一项没通过,照实说 + errors = [] + graph_ok = False + detail = f"图读不出来:{e}" + if errors: + graph_ok = False + detail = ";".join(str(issue["message"]) for issue in errors[:3]) + if len(errors) > 3: + detail += f";另有 {len(errors) - 3} 项" + checks.append({"key": "graph", "label": "图能过发布校验", "ok": graph_ok, "detail": detail}) + + source = await session.get(DataSource, draft.datasource_id) if draft.datasource_id else None + allowed = _allowed_tables(source) + sql_reasons: list[str] = [] + for group in groups: + sql = str(group.get("sql") or "") + if not sql.strip(): + sql_reasons.append("有一组没有取数 SQL") + continue + if reason := draftplan._sql_problem(sql, allowed): + sql_reasons.append(reason) + sql_detail = ";".join(dict.fromkeys(sql_reasons)) + checks.append({"key": "sql", "label": "取数都是单条只读 SELECT,表在数据目录里", + "ok": not sql_reasons, "detail": sql_detail}) + + fields = _graph_entry_fields(graph) + declared = {str(field.get("name")) for field in fields if field.get("name")} + used: set[str] = set() + for group in groups: + used |= set(draftplan._INPUT.findall(str(group.get("sql") or ""))) + missing = sorted(used - declared) + unused = sorted(declared - used) + entry_detail: list[str] = [] + if missing: + entry_detail.append(f"SQL 里用了没声明的入口:{'、'.join(missing)}") + if unused: + entry_detail.append(f"声明了但 SQL 没用到的入口:{'、'.join(unused)}") + checks.append({"key": "entries", "label": "入口字段与 SQL 占位符一致", "ok": not entry_detail, + "detail": ";".join(entry_detail)}) + + trial = payload.get("trial") if isinstance(payload.get("trial"), dict) else {} + trial_status = str(trial.get("status") or "") + trial_detail = "" + if trial_status == "failed": + trial_detail = str(trial.get("error") or "试运行没有成功") + elif trial_status == "needs_input": + trial_detail = "等补入口字段" + elif trial_status == "running": + trial_detail = "试运行还在进行" + elif trial_status == "stale": + trial_detail = "口径已修改,请重新试运行" + elif not trial_status: + trial_detail = "还没有试运行" + run_id = str(trial.get("run_id") or "") + trial_metrics = await _trial_metrics(run_id) if run_id and trial_status == "succeeded" else [] + validation = await _trial_validation(run_id, trial_status, trial_metrics) + quality_details = ";".join(c["detail"] for c in validation["checks"][:5]) + checks.append({"key": "trial", "label": "试运行结果通过质量检查", "ok": validation["status"] == "pass", + "status": validation["status"], "detail": trial_detail or quality_details}) + + missing_citations = [str(metric.get("name") or metric.get("id")) for metric in metrics + if not (metric.get("definition") or {}).get("citations")] + checks.append({"key": "citations", "label": "每条指标都有引用原文", "ok": not missing_citations, + "detail": ";".join(f"「{name}」没有引用原文" for name in missing_citations[:5])}) + return checks + + +async def _draft_graph_payload(session: AsyncSession, workflow: Workflow, version: int, *, + draft: CaliberDraft | None = None, + payload: dict[str, Any] | None = None) -> dict[str, Any] | None: + """从草稿工作流的某一版图里取出草稿页要显示的那几项(指标、入口、SQL、groups)。 + + 图是唯一真相:画布上改了指标名、定义或取数 SQL,草稿页跟着图变,不显示 payload 里起草那一刻的快照。取数节点 + 按 SQL 分组,指标按表达式里引用的节点归组;表达式不引用取数节点的(比如只读 vars)归到第一组。图里没有口径卡 + 或没有指标时交回空指标列表,不能退回起草时的旧快照。 + """ + from app.apps.tree import node_map + + row = (await session.execute(select(WorkflowVersion).where( + WorkflowVersion.workflow_id == workflow.id, + WorkflowVersion.version == int(version)))).scalar_one_or_none() + if row is None or not isinstance(row.graph, dict): + return None + nodes = node_map(row.graph) + card = next((node for node in nodes.values() + if node.get("type") == "metrics" + and not (node.get("config") or {}).get("caliber_from")), None) + metrics = [metric for metric in ((card or {}).get("config") or {}).get("metrics") or [] + if isinstance(metric, dict) and metric.get("id")] + fields = _graph_entry_fields(row.graph) + fetches: list[tuple[str, str]] = [] + for node_id, node in nodes.items(): + config = node.get("config") if isinstance(node.get("config"), dict) else {} + args = config.get("args") if isinstance(config.get("args"), dict) else {} + sql = args.get("sql") if isinstance(args, dict) else None + if node.get("type") == "tool" and isinstance(sql, str) and sql.strip(): + fetches.append((str(node_id), sql)) + groups: list[dict[str, Any]] = [] + for node_id, sql in fetches: + pattern = re.compile(rf"nodes\.{re.escape(node_id)}(?![\w])") + from app.look.calibers import _metric_paths + + ids = [str(metric["id"]) for metric in metrics + if any(pattern.search(path) for path in _metric_paths(metric) or [])] + groups.append({"sql": sql, "entry_fields": [dict(field) for field in fields], "metric_ids": ids}) + assigned = {metric_id for group in groups for metric_id in group["metric_ids"]} + loose = [str(metric["id"]) for metric in metrics if str(metric["id"]) not in assigned] + if loose: + if groups: + groups[0]["metric_ids"].extend(loose) + else: + groups = [{"sql": "", "entry_fields": [dict(field) for field in fields], "metric_ids": loose}] + out = { + "version": version, + "metrics": metrics, + "entry_fields": [dict(field) for field in fields], + "sql": "\n\n".join(sql for _node, sql in fetches), + "groups": groups, + "blocks": len(fetches) or 1, + } + if draft is not None and payload is not None: + out["trial"] = await _trial_view(session, payload.get("trial") or {}, version=version, graph=row.graph) + out["checks"] = await _draft_checks(session, draft, {**payload, "trial": out["trial"]}, row.graph, metrics, groups) + return out async def _draft_or_404(session: AsyncSession, draft_id: str) -> CaliberDraft: @@ -622,28 +874,6 @@ async def _draft_or_404(session: AsyncSession, draft_id: str) -> CaliberDraft: return draft -async def _published_metrics(session: AsyncSession) -> list[dict[str, Any]]: - """已发布口径里的每一项指标,给 docdraft.alignment 用:{workflow_id, version, node_id, name, synonyms, unit, - definition_text}。直接复用 descriptor.list_calibers 的认法(只看最新已发布且未退役版本的源头口径卡)。""" - from app.ledger.descriptor import list_calibers - - out: list[dict[str, Any]] = [] - for described in await list_calibers(session): - for metric in described.get("metrics") or []: - if not isinstance(metric, dict): - continue - definition = metric.get("definition") - text = definition.get("text") if isinstance(definition, dict) else None - name = metric.get("name") or metric.get("id") - if not name: - continue - out.append({"workflow_id": described["workflow_id"], "version": described["version"], - "node_id": described["card"]["node_id"], "name": str(name), - "synonyms": metric.get("synonyms") if isinstance(metric.get("synonyms"), list) else [], - "unit": metric.get("unit") or "", "definition_text": text or ""}) - return out - - def _doc_title(docs: list[Document]) -> str: """草稿标题取第一份文档的名字;多份文档写明还有几份。""" first = docs[0] @@ -651,305 +881,28 @@ def _doc_title(docs: list[Document]) -> str: return (f"{title}等 {len(docs)} 份文档" if len(docs) > 1 else title)[:200] -#: 一次起草最多续跑几轮:模型一轮只给一组指标,文档里的指标分成几组由它自己切,覆盖完就停。 -#: 实测一份 39 条指标的文档,每轮 3–5 条,6 轮只覆盖 22 条,所以放到 10 轮(约 5 分钟)。 -MAX_ROUNDS = 10 +#: 一次起草最多续跑几轮:模型一次尽量交多组,一组交不下时按 uncovered 再续,最多 4 轮。 +#: 以前一轮只收一组、放到 10 轮;现在多组一次交,轮数只留给「这一轮写不下」的余量。 +MAX_ROUNDS = 4 #: 一张草稿图最多收几条指标:再多,一张口径卡和一次试跑都读不动 MAX_METRICS = 60 -def _merged_fields(blocks: list[dict[str, Any]]) -> list[dict[str, Any]]: - """各组声明的入口字段合成一份,给界面看:它和建图用的是同一套合并(draftplan.merge_entry_fields)。 - - 早先这里是「先出现的为准」的另一份实现,建图那边还会因为同名冲突抛错——两份逻辑说不到一起。现在只有一处。 - """ - from app.ledger import draftplan - - fields, _ = draftplan.merge_entry_fields(blocks) - return fields - - -def _entry_notes(blocks: list[dict[str, Any]]) -> list[str]: - """入口字段在各组里声明不一致的地方(必填取并集、类型取更宽),摆给起草的人看。""" - from app.ledger import draftplan - - return draftplan.merge_entry_fields(blocks)[1] - - #: 问题去重时取第一句多长:同一件事每轮换个说法,前面那句「问的是什么」通常一样,后面才是补充 _QUESTION_KEY = 30 _CLAUSE = ",,。;;::!!??" _PUNCT = set(" \t\r\n,。;:、!?,.!?;:'\"()()[]【】《》<>-_/\\|`~") -def _question_key(text: str) -> str: - """一句话的「问的是什么」:取第一个分句(问的是同一件事,后面常跟着各轮的补充说明)。""" - head = text - for mark in _CLAUSE: - at = head.find(mark) - if at > 0: - head = head[:at] - return "".join(ch for ch in head if ch not in _PUNCT)[:_QUESTION_KEY] - - -def _merge_questions(kept: list[str], incoming: list[str]) -> list[str]: - """跨轮合并未决问题:同一件事每轮都会重新问一遍(68 条里有一半是重复),按归一化后的前 24 个字去重, - 保留先出现的那一句。措辞不同但意思一样的仍会留下——机器分不出来,交给人决定要不要回。""" - seen = {_question_key(q) for q in kept} - out = list(kept) - for item in incoming: - text = item.strip() if isinstance(item, str) else "" - if not text: - continue - key = _question_key(text) - if key in seen: - continue - seen.add(key) - out.append(text) - return out - - -def _fresh_metrics(metrics: list[dict[str, Any]], covered: list[str]) -> list[dict[str, Any]]: - """这一轮里还没起草过的指标,按名字认;同一轮重复交同名指标的只留第一条。 - - 提示词里已经写了「这些起草过、别重复」,但模型偶尔照抄一遍上一轮的指标。照单全收会出现同名两条指标,口径卡上 - 读的是哪条也说不清,所以在这里挡掉。 - """ - seen = set(covered) - fresh: list[dict[str, Any]] = [] - for metric in metrics: - name = str(metric.get("name") or "").strip() - if name: - if name in seen: - continue - seen.add(name) - fresh.append(metric) - return fresh - - -def _draft_payload(*, entries: list[dict[str, Any]], blocks: list[dict[str, Any]], problems: list[dict[str, Any]], - questions: list[str], uncovered: list[str], progress: dict[str, int], - doc_truncated: bool, catalog_truncated: bool, model_id: str, - salvaged: bool) -> dict[str, Any]: - """草稿 payload 的形状:整份指标、各组 SQL、进度。起草中途也按它写回,页面靠 progress 显示第几轮、覆盖了几条。 - - blocks 是组数(不是内容);sql 把各组的取数拼在一起(组间空一行),人看的是「这次一共要跑哪几条」。truncated 仍是 - doc_truncated 与 catalog_truncated 的「或」,老前端只认这一个键。 - """ - return { - "documents": entries, - "metrics": [m for block in blocks for m in block["metrics"]], - "entry_fields": _merged_fields(blocks), - "entry_notes": _entry_notes(blocks), - "sql": "\n\n".join(str(block["sql"]) for block in blocks), - "version": 1, - "blocks": len(blocks), - "progress": progress, - "trial": {"run_id": "", "status": "running", "error": ""}, - "dropped": problems, - "truncated": doc_truncated or catalog_truncated, - "doc_truncated": doc_truncated, - "catalog_truncated": catalog_truncated, - "model_id": model_id, - "open_questions": questions, - "uncovered": uncovered, - "salvaged": salvaged, - } - - async def _extract_in_background(draft_id: str) -> None: - """后台起草:读文档的段落和数据目录,分组反复调模型直到覆盖完,收成一张多取数的草稿图、建工作流、再跑一次试跑。 + from app.ledger.authoring import extract - 用新的会话:请求那一份在响应发出后就关了,后台接着用它写库是错的。没有数据源或没有表结构时起草必须失败——SQL 只能 - 对照真实目录写,没有目录就不让模型猜。个别指标不合格只丢那一条、写进 dropped / open_questions;SQL 本身不合格才整份 - 失败(图跑不了,留着也没有意义)。 - - **为什么一轮接一轮地跑。** 模型一轮只交一组能共用同一条 SQL 的指标(提示词里限 6 条),39 条指标的文档一轮必然覆盖 - 不完。以前只跑一轮,其余全进 uncovered,草稿就停在 6 条。现在把已起草的名字(covered)交回提示词、按 uncovered 续跑, - 每轮收一组;直到模型不再给出新指标、uncovered 空了、或到轮数/条数上限。每轮都先写一次 payload 的 progress 再 - commit:一轮要等一次模型调用,页面得能看见进展,而不是停在「起草中」。 - """ - from app.core.artifact_store import graph_hash - from app.db.models import WorkflowVersion - from app.ledger import docdraft, draftplan - - async with SessionLocal() as session: - draft = await session.get(CaliberDraft, draft_id) - if draft is None or draft.status != "drafting": - return - origin = draft.origin if isinstance(draft.origin, dict) else {} - wanted = [str(d) for d in origin.get("documents") or []] - docs: list[Document] = [] - for doc_id in wanted: - doc = await session.get(Document, doc_id) - if doc is not None: - docs.append(doc) - chunks: dict[str, dict[str, Any]] = {} - entries: list[dict[str, Any]] = [] - for doc in docs: - rows = (await session.execute(select(Chunk).where(Chunk.document_id == doc.id) - .order_by(Chunk.ordinal, Chunk.id))).scalars() - chunk_ids: list[str] = [] - for chunk in rows: - chunks[chunk.id] = {"ordinal": chunk.ordinal, "content": chunk.content or "", - "document_id": doc.id} - chunk_ids.append(chunk.id) - entries.append({"document_id": doc.id, "title": doc.title or doc.source or doc.id, - "chunk_ids": chunk_ids}) - - source = await session.get(DataSource, draft.datasource_id) if draft.datasource_id else None - if source is None: - draft.error = "没有数据源:生成取数 SQL 必须对照数据目录里的表和列" - draft.status = "failed" - await session.commit() - return - focus_text = "\n".join(str(c.get("content") or "") for c in chunks.values()) - tables_text, allowed_tables = await _schema_material(session, source, focus_text) - if not allowed_tables: - draft.error = "这个数据源还没有表结构:先探查表结构,再起草口径" - draft.status = "failed" - await session.commit() - return - datasource = {"name": source.name, "type": source.kind, "description": source.description} - - guidance = str(origin.get("guidance") or "") - fixed_filters = str(origin.get("fixed_filters") or "") - alignment: dict[str, list[Any]] = {"new": [], "duplicate": [], "conflict": []} - # 每组一组指标:{sql, entry_fields, metrics},循环结束时拼成一张图 - blocks: list[dict[str, Any]] = [] - problems: list[dict[str, Any]] = [] - covered: list[str] = [] - questions: list[str] = [] - uncovered: list[str] = [] - progress: dict[str, int] = {} - diagnostics = "" - doc_truncated = False - catalog_truncated = False - salvaged = False - model_id = "" - - def state() -> dict[str, Any]: - return _draft_payload(entries=entries, blocks=blocks, problems=problems, questions=questions, - uncovered=uncovered, progress=progress, doc_truncated=doc_truncated, - catalog_truncated=catalog_truncated, model_id=model_id, salvaged=salvaged) - - try: - for rnd in range(1, MAX_ROUNDS + 1): - result = await draftplan.plan(session, docs=entries, chunks=chunks, guidance=guidance, - datasource=datasource, tables_text=tables_text, covered=covered, - fixed=fixed_filters) - plan, round_problems = draftplan.normalize_plan(result, chunks, allowed_tables=allowed_tables) - fresh = _fresh_metrics(plan["metrics"], covered) - if plan["sql"] and fresh: - blocks.append({"sql": plan["sql"], "entry_fields": plan["entry_fields"], "metrics": fresh}) - covered += [m["name"] for m in fresh if m.get("name")] - problems += round_problems - questions = _merge_questions(questions, plan["open_questions"]) - uncovered = list(result.get("uncovered") or []) - diagnostics = result.get("diagnostics") or diagnostics - doc_truncated = doc_truncated or bool(result.get("doc_truncated")) - catalog_truncated = catalog_truncated or bool(result.get("catalog_truncated")) - salvaged = salvaged or bool(result.get("salvaged")) - model_id = result.get("model_id") or model_id - progress = {"round": rnd, "covered": len(covered), "uncovered": len(uncovered)} - # 每轮先落一次库:这一轮要等一次模型调用,页面这时就能显示「第几轮、起草了几条、还剩几条」 - draft.payload = state() - await session.commit() - if not fresh or not uncovered or len(covered) >= MAX_METRICS: - break - - if not blocks: - error = _problems_text(problems) or "模型没有给出可用的取数 SQL 和指标" - if diagnostics: - error = f"{error}({diagnostics})" - if questions: - # 模型把缺什么说清楚了(目录里没有这些列、业务上还没定):这是一份要补信息的草稿,不是失败 - draft.payload = {**state(), "trial": {"run_id": "", "status": "", "error": ""}} - draft.model_id = model_id or None - draft.error = "" - draft.status = "questions_open" - await session.commit() - return - draft.payload = {**state(), "trial": {"run_id": "", "status": "failed", "error": error}} - draft.model_id = model_id or None - draft.error = error - draft.status = "failed" - await session.commit() - return - - try: - graph = draftplan.build_graph(blocks, name=draft.title, question=guidance or draft.title, - datasource=source.name) - except ValueError as e: - # build_graph 现在合并入口字段不再抛错;这条留着兜底:真出现合不了的图,照实记为失败,别留半成品 - draft.payload = {**state(), "dropped": [*problems, {"name": "入口字段", "reason": str(e)}], - "trial": {"run_id": "", "status": "failed", "error": str(e)}} - draft.model_id = model_id or None - draft.error = str(e) - draft.status = "failed" - await session.commit() - return - - # 对齐要扫所有已发布口径:某一份读不出来不该让整份草稿失败,照实不做对齐(alignment 三个空列表) - try: - existing = await _published_metrics(session) - except Exception: # noqa: BLE001 - 对齐是辅助信息,不是草稿本身 - logger.exception("读已发布口径失败,草稿 %s 不做对齐", draft_id) - existing = [] - metrics = [m for block in blocks for m in block["metrics"]] - alignment = docdraft.alignment(metrics, existing) - - asked = list(dict.fromkeys(questions + [ - f"{p['name']}:{p['reason']}" for p in problems - if p.get("name") not in ("sql", "metrics")])) - workflow = Workflow( - name=(draft.title.strip() or "文档口径草稿")[:200], - description=(f"从业务文档起草的口径:{len(metrics)} 个指标、{len(blocks)} 组取数,取数 SQL 由模型对照" - f"数据目录生成,后台自动只读试运行;人工审改后按受管级别发布"), - graph=graph, status="draft", tags=["doc_draft"], - origin={"kind": "doc_draft", "draft_id": draft.id, "datasource_id": source.id, - "documents": [doc.id for doc in docs], "guidance": guidance}) - session.add(workflow) - await session.flush() - session.add(WorkflowVersion(workflow_id=workflow.id, version=1, graph=graph, - graph_hash=graph_hash(graph), note="从业务文档分组生成")) - payload = { - **state(), "workflow_id": workflow.id, "open_questions": asked, - "alignment": alignment, - } - except docdraft.DraftUnavailable as e: - draft.error = str(e) or "AI 起草不可用" - draft.status = "failed" - await session.commit() - return - except Exception as e: # noqa: BLE001 - 后台出错要写回这一行,不然界面永远停在「起草中」 - logger.exception("从文档起草口径出错(草稿 %s)", draft_id) - draft.error = f"起草出错:{e}" - draft.status = "failed" - await session.commit() - return - draft.payload = payload - draft.workflow_id = workflow.id - draft.model_id = model_id or None - draft.error = None - draft.status = _status_for(payload) - await session.commit() - values = {field["name"]: field.get("default") for field in _merged_fields(blocks) - if field.get("default") not in (None, "")} - - try: - trial = await _run_trial(draft_id=draft_id, workflow_id=workflow.id, graph=graph, values=values, - started_by=draft.created_by, entry_fields=_merged_fields(blocks)) - except Exception as e: # noqa: BLE001 - 试运行出错也要写进 payload,不能让界面停在「试运行中」 - logger.exception("草稿 %s 的试运行出错", draft_id) - trial = {"run_id": "", "status": "failed", "error": f"试运行出错:{e}"} - await _save_trial(draft_id, trial) + await extract(draft_id, run_trial=_run_trial, save_trial=_save_trial, trial_binding=_trial_binding) -def _problems_text(problems: list[dict[str, Any]]) -> str: - """把校验问题拼成一句给人看的原因(接口的 error 只有一个字段)。""" - return ";".join(f"{p.get('name') or '问题'}:{p.get('reason') or ''}".strip(":") - for p in problems if p.get("reason"))[:2000] +from app.ledger import jobs as draft_jobs + +draft_jobs.executor = _extract_in_background class DraftExtractIn(BaseModel): @@ -1000,27 +953,48 @@ async def draft_extract(payload: DraftExtractIn, background: BackgroundTasks, "fixed_filters": payload.fixed_filters.strip()}, created_by=current_principal().username) session.add(draft) + await session.flush() + from app.db.models import CaliberDraftJob + + session.add(CaliberDraftJob(id=draft.id)) await session.commit() - background.add_task(_extract_in_background, draft.id) + background.add_task(draft_jobs.start, draft.id) return {"draft_id": draft.id, "status": draft.status} @router.get("/drafts") async def list_drafts(status: str | None = Query(default=None, description="按状态筛:drafting / ready / " "questions_open / path_pending / attached / rejected / failed"), + stage: str | None = Query(default=None, description="按界面阶段筛:drafting / review / " + "discarded / failed"), session: AsyncSession = Depends(get_session)) -> dict[str, Any]: - """口径草稿列表,最近改过的在前。只给计数和状态,正文用 GET /api/ledger/drafts/{id} 看。""" + """口径草稿列表,最近改过的在前。只给计数、细状态和界面阶段,正文用 GET /api/ledger/drafts/{id} 看。 + + 细状态(status)是接口兼容用的;页面按四种阶段(stage)筛:起草中、待审、已丢弃、失败。ready / questions_open / + path_pending / attached 都归「待审」。 + """ from app.api.deps import ensure_role from app.ledger import docdraft ensure_role("builder") - query = select(CaliberDraft).order_by(CaliberDraft.updated_at.desc(), CaliberDraft.id.desc()) + query = select(CaliberDraft, Workflow).outerjoin(Workflow, Workflow.id == CaliberDraft.workflow_id) \ + .order_by(CaliberDraft.updated_at.desc(), CaliberDraft.id.desc()) if status: if status not in docdraft.STATUSES: raise HTTPException(422, f"不认识的状态:{status}") query = query.where(CaliberDraft.status == status) - rows = (await session.execute(query)).scalars() - return {"drafts": [_draft_summary(draft) for draft in rows]} + if stage: + if stage not in docdraft.STAGES: + raise HTTPException(422, f"不认识的阶段:{stage}") + if stage == "review": + query = query.where(CaliberDraft.status.in_(("ready", "questions_open", "path_pending", "attached"))) + elif stage == "discarded": + query = query.where(CaliberDraft.status == "rejected") + else: + query = query.where(CaliberDraft.status == stage) + rows = (await session.execute(query)).all() + return {"drafts": [_draft_summary(draft, graph=workflow.graph if _doc_draft_workflow(workflow) else None) + for draft, workflow in rows]} @router.get("/drafts/{draft_id}") @@ -1035,29 +1009,35 @@ async def get_draft(draft_id: str, session: AsyncSession = Depends(get_session)) @router.get("/drafts/{draft_id}/trial") async def get_draft_trial(draft_id: str, session: AsyncSession = Depends(get_session)) -> dict[str, Any]: - """这份草稿的试跑:{run_id, status, metrics: [{id, name, rendered, value}], error}。 + """这份草稿的试跑:{run_id, status, metrics: [{id, name, rendered, value}], queries, error}。 - 状态只有 succeeded / failed / running 三种;指标值读试跑运行封存的口径卡(和口径试跑同一个读法)。还没试跑(老草稿、 - 起草失败)回 404。只读,不发起运行。""" + 状态只有 succeeded / failed / running 三种;指标值读试跑运行封存的口径卡(和口径试跑同一个读法),queries 是取数节点 + 交回的查询快照(前几行、遮罩列换成「已遮罩」,不含 SQL)。还没试跑(老草稿、起草失败)回 404。只读,不发起运行。""" from app.api.deps import ensure_role ensure_role("builder") draft = await _draft_or_404(session, draft_id) payload = draft.payload if isinstance(draft.payload, dict) else {} trial = payload.get("trial") if isinstance(payload.get("trial"), dict) else {} + workflow = await session.get(Workflow, draft.workflow_id) if draft.workflow_id else None + if _doc_draft_workflow(workflow): + trial = await _trial_view(session, trial, version=workflow.version, graph=workflow.graph) + if trial.get("status") == "stale": + return {**trial, "metrics": [], "queries": []} run_id = trial.get("run_id") if not isinstance(run_id, str) or not run_id: - if trial.get("status") == "needs_input": - return {"run_id": "", "status": "needs_input", "metrics": [], - "error": str(trial.get("error") or "")} + if trial.get("status") in ("needs_input", "running"): + return {**trial, "metrics": [], "queries": []} raise HTTPException(404, "这份草稿还没有试运行") run = await session.get(Run, run_id) if run is None: - return {"run_id": run_id, "status": _run_status(trial.get("status")), "metrics": [], + return {"run_id": run_id, "status": _run_status(trial.get("status")), "metrics": [], "queries": [], "error": str(trial.get("error") or "")} status = _run_status(run.status) metrics = await _trial_metrics(run.id) if status == "succeeded" else [] - return {"run_id": run.id, "status": status, "metrics": metrics, "error": str(run.error or "")} + queries = await _trial_queries(run.id) if status == "succeeded" else [] + return {**trial, "run_id": run.id, "status": status, "metrics": metrics, "queries": queries, + "validation": await _trial_validation(run.id, status, metrics), "error": str(run.error or "")} class DraftTrialIn(BaseModel): @@ -1070,7 +1050,8 @@ async def run_draft_trial(draft_id: str, payload: DraftTrialIn, """按填好的入口字段试跑一次(builder 及以上)。 自动试跑缺必填输入时停在 needs_input(不拿系统编的日期跑,那样跑出来的数没有意义),人在这里补上再跑。 - 参数按入口字段校验,和正式运行同一套(apps/forms);跑的仍是探针图:只取数与口径卡,不写报告、不进统计。 + 参数按入口字段校验,和正式运行同一套(apps/forms);跑的仍是探针图:只取数与口径卡,不写报告、不进统计。成功时另给 + queries(取数节点交回的查询快照,前几行、遮罩列换成「已遮罩」,不含 SQL);没有运行、失败或还在跑时为空数组。 """ from app.api.deps import current_principal, ensure_role from app.apps.forms import input_schema, validate_form @@ -1082,21 +1063,31 @@ async def run_draft_trial(draft_id: str, payload: DraftTrialIn, workflow_id = draft.workflow_id or body.get("workflow_id") if not workflow_id: raise HTTPException(409, "这份草稿还没有取数图,不能试运行") - version = int(body.get("version") or 1) + workflow = await session.get(Workflow, str(workflow_id)) + version = workflow.version if _doc_draft_workflow(workflow) else int(body.get("version") or 1) row = (await session.execute(select(WorkflowVersion).where( WorkflowVersion.workflow_id == workflow_id, WorkflowVersion.version == version))).scalar_one_or_none() if row is None or not isinstance(row.graph, dict): raise HTTPException(409, "找不到这份草稿的取数图,可能已被删除") + # 文档起草的草稿以图为准:画布上改过入口字段之后,试跑按图里的字段校验,不能拿 payload 的旧快照 + if _doc_draft_workflow(workflow): + fields = _graph_entry_fields(row.graph) + unknown = sorted(set(payload.inputs) - {str(field.get("name")) for field in fields}) + if unknown: + raise HTTPException(422, f"不认识的入口字段:{'、'.join(unknown)}") values: dict[str, Any] = {str(field.get("name")): field.get("default") for field in fields if field.get("default") not in (None, "")} values.update({str(key): value for key, value in payload.inputs.items()}) if errors := validate_form(input_schema(fields), values): raise HTTPException(422, ";".join(str(e.get("message")) for e in errors[:5])) trial = await _run_trial(draft_id=draft.id, workflow_id=str(workflow_id), graph=row.graph, - values=values, started_by=current_principal().username, entry_fields=fields) + values=values, started_by=current_principal().username, entry_fields=fields, version=version) await _save_trial(draft.id, trial) run_id = str(trial.get("run_id") or "") - return {**trial, "metrics": await _trial_metrics(run_id) if run_id else []} + queries = await _trial_queries(run_id) if run_id and trial.get("status") == "succeeded" else [] + metrics = await _trial_metrics(run_id) if run_id and trial.get("status") == "succeeded" else [] + return {**trial, "metrics": metrics, "queries": queries, + "validation": await _trial_validation(run_id, str(trial.get("status") or ""), metrics)} def _check_metrics(metrics: Any) -> list[dict[str, Any]]: @@ -1118,6 +1109,48 @@ def _check_metrics(metrics: Any) -> list[dict[str, Any]]: return [dict(metric) for metric in metrics] +async def _sync_draft_graph(session: AsyncSession, draft: CaliberDraft, payload_data: dict[str, Any]) -> None: + """在当前图上修改口径卡,再新增版本;取数节点、SQL、入口和用户添加的编排保持其身份。""" + from app.apps.versions import WorkflowChanged, save_graph + + workflow = await session.get(Workflow, draft.workflow_id) if draft.workflow_id else None + metrics = payload_data.get("metrics") + if not _doc_draft_workflow(workflow) or not isinstance(metrics, list): + return + graph = copy.deepcopy(workflow.graph) + card = next((n for n in graph.get("nodes") or [] if n.get("type") == "metrics" + and not (n.get("data", {}).get("config") or {}).get("caliber_from")), None) + if card is None: + raise HTTPException(409, "当前画布没有可编辑的口径卡,请先在画布上添加") + config = card.setdefault("data", {}).setdefault("config", {}) + old_ids = {m.get("id") for m in config.get("metrics") or [] if isinstance(m, dict)} + new_ids = [m.get("id") for m in metrics] + if any(not isinstance(mid, str) or not mid.strip() for mid in new_ids) or len(set(new_ids)) != len(new_ids): + raise ValueError("每条指标需要非空且唯一的 ID") + config["metrics"] = copy.deepcopy(metrics) + removed = old_ids - set(new_ids) + for node in graph.get("nodes") or []: + contract = node.get("data", {}).get("config", {}).get("contract") + if not isinstance(contract, dict): + continue + sources = contract.get("metrics_from") or [] + if card["id"] not in ([sources] if isinstance(sources, str) else sources): + continue + for key in ("required", "expected"): + if isinstance(contract.get(key), list): + before = contract[key] + contract[key] = list(new_ids) if key == "required" and set(before) == old_ids \ + else [mid for mid in before if mid not in removed] + try: + await save_graph(session, workflow, graph, note="编辑口径草稿") + except WorkflowChanged as e: + raise HTTPException(409, str(e)) from None + await session.flush() + live = await _draft_graph_payload(session, workflow, workflow.version) + if live is not None: + payload_data.update(live) + + class DraftPatchIn(BaseModel): """改标题、整体换掉指标、或把已经答清的问题消掉。""" @@ -1133,11 +1166,22 @@ async def patch_draft(draft_id: str, payload: DraftPatchIn, session: AsyncSessio 据此重算状态(还有问题 → questions_open,否则有图 → ready、没有图的老草稿 → path_pending)。已经 attach 或驳回的 草稿不改状态——那时它说的已经不是一份草稿了。""" from app.api.deps import ensure_role + from sqlalchemy import update ensure_role("builder") + # 和重试对同一草稿串行,SQLite 也通过写锁保护;后台还在生成时不接受会被它覆盖的编辑。 + await session.execute(update(CaliberDraft).where(CaliberDraft.id == draft_id) + .values(updated_at=CaliberDraft.updated_at).execution_options(synchronize_session=False)) draft = await _draft_or_404(session, draft_id) + if draft.status == "drafting": + raise HTTPException(409, "草稿仍在起草,请等待完成或停止起草后再编辑") body = draft.payload if isinstance(draft.payload, dict) else {} - payload_data = dict(body) + payload_data = copy.deepcopy(body) + workflow = await session.get(Workflow, draft.workflow_id) if draft.workflow_id else None + if _doc_draft_workflow(workflow): + live = await _draft_graph_payload(session, workflow, workflow.version) + if live is not None: + payload_data.update(copy.deepcopy(live)) if payload.title is not None: if not payload.title.strip(): raise HTTPException(422, "标题不能为空") @@ -1156,6 +1200,11 @@ async def patch_draft(draft_id: str, payload: DraftPatchIn, session: AsyncSessio metric["open_questions"] = [q for q in metric["open_questions"] if not (isinstance(q, str) and q.strip() in drop)] if payload.metrics is not None or payload.resolved is not None: + # 换指标、清问题都要落到图上:草稿页从图读,只改 payload 会被下一次读取覆盖回去 + try: + await _sync_draft_graph(session, draft, payload_data) + except ValueError as e: + raise HTTPException(422, f"改完的指标建不成图:{e}") from e draft.payload = payload_data metrics = payload_data.get("metrics") # 起草失败的草稿补上真指标后要能往前走;没有指标时不重算,免得空草稿被当成「待接数据路径」 @@ -1166,6 +1215,34 @@ async def patch_draft(draft_id: str, payload: DraftPatchIn, session: AsyncSessio return await _draft_view(session, draft) +@router.post("/drafts/{draft_id}/retry") +async def retry_draft(draft_id: str, background: BackgroundTasks, + session: AsyncSession = Depends(get_session)) -> dict[str, Any]: + from app.api.deps import ensure_role + + ensure_role("builder") + draft = await _draft_or_404(session, draft_id) + try: + await draft_jobs.retry(session, draft) + except ValueError as exc: + raise HTTPException(409, str(exc)) from None + background.add_task(draft_jobs.start, draft.id) + return {"draft_id": draft.id, "status": "drafting"} + + +@router.post("/drafts/{draft_id}/stop") +async def stop_draft(draft_id: str, session: AsyncSession = Depends(get_session)) -> dict[str, Any]: + from app.api.deps import ensure_role + + ensure_role("builder") + draft = await _draft_or_404(session, draft_id) + await session.commit() + if not await draft_jobs.manager.stop(draft.id, "user"): + await draft_jobs.finish(session, draft.id, "cancelled", "起草已停止,已保留进度") + await session.refresh(draft) + return await _draft_view(session, draft) + + class DraftDismissIn(BaseModel): note: str | None = Field(default=None, max_length=500) @@ -1179,11 +1256,16 @@ async def dismiss_draft(draft_id: str, payload: DraftDismissIn, ensure_role("builder") draft = await _draft_or_404(session, draft_id) + await session.commit() + await draft_jobs.manager.stop(draft.id, "user") + await session.refresh(draft) draft.status = "rejected" await record(session, DRAFT_DISMISS_ACTION, DRAFT_SUBJECT_KIND, draft.id, data={k: v for k, v in {"title": draft.title, "note": payload.note}.items() if v}) await session.commit() - return {"ok": True, "draft_id": draft.id, "status": draft.status} + from app.ledger import docdraft + + return {"ok": True, "draft_id": draft.id, "status": draft.status, "stage": docdraft.stage_of(draft.status)} class DraftAttachIn(BaseModel): diff --git a/backend/app/api/workflows.py b/backend/app/api/workflows.py index cc340f01..764d5836 100644 --- a/backend/app/api/workflows.py +++ b/backend/app/api/workflows.py @@ -100,14 +100,29 @@ async def _published_levels(session: AsyncSession, ids: list[str] | None = None) async def _same_as_published(session: AsyncSession, ids: list[str] | None = None) -> set[str]: - """画布这一版(Workflow.version)的图哈希和当前发布版相同的工作流 id。两边哈希都记着才比,不在前端比图。""" + """当前图与发布版执行内容相同;仅移动节点不算业务逻辑变化。旧封存哈希不改写。""" + from app.core.artifact_store import execution_hash + current, published = aliased(WorkflowVersion), aliased(WorkflowVersion) stmt = select(Workflow.id, current.graph_hash, published.graph_hash).join( current, (current.workflow_id == Workflow.id) & (current.version == Workflow.version)).join( published, (published.workflow_id == Workflow.id) & (published.version == Workflow.published_version)) if ids is not None: stmt = stmt.where(Workflow.id.in_(ids)) - return {wid for wid, mine, theirs in (await session.execute(stmt)).all() if mine and mine == theirs} + rows = (await session.execute(stmt)).all() + same = {wid for wid, mine, theirs in rows if mine and mine == theirs} + changed = [wid for wid, _, _ in rows if wid not in same] + if not changed: + return same + # 常见的未改动版本直接用已存哈希;只有哈希不同的候选才读取图,避免列表每次反序列化全部发布图。 + graphs = stmt.with_only_columns(Workflow.id, current.graph, published.graph).where(Workflow.id.in_(changed)) + for wid, mine, theirs in (await session.execute(graphs)).all(): + try: + if mine and theirs and execution_hash(mine) == execution_hash(theirs): + same.add(wid) + except (TypeError, ValueError): + continue # 不能解析的历史图不宣称执行一致 + return same async def _run_counts(session: AsyncSession, ids: list[str] | None = None) -> dict[str, int]: @@ -244,24 +259,13 @@ async def update_workflow( workflow.description = payload.description if payload.tags is not None: workflow.tags = payload.tags - if payload.graph is not None and payload.graph != workflow.graph: - # 图有实际变化才留快照,避免改个名字就堆一堆版本 - workflow.graph = payload.graph - workflow.version += 1 - session.add( - WorkflowVersion( - workflow_id=workflow.id, - version=workflow.version, - graph=payload.graph, - graph_hash=graph_hash(payload.graph), - note=payload.note, - ) - ) - # status 说的是**当前画布**的状态,不是"这个工作流曾经发布过"。 - # 改完图还挂着 governed,界面上就会显示一张从未过闸的图是受管模板—— - # 已发布的那一版仍由 published_version 指着,formal 运行不受影响。 - if workflow.status in ("published", "governed"): - workflow.status = "draft" + if payload.graph is not None: + from app.apps.versions import WorkflowChanged, save_graph + + try: + await save_graph(session, workflow, payload.graph, note=payload.note) + except WorkflowChanged as e: + raise HTTPException(409, str(e)) from None await session.commit() await session.refresh(workflow) return await _workflow_out(session, workflow) @@ -560,19 +564,8 @@ def _unparsed(graph: Any) -> str: return "无法解析工作流结构" -async def gate_issues(session: AsyncSession, graph: Any, level: str) -> list[dict[str, Any]] | None: - """发布这张图会遇到的问题(validate + 门禁),只读:不改库、不发布。图不成形时为 None。 - 发布接口和发布单的预检(blueprints/publish_plan.py)共用,口径只有这一份。""" - try: - spec = GraphSpec.model_validate(graph) - except Exception: # noqa: BLE001 - return None - checkers = await _sql_checkers(session, spec) - providers = await _providers(session, spec) - with with_subgraphs(await load_subgraphs(session, spec)): - issues = [i.model_dump() for i in publish_issues(spec, level=level, checkers=checkers, providers=providers)] - # 这一版直接引用的表上还没处理的结构变化(next-round 03 B1):只是警告,不拦发布 - return issues + await open_structure_issues(session, spec) +# 保留导出供旧调用方使用,发布预检的实现位于应用服务。 +from app.apps.validation import gate_issues async def commit_publish(session: AsyncSession, workflow: Workflow, snapshot: WorkflowVersion, level: str, diff --git a/backend/app/apps/impact.py b/backend/app/apps/impact.py new file mode 100644 index 00000000..c60bf693 --- /dev/null +++ b/backend/app/apps/impact.py @@ -0,0 +1,53 @@ +"""固定版本依赖的反向索引:一次读取已发布快照,沿引用关系计算传递影响。""" +from __future__ import annotations + +from collections import defaultdict, deque +from typing import Any + +from sqlalchemy import select + +from app.db.models import Workflow, WorkflowVersion +from app.engine.schema import GraphSpec + + +async def dependents(session: Any, workflow_id: str, version: int) -> dict[str, Any]: + from app.apps.versions import is_published, pinned_upstream, pinning_apps + + rows = (await session.execute(select(WorkflowVersion, Workflow.name, Workflow.published_version) + .join(Workflow, Workflow.id == WorkflowVersion.workflow_id))).all() + current = {row.workflow_id: published for row, _, published in rows} + reverse: dict[tuple[str, int], list[dict[str, Any]]] = defaultdict(list) + unreadable = [] + for row, name, published in rows: + if not is_published(row, current=published): + continue + try: + spec = GraphSpec.model_validate(row.graph) + except ValueError: + unreadable.append({"workflow_id": row.workflow_id, "version": row.version, "name": name}) + continue + for node in spec.nodes: + ref = pinned_upstream(node) + if ref is None and str(node.type) == "subgraph" and node.config.get("workflow_id"): + wid = str(node.config["workflow_id"]) + if current.get(wid): + ref = ("subgraph", wid, current[wid]) + if ref: + kind, wid, pinned = ref + reverse[(wid, pinned)].append({"workflow_id": row.workflow_id, "version": row.version, + "name": name, "node_id": node.id, "kind": kind}) + root = (workflow_id, version) + queue, visited = deque([(root, 0)]), {root} + affected, indirect_apps = [], [] + while queue: + key, depth = queue.popleft() + for ref in reverse.get(key, []): + upstream = (ref["workflow_id"], ref["version"]) + affected.append({**ref, "depth": depth + 1, "via": {"workflow_id": key[0], "version": key[1]}}) + if upstream in visited: + continue + visited.add(upstream) + queue.append((upstream, depth + 1)) + indirect_apps.extend({**app, "via": {"workflow_id": upstream[0], "version": upstream[1]}} + for app in await pinning_apps(session, *upstream)) + return {"workflows": affected, "indirect_apps": indirect_apps, "unreadable_workflows": unreadable} diff --git a/backend/app/apps/validation.py b/backend/app/apps/validation.py new file mode 100644 index 00000000..867d200b --- /dev/null +++ b/backend/app/apps/validation.py @@ -0,0 +1,24 @@ +"""发布预检的应用服务;编辑页、发布接口和发布单使用同一组检查。""" +from typing import Any + +from sqlalchemy.ext.asyncio import AsyncSession + +from app.engine.schema import GraphSpec + + +async def gate_issues(session: AsyncSession, graph: Any, level: str) -> list[dict[str, Any]] | None: + from app.data.catalog_impact import open_structure_issues + from app.data.sqlcheck import graph_sources, load_checkers_by_name + from app.engine.governance import publish_issues + from app.engine.lint_process import load_subgraphs, with_subgraphs + from app.engine.provider_refs import load_providers + + try: + spec = GraphSpec.model_validate(graph) + except Exception: + return None + checkers = await load_checkers_by_name(session, graph_sources(spec.nodes)) + providers = await load_providers(session, spec) + with with_subgraphs(await load_subgraphs(session, spec)): + issues = [i.model_dump() for i in publish_issues(spec, level=level, checkers=checkers, providers=providers)] + return issues + await open_structure_issues(session, spec) diff --git a/backend/app/apps/versions.py b/backend/app/apps/versions.py index 7742366d..53edb038 100644 --- a/backend/app/apps/versions.py +++ b/backend/app/apps/versions.py @@ -8,8 +8,9 @@ from __future__ import annotations from dataclasses import dataclass from typing import Any -from sqlalchemy import and_, or_, select +from sqlalchemy import and_, or_, select, update from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy.orm.attributes import set_committed_value from app.db.models import Workflow, WorkflowVersion from app.db.models_apps import App, AppVersion @@ -21,6 +22,30 @@ RETIRE_UNPUBLISHED = "version_retire_unpublished" RETIRE_ALREADY = "version_retire_already" +class WorkflowChanged(ValueError): + """保存期间另一处已经提交了新版本,需要重新读取后再编辑。""" + + +async def save_graph(session: AsyncSession, workflow: Workflow, graph: dict[str, Any], *, + note: str | None = None) -> bool: + """画布与口径草稿共用的保存:只新增快照,不改任何旧版本;由调用方提交事务。""" + from app.core.artifact_store import graph_hash + + if graph == workflow.graph: + return False + previous = workflow.version + result = await session.execute(update(Workflow).where( + Workflow.id == workflow.id, Workflow.version == previous, + ).values(graph=graph, version=previous + 1, status="draft").execution_options(synchronize_session=False)) + if result.rowcount != 1: + raise WorkflowChanged("工作流已有新的修改,请刷新后再保存") + for key, value in (("graph", graph), ("version", previous + 1), ("status", "draft")): + set_committed_value(workflow, key, value) + session.add(WorkflowVersion(workflow_id=workflow.id, version=workflow.version, graph=graph, + graph_hash=graph_hash(graph), note=note or "")) + return True + + class RetireRefused(Exception): """不退役。status_code / message / code 就是 HTTP 回的状态码、detail、机器码。""" @@ -228,9 +253,12 @@ async def formal_run_count(session: AsyncSession, workflow_id: str, version: int async def usage(session: AsyncSession, workflow_id: str, version: int) -> dict[str, Any]: """{apps, effects, formal_runs}:版本详情、账本接口、退役确认给的同一份。""" + from app.apps.impact import dependents + return {"apps": await pinning_apps(session, workflow_id, version), "effects": await effect_count(session, workflow_id, version), - "formal_runs": await formal_run_count(session, workflow_id, version)} + "formal_runs": await formal_run_count(session, workflow_id, version), + **await dependents(session, workflow_id, version)} # -------------------------------------------------------------------------- @@ -260,15 +288,18 @@ async def retire_version(session: AsyncSession, workflow_id: str, version: int, RETIRE_CURRENT) if not is_published(row, current=workflow.published_version): raise RetireRefused(409, f"v{version} 没有发布过,不需要退役", RETIRE_UNPUBLISHED) + from app.apps.impact import dependents + affected = await pinning_apps(session, workflow_id, version) + impact = await dependents(session, workflow_id, version) row.retired_at = utcnow() row.retired_by = actor row.retire_note = (note or "").strip() or None await record(session, action="workflow_version.retire", subject_kind="workflow_version", subject_id=f"{workflow_id}:{version}", actor=actor, data={"workflow_id": workflow_id, "workflow_name": workflow.name, "version": version, - "note": row.retire_note, "affected_apps": affected}) + "note": row.retire_note, "affected_apps": affected, "impact": impact}) await session.commit() return {"retired": {"workflow_id": workflow_id, "version": version, "retired_at": row.retired_at, "retired_by": actor, "retire_note": row.retire_note}, - "affected_apps": affected} + "affected_apps": affected, "impact": impact} diff --git a/backend/app/core/artifact_store.py b/backend/app/core/artifact_store.py index 3837f934..bf5ed29e 100644 --- a/backend/app/core/artifact_store.py +++ b/backend/app/core/artifact_store.py @@ -38,6 +38,26 @@ def graph_hash(graph: dict[str, Any]) -> str: return content_hash(canonical_json(slim)) +def execution_content(graph: dict[str, Any]) -> dict[str, Any]: + """执行定义的规范形式。历史 graph_hash 保持原算法,以便复验已封存证据。""" + from app.engine.schema import GraphSpec + + spec = GraphSpec.model_validate(graph) + return { + "schema_version": 1, + "defaults": spec.defaults, + "nodes": sorted([{"id": n.id, "type": n.type.value, "data": n.data.model_dump()} + for n in spec.nodes], key=lambda n: n["id"]), + "edges": sorted([{"source": e.source, "target": e.target, "sourceHandle": e.sourceHandle, + "targetHandle": e.targetHandle} for e in spec.edges], key=canonical_json), + } + + +def execution_hash(graph: dict[str, Any]) -> str: + """布局、选中态和边的显示标签不改变执行身份。""" + return content_hash(canonical_json(execution_content(graph))) + + def _path_of(artifact_id: str) -> Path: # 按前两位分桶,避免单目录塞几万个文件 return settings.data_dir / "artifacts" / artifact_id[:2] / f"{artifact_id}.json" diff --git a/backend/app/core/job_runtime.py b/backend/app/core/job_runtime.py index 15775ee7..e45e5b01 100644 --- a/backend/app/core/job_runtime.py +++ b/backend/app/core/job_runtime.py @@ -648,6 +648,8 @@ class JobManager: def start(self, job_id: str) -> None: """开始在后台执行这个(排队中的)任务:先进这个源的队,轮到了才开始。""" + if self._alive(job_id) is not None: + return run = self._factory(job_id) run.task = asyncio.get_running_loop().create_task(run.run(), name=f"{self.name}-{job_id}") self._runs[job_id] = run diff --git a/backend/app/data/caliber_identity.py b/backend/app/data/caliber_identity.py index 2f12915d..c1dd8009 100644 --- a/backend/app/data/caliber_identity.py +++ b/backend/app/data/caliber_identity.py @@ -5,10 +5,11 @@ - definition_hash:一张口径卡的指标定义(caliber、caliber_version 和每个指标的语义字段)的 sha256; - version_hash:一份口径版本里各卡的内容哈希排序后再取 sha256,也就是整份版本的定义指纹; +- semantic_hash:口径卡及上游执行依赖的指纹,含 SQL、数据源与绑定,排除布局; - card_hash_of:从 {node_id, metrics, caliber?, caliber_version?} 这种卡形状取哈希(运行时指标卡、账本里源头口径卡的 两种写法共用)。 -**只取语义字段。** 指标只留 id / name / expression / unit / decimals / format / kind / definition / params 里存在的键, +**定义指纹只取声明字段。** 指标只留 SEMANTIC_FIELDS 中存在的键(含类型配置、粒度、过滤和可执行约束), 按 id 排序(顺序不影响哈希);空字符串、None、空的列表和字典不写进规范 JSON。值、渲染后的字、输入出处、状态这些都是 运行时产物,不进哈希——同一份定义重跑一次,或者补了无关的字段,哈希不变。 @@ -26,7 +27,8 @@ from typing import Any #: 指标的语义字段:只有这几样算定义,运行时值(value、rendered、inputs……)不参与哈希 SEMANTIC_FIELDS = ("id", "name", "expression", "unit", "decimals", "format", "kind", "definition", "params", "external", - "rule", "assessment") + "rule", "assessment", "grain", "time_field", "numerator", "denominator", "dimensions", "filters", + "contract") def _blank(value: Any) -> bool: @@ -72,4 +74,38 @@ def version_hash(card_hashes: Iterable[str]) -> str: return content_hash("\n".join(sorted(str(h) for h in card_hashes or () if h))) +def semantic_hash(graph: dict[str, Any], node_id: str) -> str: + """口径及其上游执行依赖的指纹:包含 SQL、数据源、入口绑定和固定版本引用。 + + definition_hash 继续表示指标定义自身;此指纹才覆盖取数语义。保守保留 SQL 原文, + 不把未证明等价的改写判为相同;画布坐标和无关的下游写作节点不参与。 + """ + import re + + from app.core.artifact_store import canonical_json, content_hash, execution_content + + content = execution_content(graph) + nodes = {n["id"]: n for n in content["nodes"]} + wanted, pending = set(), [node_id] + while pending: + current = pending.pop() + if current in wanted: + continue + wanted.add(current) + pending.extend(e["source"] for e in content["edges"] if e["target"] == current) + node = nodes.get(current) + if node: + # 非相邻节点也能被表达式引用。保守扫描配置,包含节点参数中的模板引用。 + config = canonical_json(node["data"].get("config") or {}) + pending.extend(re.findall(r"\bnodes\.([\w-]+)", config)) + pending.extend(re.findall(r"\bnodes\[['\"]([\w-]+)['\"]\]", config.replace('\\"', '"'))) + inputs = {n["id"] for n in nodes.values() if n["type"] == "input"} + wanted |= inputs + return content_hash(canonical_json({ + "schema_version": 1, "defaults": content["defaults"], + "nodes": [n for n in content["nodes"] if n["id"] in wanted], + "edges": [e for e in content["edges"] if e["source"] in wanted and e["target"] in wanted], + })) + + __all__ = ["SEMANTIC_FIELDS", "card_hash_of", "definition_hash", "version_hash"] diff --git a/backend/app/data/provenance_db.py b/backend/app/data/provenance_db.py index 1540f070..9914a7f6 100644 --- a/backend/app/data/provenance_db.py +++ b/backend/app/data/provenance_db.py @@ -93,8 +93,8 @@ def view_of(snapshot: SourceSnapshot, *, source_name: str) -> SourceView: async def compile_facts(view: SourceView, sql: str) -> CompileFacts: - """在查询读过的同一个文件上编译原 SQL(EXPLAIN,不执行),给出与解析无关的三项事实(R15): - 授权回调报出读了哪些表、字节码里 ResultRow 和 Yield 各有几个。 + """在查询读过的同一个文件上编译原 SQL(EXPLAIN,不执行),给出与解析无关的事实(R15): + 授权回调报出读了哪些表、编译了几个 SELECT,字节码里 ResultRow 和 Yield 各有几个。 步骤:经 EngineCache 取引擎(首次时整份核对哈希)→ **同步**取 engines.pinned(view)(中间没有 await,不会被 LRU 挤掉)→ 在线程里:比指纹 → 单开 immutable 只读连接(和 EngineCache 打开快照的方式相同)→ 装授权回调、EXPLAIN @@ -115,12 +115,16 @@ def _compile(path: str, pinned: engine_mod.Fingerprint, sql: str) -> CompileFact if _fingerprint(path) != pinned: raise SnapshotTampered(_REPLACED) reads: set[str] = set() + selects = 0 def observe(action: int, arg1: str | None, _arg2: str | None, _db: str | None, _trigger: str | None) -> int: + nonlocal selects # SQLITE_READ 的 arg1 是表名(建表时的写法)。子查询、自连接读到的表都会报出来,改名、CTE 报的和合法写法 # 一样(P4-SPEC 2.4 末尾),所以这只是兜底,主判据仍是识别器 if action == sqlite3.SQLITE_READ and arg1: reads.add(arg1) + if action == sqlite3.SQLITE_SELECT: + selects += 1 return sqlite3.SQLITE_OK conn = open_checked_sqlite(path, readonly=True, immutable=True) @@ -131,7 +135,7 @@ def _compile(path: str, pinned: engine_mod.Fingerprint, sql: str) -> CompileFact conn.close() if _fingerprint(path) != pinned: raise SnapshotTampered(_REPLACED) - return CompileFacts(tables=reads, result_rows=opcodes.count("ResultRow"), yields=opcodes.count("Yield")) + return CompileFacts(tables=reads, result_rows=opcodes.count("ResultRow"), yields=opcodes.count("Yield"), selects=selects) # -------------------------------------------------------------------------- diff --git a/backend/app/data/provenance_types.py b/backend/app/data/provenance_types.py index b76802df..2ed6dbfc 100644 --- a/backend/app/data/provenance_types.py +++ b/backend/app/data/provenance_types.py @@ -659,11 +659,12 @@ class RelatedCheckPlan: @dataclass class CompileFacts: - """R15 的第二道核对(EXPLAIN 编译、不执行):授权回调读到的表、字节码里 ResultRow 和 Yield 的个数。""" + """R15 的第二道核对:授权回调读到的表、SELECT 次数和字节码的输出指令数。""" tables: set[str] result_rows: int yields: int + selects: int = 1 # ========================================================================== diff --git a/backend/app/db/models.py b/backend/app/db/models.py index 0681990d..a2d9793f 100644 --- a/backend/app/db/models.py +++ b/backend/app/db/models.py @@ -1093,6 +1093,20 @@ class WebhookDelivery(Base): # -------------------------------------------------------------------------- +class CaliberDraftJob(Base, TimestampMixin): + """口径起草的持久化任务,和草稿业务状态分开。一次草稿同一时间只执行一个任务。""" + + __tablename__ = "caliber_draft_jobs" + + id: Mapped[str] = mapped_column(ForeignKey("caliber_drafts.id", ondelete="CASCADE"), primary_key=True) + status: Mapped[str] = mapped_column(String(20), default="queued", index=True) + owner: Mapped[str | None] = mapped_column(String(120), default=current_owner) + attempt: Mapped[int] = mapped_column(Integer, default=1) + error: Mapped[str | None] = mapped_column(Text, default=None) + started_at: Mapped[datetime | None] = mapped_column(UTCDateTime(), default=None) + finished_at: Mapped[datetime | None] = mapped_column(UTCDateTime(), default=None) + + class CaliberDraft(Base, TimestampMixin): """从业务文档起草的一份口径(app/ledger/draftplan.py 的产出)。 diff --git a/backend/app/engine/compare.py b/backend/app/engine/compare.py index 6afdab43..ad80bb25 100644 --- a/backend/app/engine/compare.py +++ b/backend/app/engine/compare.py @@ -117,7 +117,7 @@ _CFG_KEYS: dict[str, frozenset[str]] = { "transform": frozenset({"mode", "expression", "template", "assign_to"}), "llm": _MODEL_KEYS | {"system", "skills", "use_history", "prompt", "output_schema", "emit_message", "assign_to"}, "agent": _MODEL_KEYS | {"system", "skills", "use_history", "prompt", "output_schema", "cite_fields", "tools", - "memory_scope", "collection", "parallel_tools", "max_steps", "budget_tokens", + "memory_scope", "collection", "parallel_tools", "max_steps", "budget_tokens", "investigation", "budget_usd", "approval", "emit_message", "assign_to"}, "supervisor": frozenset({"agents", "max_rounds", "on_exhausted", "goal", "provider", "model", "max_tokens", "max_parallel", "approval", "emit_message", "assign_to"}), diff --git a/backend/app/engine/evidence_reader.py b/backend/app/engine/evidence_reader.py new file mode 100644 index 00000000..c28f0360 --- /dev/null +++ b/backend/app/engine/evidence_reader.py @@ -0,0 +1,188 @@ +"""读取并验证封存证据的公共服务,供试运行、证据接口和评测使用。""" +from __future__ import annotations +from dataclasses import dataclass, field +from typing import Any +from sqlalchemy import select +from app.core.artifact_store import load +from app.core.events import EventType +from app.db.base import SessionLocal +from app.db.models import Run, RunEvent +from app.engine.evidence import ledger_enabled +from app.engine.lineage import scoped_id as _scoped +from app.analysis.contract import LEDGER_KIND + + +def _fetch(artifact: str) -> tuple[Any, bool]: + try: + value = load(artifact) + return value, value is not None + except (OSError, ValueError): + return None, False + + +@dataclass +class Sealed: + run_id: str + graph: dict[str, Any] + seal: dict[str, Any] + events: list[tuple[int, str, str | None, dict[str, Any]]] + #: 封存范围内 node.finished.evidence 记过的 (节点, 工件) + ledger: set[tuple[str, str]] = field(default_factory=set) + #: 节点 id → 它最后一次 node.finished 的产出工件 id + outputs: dict[str, str] = field(default_factory=dict) + #: 封存范围内交回过的查询快照:工件 id → {node_id, tool, source, via, call_id, exec} + queries: dict[str, dict[str, Any]] = field(default_factory=dict) + #: 口径卡节点 id → 封存范围内它的 caliber.upgrade 事件(升版处置) + upgrades: dict[str, dict[str, Any]] = field(default_factory=dict) + #: 封存范围内 node.finished.evidence 的台账条目,按事件顺序(给可疑实体找「最接近的名字」重建目录用) + entries: list[dict[str, Any]] = field(default_factory=list) + #: 封存范围内交回过的表结构快照:工件 id → {node_id, source} + schemas: dict[str, dict[str, Any]] = field(default_factory=dict) + #: 封存范围内交回过的检索快照:工件 id → {node_id, source} + retrievals: dict[str, dict[str, Any]] = field(default_factory=dict) + #: 封存范围内台账里记过的分析结论:工件 id → {node_id, exec, call_id / item, inputs} + analyses: dict[str, dict[str, Any]] = field(default_factory=dict) + #: 封存时记下的清单哈希(没封存的是 None):旧答案的猜测按 (run_id, 它) 缓存 + manifest_hash: str | None = None + #: 运行记录上的 manifest_seq 原值(老运行是 None):按需裁判追加判定前核对封存没换过 + run_manifest_seq: int | None = None + #: verify_manifest 的完整结论(审计表照实附上) + verdict: dict[str, Any] = field(default_factory=dict) + #: 运行记录上的几样:按需裁判只给跑完、封存了的探索运行,升级前发起的不给 + run_class: str | None = None + status: str | None = None + ledger_on: bool = False + #: 按需裁判的判定(evidence.judged 事件),按先后:(seq, 节点, 载荷)。不管落在封存前后都收—— + #: 失败后接着跑的运行会把早先追加的判定封进新的清单里,它仍然是按需裁判的批注 + judged: list[tuple[int, str | None, dict[str, Any]]] = field(default_factory=list) + #: 节点 id → node.started 上记的名字、类型。子工作流里的节点(「子工作流节点 id/…」)不在这次运行的图上,名字从这里取 + labels: dict[str, str] = field(default_factory=dict) + types: dict[str, str] = field(default_factory=dict) + #: 这一次请求里复算过的分析结论:结论工件 id → 复算的结论(_analysis_recheck)。导出页里同一份结论被判定语、具名的数、 + #: 整表的每一格、分析范围各引一处,各接一条链,复算只做一次。只活在这一次请求里:下一次请求重新取、重新验 + rechecks: dict[str, dict[str, Any]] = field(default_factory=dict) + + @property + def sealed(self) -> bool: + return bool(self.seal["sealed"]) + + @property + def trusted(self) -> bool: + """封存了、而且封存核对通过。封存范围内有事件被改过的话,范围里的哪一条都不能再 + 当「已封存」展示——只看 sealed 的消费方会把一条断掉的封存链画成绿的。""" + return self.sealed and self.seal["ok"] is True + + def nodes_of(self, node_type: str, *, nested: bool = False) -> list[str]: + """这次运行的图上这一类的节点;nested 为真时连子工作流里跑过的一起给(「子工作流节点 id/…」,类型照 node.started 记的)。""" + out = [str(n.get("id")) for n in self.graph.get("nodes") or [] + if isinstance(n, dict) and n.get("type") == node_type] + if nested: + out += [nid for nid, kind in self.types.items() if "/" in nid and kind == node_type] + return out + + def payload_of(self, node_ids: list[str]) -> dict[str, Any] | None: + """这几个节点里最后跑完的那个的产出,从封存的事件指向的工件里取。""" + for node_id in reversed([nid for _, t, nid, _ in self.events if t == "node.finished" and nid in node_ids]): + artifact = self.outputs.get(node_id) + try: + payload = load(artifact) if artifact else None + except ValueError: + payload = None + if isinstance(payload, dict): + return payload + return None + + + +async def read_sealed(run_id: str) -> Sealed: + from app.engine.runner import verify_manifest + + async with SessionLocal() as session: + run = await session.get(Run, run_id) + if run is None: + raise LookupError("运行记录不存在") + rows = [tuple(r) for r in await session.execute( + select(RunEvent.seq, RunEvent.type, RunEvent.node_id, RunEvent.data) + .where(RunEvent.run_id == run_id).order_by(RunEvent.seq))] + graph = run.graph if isinstance(run.graph, dict) else {} + manifest, manifest_seq = run.manifest_hash, run.manifest_seq + run_class, status, ledger_on = run.run_class, run.status, ledger_enabled(run) + verdict = await verify_manifest(run_id) + bound = verdict.get("sealed_at") if verdict.get("sealed") else None + # 没封存(还在跑、停在审批)的运行没有「封存之后」:现有的事件都算,但每件证据都标未封存 + events = [r for r in rows if bound is None or r[0] <= bound] + out = Sealed(run_id=run_id, graph=graph, events=events, seal={ + "sealed": bool(verdict.get("sealed")), "ok": verdict.get("ok"), "manifest_seq": bound, + "legacy": bool(verdict.get("legacy", False))}, manifest_hash=manifest, run_manifest_seq=manifest_seq, + verdict=verdict, run_class=run_class, status=status, ledger_on=ledger_on, + judged=[(seq, nid, data) for seq, etype, nid, data in rows + if etype == EventType.EVIDENCE_JUDGED and isinstance(data, dict)]) + for _, etype, node_id, data in events: + if not node_id or not isinstance(data, dict): + continue + if etype == "tool.end": + if data.get("query_artifact"): + out.queries.setdefault(str(data["query_artifact"]), { + "node_id": node_id, "tool": data.get("tool"), "via": data.get("artifact")}) + if data.get("schema_artifact"): + out.schemas.setdefault(str(data["schema_artifact"]), {"node_id": node_id}) + elif etype == "retrieve.end" and data.get("artifact"): + out.retrievals.setdefault(str(data["artifact"]), {"node_id": node_id, "source": data.get("collection")}) + elif etype == "caliber.upgrade": + out.upgrades[node_id] = data + elif etype == "node.started": + if isinstance(data.get("label"), str) and data["label"]: + out.labels.setdefault(node_id, data["label"]) + if isinstance(data.get("node_type"), str) and data["node_type"]: + out.types.setdefault(node_id, data["node_type"]) + if etype != "node.finished": + continue + if data.get("artifact"): + out.outputs[node_id] = data["artifact"] + for entry in data.get("evidence") or []: + if isinstance(entry, dict) and entry.get("artifact"): + out.ledger.add((node_id, entry["artifact"])) + # 条目里另记的节点 id(双盲复算复算的口径卡)是那个节点所在那一层图里的 id:换成这次运行里的 + out.entries.append({**entry, "node_id": node_id, + **({"card": _scoped(node_id, entry["card"])} if entry.get("card") else {})}) + artifact = str(entry["artifact"]) + if entry.get("kind") == "query": + out.queries[artifact] = { + "node_id": node_id, **{k: entry[k] for k in ("tool", "source", "via", "call_id", "exec") + if entry.get(k) is not None}} + elif entry.get("kind") == "schema": + out.schemas[artifact] = {"node_id": node_id, "source": entry.get("source")} + elif entry.get("kind") == "retrieval": + out.retrievals[artifact] = {"node_id": node_id, "source": entry.get("source")} + elif entry.get("kind") == LEDGER_KIND: + out.analyses.setdefault(artifact, { + "node_id": node_id, **{k: entry[k] for k in ("exec", "call_id", "item", "inputs") + if entry.get(k) is not None}}) + return out + + + +def metric_cards(sealed: Sealed) -> list[tuple[str, str, dict[str, Any]]]: + """封存范围内的口径卡:(节点, 工件, 指标集)。取回时复验哈希,对不上的、取不回来的不要。 + + 一期以后口径卡把指标集落成 metric_set 工件、记在台账里;一期以前没有,节点的产出(node.finished + 的 node_output 工件)本身就是指标集——只对画布上确实是口径卡的节点这样认。 + """ + out: list[tuple[str, str, dict[str, Any]]] = [] + seen: set[tuple[str, str]] = set() + calibers = set(sealed.nodes_of("metrics")) + for _, etype, node_id, data in sealed.events: + if etype != "node.finished" or not node_id or not isinstance(data, dict): + continue + arts = [str(e["artifact"]) for e in data.get("evidence") or [] + if isinstance(e, dict) and e.get("kind") == "metric_set" and e.get("artifact")] + if not arts and node_id in calibers and data.get("artifact"): + arts = [str(data["artifact"])] + for art in arts: + if (node_id, art) in seen: + continue + seen.add((node_id, art)) + content, ok = _fetch(art) + if ok is True and isinstance(content, dict) and isinstance(content.get("metrics"), list): + out.append((node_id, art, content)) + return out diff --git a/backend/app/engine/investigation.py b/backend/app/engine/investigation.py new file mode 100644 index 00000000..02175371 --- /dev/null +++ b/backend/app/engine/investigation.py @@ -0,0 +1,102 @@ +"""异常调查的确定性完成条件。模型选择下一次查询,系统核对证据与停止原因。""" +from __future__ import annotations + +import math +from typing import Any + +from pydantic import BaseModel, ConfigDict, Field + +INVESTIGATION_VERSION = 1 + + +class InvestigationPolicy(BaseModel): + model_config = ConfigDict(extra="forbid") + enabled: bool = True + max_rounds: int = Field(default=2, ge=0, le=5) + minimum_depth: int = Field(default=1, ge=0, le=5) + min_coverage: float = Field(default=0.8, ge=0, le=1) + + +def _number(value: Any) -> float: + try: + number = float(value) + return number if math.isfinite(number) else 0.0 + except (ValueError, TypeError, OverflowError): + return 0.0 + + +def assess(scope: Any, question: str, policy: InvestigationPolicy) -> dict[str, Any]: + from app.analysis.intent import analysis_intent + + results = list(scope.results.values()) if scope is not None else [] + intent = analysis_intent(question) + active = bool(policy.enabled and scope is not None and ( + (intent is not None and intent.kind == "why") or any( + r.get("status") == "ok" and (r.get("verdict") or {}).get("code") in ("outside_band", "far_outside_band") + for r in results))) + out: dict[str, Any] = {"version": INVESTIGATION_VERSION, "active": active, "status": "not_required", "depth": 0, "coverage": None, + "query_count": len(scope.queries) if scope is not None else 0, + "analysis_count": len(results), "reason": "", "next_step": ""} + if not active: + return out + + def pending(reason: str, next_step: str, stage: str) -> dict[str, Any]: + return {**out, "status": "partial", "stage": stage, "reason": reason, "next_step": next_step} + + done = [r for r in results if r.get("status") == "ok"] + decomposed = [r for r in done if r.get("op") == "decompose"] + if not decomposed: + refused = [r for r in results if r.get("status") == "refused"] + if refused: + return pending(str(refused[-1].get("reason") or "现有数据不满足分析条件"), + "检查缺数、截断、时间范围和口径;可以补齐时重新查询,无法补齐时明确证据缺口。", "data_quality") + return pending("尚未对偏差进行贡献分解", "先核对口径、数据完整性和两期可比性,再选择业务维度或因子进行分解。", "decompose") + last = decomposed[-1] + params = last.get("params") or {} + depth = int(params.get("depth") or 0) + out["depth"] = depth + chain = [last] + seen = set() + while (parent := (chain[-1].get("params") or {}).get("parent")): + name = parent.get("of") if isinstance(parent, dict) else None + previous = scope.results.get(name) if isinstance(name, str) else None + if (name in seen or not isinstance(previous, dict) or previous.get("status") != "ok" + or previous.get("op") != "decompose" or previous.get("mode") != last.get("mode")): + return pending("下钻与上层结果的关联不完整", "重新取得上层分解,用 parent 关联真实的贡献项。", "lineage") + seen.add(name) + chain.append(previous) + if depth != len(chain) - 1: + return pending("下钻层数与证据链不一致", "从总指标开始,用 parent 逐层关联分解结果。", "lineage") + caveats = {c.get("code") for result in chain for c in result.get("caveats") or [] if isinstance(c, dict)} + code = (last.get("verdict") or {}).get("code") + if any((r.get("verdict") or {}).get("code") == "unclosed" for r in chain) or "headline_basis" in caveats: + return pending("分解尚未与总指标核对闭合", "取同一口径的总数作为 headline,核对分组和总数的差异,再继续解释。", "closure") + if "comparability" in caveats: + return pending("两期可比性尚未确认", "先核对两期的窗口、过滤条件和数据完整性;无法确认时保留有条件结论。", "comparability") + comparable = [r for r in done if r.get("op") == "comparable"] + # 算子已有内部可比性检查的结果不要求模型重复调用;缺少声明则不能据此证明可比。 + if comparable and (comparable[-1].get("verdict") or {}).get("code") != "comparable": + return pending("可比性检查未通过", "先解决可比性缺口,再基于同口径数据重新分解。", "comparability") + values = last.get("values") or {} + coverage = max(_number(values.get("top_share")), _number(values.get("top_k_share"))) + if last.get("mode") == "additive": + from app.analysis.inputs import same_value + + coverage = 1.0 if code in ("flat", "dispersed") else max(0.0, min(1.0, coverage)) + for child, previous in zip(chain, chain[1:]): + key = child["params"]["parent"].get("key") or [] + table = previous.get("table") or {} + columns = table.get("columns") or [] + row = next((r for r in table.get("rows") or [] if key and len(r) >= len(columns) + and all(same_value(k, v) for k, v in zip(key, r[:len(key)]))), None) + if row is None or "share" not in columns: + return pending("无法核对下钻贡献占整体偏差的比例", "保留上层贡献表及下钻的分组键,再核对整体覆盖程度。", "coverage") + coverage *= max(0.0, min(1.0, _number(row[columns.index("share")]))) + out["coverage"] = coverage if last.get("mode") == "additive" else None + if code in ("flat", "dispersed") and depth == 0: + return {**out, "status": "complete", "stage": "contribution", "reason": "分解显示变化平稳或贡献分散"} + if last.get("mode") != "factor" and depth < policy.minimum_depth: + return pending("主要贡献项尚未进一步下钻", "对主要贡献项选择更细的业务维度,用 parent 关联上层结果继续下钻;没有合适维度时说明限制。", "drill") + if last.get("mode") == "additive" and coverage < policy.min_coverage: + return pending("主要贡献项的解释覆盖程度不足", "检查其他候选维度和剩余差异,保留未解释的部分,不把贡献分解写成因果证明。", "coverage") + return {**out, "status": "complete", "stage": "contribution", "reason": "完成贡献分析,因果关系仍需独立证据"} diff --git a/backend/app/engine/metric_spec.py b/backend/app/engine/metric_spec.py new file mode 100644 index 00000000..b785f068 --- /dev/null +++ b/backend/app/engine/metric_spec.py @@ -0,0 +1,131 @@ +"""指标的公共契约:依赖提取、可执行约束和质量结论共用这里的字段。""" +from __future__ import annotations + +import math +from typing import Any, Literal + +from pydantic import BaseModel, ConfigDict, Field, model_validator + + +class RatioContract(BaseModel): + model_config = ConfigDict(extra="forbid", allow_inf_nan=False) + numerator: str = Field(min_length=1) + denominator: str = Field(min_length=1) + scale: float = Field(default=1, gt=0) + subset: bool = True + + +class MetricContract(BaseModel): + """显式约束查询结果和指标值;自然语言定义仍需人工审阅。""" + + model_config = ConfigDict(extra="forbid", allow_inf_nan=False) + shape: Literal["scalar", "table"] = "scalar" + grain: list[str] = Field(default_factory=list) + non_null: list[str] = Field(default_factory=list) + unit: str | None = None + minimum: float | None = None + maximum: float | None = None + ratio: RatioContract | None = None + + @model_validator(mode="after") + def valid(self) -> "MetricContract": + if self.minimum is not None and self.maximum is not None and self.minimum > self.maximum: + raise ValueError("minimum 不能大于 maximum") + if any(not c.strip() for c in self.grain + self.non_null): + raise ValueError("粒度和非空列名不能为空") + if len(set(self.grain)) != len(self.grain): + raise ValueError("粒度列不能重复") + return self + + +def metric_expressions(metric: Any) -> list[str] | None: + """只读取当前类型的有效输入;隐藏的旧编辑字段不产生依赖。""" + if not isinstance(metric, dict): + return None + kind = metric.get("kind") or "formula" + if kind == "external": + return [] + if kind == "formula": + return [metric["expression"]] if isinstance(metric.get("expression"), str) else None + if kind == "rule": + inputs = (metric.get("rule") or {}).get("inputs") if isinstance(metric.get("rule"), dict) else None + return list(inputs.values()) if isinstance(inputs, dict) and all(isinstance(v, str) for v in inputs.values()) else None + if kind == "assessment": + dims = (metric.get("assessment") or {}).get("dimensions") if isinstance(metric.get("assessment"), dict) else None + if not isinstance(dims, list) or any(not isinstance(d, dict) or not isinstance(d.get("input"), str) for d in dims): + return None + return [d["input"] for d in dims] + return None + + +def contract_findings(definition: dict[str, Any], metric: dict[str, Any], snapshots: list[dict[str, Any]]) -> list[str]: + """检查明确声明的约束,不把自然语言字段解释为已经执行的过滤条件。""" + raw = definition.get("contract") + if raw is None: + return [] + contract = MetricContract.model_validate(raw) + problems: list[str] = [] + if contract.unit is not None and contract.unit != str(definition.get("unit") or ""): + problems.append("指标单位与约束声明不一致") + value = metric.get("value") + if contract.minimum is not None or contract.maximum is not None: + if isinstance(value, bool) or not isinstance(value, (int, float)) or not math.isfinite(value): + problems.append("无法检查取值范围:结果不是有限数值") + elif ((contract.minimum is not None and value < contract.minimum) + or (contract.maximum is not None and value > contract.maximum)): + problems.append("指标值超出声明的范围") + needs_snapshot = bool("shape" in contract.model_fields_set or contract.grain or contract.non_null or contract.ratio) + if needs_snapshot and not snapshots: + problems.append("无法检查结果形状:没有可验证的查询快照") + for snap in snapshots: + rows, columns = snap.get("rows"), snap.get("columns") + if not isinstance(rows, list) or not isinstance(columns, list): + problems.append("无法检查结果形状:查询快照不完整") + continue + if contract.shape == "scalar" and len(rows) != 1: + problems.append(f"标量指标要求取数结果恰好一行,实际为 {len(rows)} 行") + if snap.get("truncated"): + problems.append("查询已截断,无法验证完整结果的粒度和行数") + required = set(contract.grain + contract.non_null) + if missing := required - set(columns): + problems.append("查询缺少约束列:" + "、".join(sorted(missing))) + continue + if any(not isinstance(row, list) or len(row) != len(columns) for row in rows): + problems.append("查询行与列定义不一致") + continue + if any(row[columns.index(col)] is None for row in rows for col in contract.non_null): + problems.append("声明非空的列含有空值") + if contract.grain: + from app.core.artifact_store import canonical_json + + keys = [canonical_json([row[columns.index(col)] for col in contract.grain]) for row in rows] + if len(keys) != len(set(keys)): + problems.append("查询结果在声明的粒度上存在重复行") + if contract.ratio is not None: + ratio = contract.ratio + candidates = [s for s in snapshots if ratio.numerator in (s.get("columns") or []) + and ratio.denominator in (s.get("columns") or [])] + if not candidates: + problems.append("比率约束要求同一查询结果提供分子和分母列") + else: + for snap in candidates: + columns, rows = snap["columns"], snap["rows"] + if len(rows) != 1 or not isinstance(rows[0], list) or len(rows[0]) != len(columns): + problems.append("比率约束要求分子和分母来自同一行汇总结果") + continue + numerator, denominator = (rows[0][columns.index(c)] for c in (ratio.numerator, ratio.denominator)) + if any(isinstance(v, bool) or not isinstance(v, (int, float)) or not math.isfinite(v) + for v in (numerator, denominator)): + problems.append("分子和分母必须是有限数值") + elif denominator <= 0: + problems.append("比率分母必须大于零") + elif ratio.subset and not 0 <= numerator <= denominator: + problems.append("分子作为分母的子集,不能小于零或大于分母") + else: + expected = numerator / denominator * ratio.scale + decimals = metric.get("decimals") + if isinstance(decimals, int) and not isinstance(decimals, bool): + expected = round(expected, decimals) + if isinstance(value, bool) or not isinstance(value, (int, float)) or not math.isclose(value, expected, rel_tol=1e-9, abs_tol=1e-12): + problems.append("指标值与声明的分子、分母和换算倍率不一致") + return list(dict.fromkeys(problems)) diff --git a/backend/app/engine/nodes/io.py b/backend/app/engine/nodes/io.py index 03b84196..8d2b6799 100644 --- a/backend/app/engine/nodes/io.py +++ b/backend/app/engine/nodes/io.py @@ -198,6 +198,13 @@ def _apply_contract( gaps.extend(no_data_gaps(metrics)) # 外部常量过了有效期:值还是那个值,但已经不能代表现在 gaps.extend(external_stale_gaps(metrics)) + from app.engine.validation import additional_metric_gaps + + gaps.extend(additional_metric_gaps(metrics)) + for output in (state.get("nodes") or {}).values(): + investigation = output.get("investigation") if isinstance(output, dict) else None + if isinstance(investigation, dict) and investigation.get("active") and investigation.get("status") != "complete": + gaps.append("异常调查未完成:" + str(investigation.get("reason") or "证据不足")) # 来源查询没通过 SQL 检查(error 级:重复计算、存量跨期加总):口径卡复算得一致,算的却是错数。 # 报告直接引用的查询要等引用核对之后才知道,缺口放在这个位置,下面一起算 sql_at = len(gaps) @@ -388,8 +395,8 @@ def _untraced_gaps(metrics: list[dict[str, Any]], ctx: NodeContext) -> list[str] def _comparability(metrics: list[dict[str, Any]]) -> tuple[list[str], list[dict[str, Any]]]: """口径卡指标上的可比性(contract.MetricComparability,口径卡跨两份同形查询算环比、同比时自动做的检查): - (缺口, 出具声明里的那一项)。metrics 是要看的指标(报告引用了的、契约必需的,调用方挑好):两期不可比的记缺口(降档, - 同 sql_check_failed),有条件可比的只标注。没有可比性记录的指标不在里面;同一条指标只看一次。""" + (缺口, 出具声明里的那一项)。metrics 是要看的指标(报告引用了的、契约必需的,调用方挑好):两期不可比或尚有条件的 + 都记缺口并降档,不能把尚未核实的可比性当成通过。没有可比性记录的指标不在里面;同一条指标只看一次。""" gaps: list[str] = [] declared: list[dict[str, Any]] = [] seen: set[int] = set() @@ -407,7 +414,7 @@ def _comparability(metrics: list[dict[str, Any]]) -> tuple[list[str], list[dict[ declared.append({"metric": mid, "name": name, "verdict": verdict, "phrase": phrase, "reasons": [str(r) for r in found.get("reasons") or [] if r], **({"artifact": found["artifact"]} if found.get("artifact") else {})}) - if verdict == "incomparable": + if verdict in ("incomparable", "conditional"): gaps.append(f"指标「{name}」{phrase or '两期不可比'}") return gaps, declared diff --git a/backend/app/engine/nodes/llm.py b/backend/app/engine/nodes/llm.py index 2b90393e..03911cc7 100644 --- a/backend/app/engine/nodes/llm.py +++ b/backend/app/engine/nodes/llm.py @@ -664,6 +664,12 @@ async def run_agent(state: GraphState, ctx: NodeContext) -> dict[str, Any]: #: 为什么没等到模型自己给结论:steps / stall / budget_tokens / budget_usd / context settle_reason: str | None = None answered = False + from app.engine.investigation import InvestigationPolicy, assess as assess_investigation + + investigation_policy = InvestigationPolicy.model_validate(ctx.cfg("investigation", {}) or {}) + investigation_rounds = 0 + investigation_question = "\n".join(message_text(m) for m in messages if isinstance(m, HumanMessage)) + for step in range(max_steps): response = await llm_step(step, messages) if guard: @@ -685,6 +691,18 @@ async def run_agent(state: GraphState, ctx: NodeContext) -> dict[str, Any]: messages.append(HumanMessage(content=TOOL_MARKUP_NUDGE)) final_text = "" continue + investigation = assess_investigation(scope, investigation_question, investigation_policy) + if (investigation["active"] and investigation["status"] != "complete" + and investigation_rounds < investigation_policy.max_rounds and step < max_steps - 1): + investigation_rounds += 1 + messages.append(HumanMessage(content=( + f"调查尚未完成:{investigation['reason']}。{investigation['next_step']}" + "仅在现有权限和查询预算内补充证据;不能获得证据时明确说明,不编造原因。"))) + await once(ctx, EventType.LOG, level="info", code="investigation_continue", + round=investigation_rounds, stage=investigation.get("stage"), + message=investigation["reason"]) + final_text = "" + continue answered = True break @@ -861,6 +879,16 @@ async def run_agent(state: GraphState, ctx: NodeContext) -> dict[str, Any]: final_text = f"{final_text}\n\n({hint})".strip() if final_text else f"({hint})" ctx.emit(EventType.LOG, level="warn", message=hint, code="step_limit", **limited) + investigation = assess_investigation(scope, investigation_question, investigation_policy) + if investigation["active"]: + investigation["rounds"] = investigation_rounds + investigation["stop_reason"] = (settle_reason or "evidence_sufficient" if investigation["status"] == "complete" + else settle_reason or "evidence_insufficient") + if investigation["status"] != "complete": + final_text = f"{final_text}\n\n调查范围:{investigation['reason']};现有证据不足以确认根因。".strip() + await once(ctx, EventType.LOG, level="warn", code="investigation_incomplete", investigation=investigation, + message=investigation["reason"]) + cited: tuple[dict[str, Any], dict[str, Any]] | None = None schema = _output_schema(ctx) if evidence_on and ctx.cfg("cite_fields") is True else None if schema is not None and (fields := cited_fields(schema)): @@ -882,6 +910,7 @@ async def run_agent(state: GraphState, ctx: NodeContext) -> dict[str, Any]: result = { "text": final_text, "steps": len(transcript), + **({"investigation": investigation} if investigation["active"] else {}), "tool_calls": transcript, "model": model_id, # 没等到模型自己给结论就收了尾:下游校验失败时拿它说明根源(human.py) diff --git a/backend/app/engine/nodes/metrics.py b/backend/app/engine/nodes/metrics.py index 1bcb3227..693de465 100644 --- a/backend/app/engine/nodes/metrics.py +++ b/backend/app/engine/nodes/metrics.py @@ -2,6 +2,7 @@ from __future__ import annotations import ast import asyncio +import copy import json import math import re @@ -96,6 +97,51 @@ async def run_metrics(state: GraphState, ctx: NodeContext) -> dict[str, Any]: snapshots: dict[str, Any] = {} #: 可比性检查:同一对查询快照只查一次(几个指标都用本周、上周两份快照时),和做检查要用的几样(_Comparing) comparing = _Comparing() + + async def quality(inputs: list[dict[str, Any]], paths: list[str], use: TruncatedUse) -> dict[str, Any]: + """所有取数型指标共用的质量检查;换一种计算方式不能丢掉来源查询的缺口。""" + carried = [m for item in inputs if (m := _copied_metric(item, tctx)) is not None] + truncated = list(use.sources.values()) + for source in (s for m in carried if m.get("incomplete") for s in m.get("truncated_sources") or []): + if isinstance(source, dict) and source not in truncated: + truncated.append(source) + flags: dict[str, Any] = {} + if truncated: + flags.update(incomplete=True, incomplete_reason=_incomplete_reason(truncated), + truncated_sources=truncated) + copied_checks = [str(c.get("artifact")) for m in carried for c in m.get("sql_check_sources") or [] + if isinstance(c, dict) and c.get("artifact")] + if failed := _failed_queries(inputs, truncated, paths, tctx, checked, extra=copied_checks): + flags.update(sql_check_failed=True, sql_check_reason=_sql_check_reason(failed), + sql_check_sources=[{"artifact": artifact, "codes": list(dict.fromkeys( + code for p in found for code in p.codes))} for artifact, found in failed]) + failed_by[metric_id] = [p for _, found in failed for p in found] + if lacking := _no_data_inputs(inputs, carried, snapshots): + flags.update(no_data=True, no_data_reason=_no_data_reason(lacking), no_data_sources=lacking) + comparable = await _comparability(metric_id, str(definition.get("name") or metric_id), inputs, ctx, + comparing, snapshots) + if comparable: + flags["comparability"] = comparable + artifacts = {item.get("artifact") for item in inputs if item.get("artifact")} + if any(set(pair) <= artifacts for pair in comparing.skipped): + flags["comparability_skipped"] = True + if any(m.get("comparability_skipped") for m in carried): + flags["comparability_skipped"] = True + if any(m.get("semantic_unverified") for m in carried): + flags["semantic_unverified"] = True + flags["contract_findings"] = list(dict.fromkeys(str(f) for m in carried for f in m.get("contract_findings") or [])) + inherited = [m["comparability"] for m in carried if isinstance(m.get("comparability"), dict) + and m["comparability"].get("verdict") != "comparable"] + if inherited: + worst = next((c for c in inherited if c.get("verdict") == "incomparable"), inherited[0]) + if flags.get("comparability", {}).get("verdict") != "incomparable": + flags["comparability"] = worst + return flags + + def input_refs(expressions: list[str]) -> tuple[list[str], list[dict[str, Any]]]: + paths = list(dict.fromkeys(path for expr in expressions for path in leaf_refs(parse_expression(expr)[0]))) + return paths, [_input_of(path, _value_of(path, tctx), state, ctx, tctx) for path in paths] + for definition in definitions: metric_id = str(definition.get("id") or "").strip() if not metric_id: @@ -105,13 +151,14 @@ async def run_metrics(state: GraphState, ctx: NodeContext) -> dict[str, Any]: if problem: errors.append(f"指标「{metric_id}」:{problem}") continue + use = TruncatedUse(edge=lambda path: _truncated_field(path, state, ctx)) if str(definition.get("kind") or "formula") == "assessment": assessment, problem = _assessment_of(definition) if problem: errors.append(f"指标「{metric_id}」:{problem}") continue dimensions = assessment["dimensions"] - values_by_name = {str(d["name"]): _value_of(str(d["input"]), tctx) for d in dimensions} + values_by_name = {str(d["name"]): _value_of(str(d["input"]), tctx, track=use) for d in dimensions} missing = [name for name, value in values_by_name.items() if value is None] if missing and on_missing != "null": errors.append(f"指标「{metric_id}」:" + _missing_reason([str(d["input"]) for d in dimensions @@ -132,6 +179,9 @@ async def run_metrics(state: GraphState, ctx: NodeContext) -> dict[str, Any]: except (TypeError, ValueError): bad = f"维度「{d['name']}」的输入不是数({values_by_name[str(d['name'])]!r})" break + if not math.isfinite(raw) or isinstance(values_by_name[str(d["name"])], bool): + bad = f"维度「{d['name']}」的输入必须是有限数值" + break normalized = max(0.0, min(100.0, (raw - low) / (high - low) * 100)) weight = float(d.get("weight", 1)) points += normalized * weight @@ -145,8 +195,7 @@ async def run_metrics(state: GraphState, ctx: NodeContext) -> dict[str, Any]: raw_score = points / total value = round_metric(raw_score, display["decimals"]) level = next((str(l["name"]) for l in assessment["levels"] if raw_score >= float(l["min"])), None) - inputs = [_input_of(str(d["input"]), values_by_name[str(d["name"])], state, ctx, tctx) - for d in dimensions] + paths, inputs = input_refs([str(d["input"]) for d in dimensions]) metrics.append({ "id": metric_id, "name": definition.get("name") or metric_id, @@ -166,6 +215,7 @@ async def run_metrics(state: GraphState, ctx: NodeContext) -> dict[str, Any]: **({"missing": missing} if missing else {}), **({"out_of_range": out_of_range} if out_of_range else {}), **({"unlevelled": True} if value is not None and level is None else {})}, + **await quality(inputs, paths, use), }) continue if str(definition.get("kind") or "formula") == "rule": @@ -174,7 +224,7 @@ async def run_metrics(state: GraphState, ctx: NodeContext) -> dict[str, Any]: errors.append(f"指标「{metric_id}」:{problem}") continue inputs_cfg = {str(name): str(path) for name, path in (rule.get("inputs") or {}).items()} - values_by_name = {name: _value_of(path, tctx) for name, path in inputs_cfg.items()} + values_by_name = {name: _value_of(path, tctx, track=use) for name, path in inputs_cfg.items()} missing = [name for name, value in values_by_name.items() if value is None] if missing and on_missing != "null": errors.append(f"指标「{metric_id}」:" + _missing_reason([inputs_cfg[name] for name in missing])) @@ -184,7 +234,7 @@ async def run_metrics(state: GraphState, ctx: NodeContext) -> dict[str, Any]: value = rule.get("then", 1) if _rule_matches(values_by_name, rule) else rule.get("else", 0) decimals = display["decimals"] value = round_metric(value, decimals) - inputs = [_input_of(path, values_by_name[name], state, ctx, tctx) for name, path in inputs_cfg.items()] + paths, inputs = input_refs(list(inputs_cfg.values())) metrics.append({ "id": metric_id, "name": definition.get("name") or metric_id, @@ -200,6 +250,7 @@ async def run_metrics(state: GraphState, ctx: NodeContext) -> dict[str, Any]: "recompute_reason": "这个指标不按表达式复算", "status": "ok" if value is not None else "missing_input", "rule": rule, + **await quality(inputs, paths, use), }) continue if str(definition.get("kind") or "formula") == "external": @@ -240,7 +291,6 @@ async def run_metrics(state: GraphState, ctx: NodeContext) -> dict[str, Any]: missing = [path for path in paths if values[path] is None] value: Any = None - use = TruncatedUse(edge=lambda path: _truncated_field(path, state, ctx)) try: value = eval_expression(expr, tctx, track=use) except ExpressionError as e: @@ -271,17 +321,6 @@ async def run_metrics(state: GraphState, ctx: NodeContext) -> dict[str, Any]: value = round_metric(value, decimals) substituted = substitute(tree, values) inputs = [_input_of(path, values[path], state, ctx, tctx) for path in paths] - # 转抄另一张口径卡的指标:那个指标不完整、所依据的查询没通过 SQL 检查的,这一个照样不完整、存疑(出处是跨卡追下去 - # 的,判档也按追到底的结果,见 engine/lineage.metric_copy) - carried = [m for item in inputs if (m := _copied_metric(item, tctx)) is not None] - truncated = list(use.sources.values()) - for source in (s for m in carried if m.get("incomplete") for s in m.get("truncated_sources") or []): - if isinstance(source, dict) and source not in truncated: - truncated.append(source) - incomplete: dict[str, Any] = {} - if truncated: - incomplete = {"incomplete": True, "incomplete_reason": _incomplete_reason(truncated), - "truncated_sources": truncated} for path, item in zip(paths, inputs): # 容器型的 vars.* 输入(列表、字典)另存一份完整值:卡里只记了「[12 项]」这样的大概,事后推演 # (engine/recompute.py)要拿完整的值重算。nodes.*、input.* 的完整值在那个节点封存的产出工件里, @@ -289,22 +328,6 @@ async def run_metrics(state: GraphState, ctx: NodeContext) -> dict[str, Any]: # 只能在这一刻存下。只给这种输入多一个键:没有容器型 vars 输入的卡和以前逐字节相同 if _container_var(path, values[path]) and (stored := await _store_value(path, values[path], ctx)): item["value_artifact"] = stored - sql_check: dict[str, Any] = {} - copied_checks = [str(c.get("artifact")) for m in carried for c in m.get("sql_check_sources") or [] - if isinstance(c, dict) and c.get("artifact")] - if failed := _failed_queries(inputs, truncated, paths, tctx, checked, extra=copied_checks): - sql_check = {"sql_check_failed": True, "sql_check_reason": _sql_check_reason(failed), - "sql_check_sources": [{"artifact": artifact, "codes": list(dict.fromkeys( - code for p in found for code in p.codes))} for artifact, found in failed]} - failed_by[metric_id] = [p for _, found in failed for p in found] - # 输入取自「查询区间里这张表没有数据」的列(查询快照的 no_data,data/coverage.py):值照算,标 no_data 并写明原因, - # 出具契约据此降档(issuance.no_data_gaps)。按输入的快照 id + 列认:同一条查询里有数的列不受影响。转抄的指标 - # 那张卡标了的,这一个照样标 - no_data: dict[str, Any] = {} - if lacking := _no_data_inputs(inputs, carried, snapshots): - no_data = {"no_data": True, "no_data_reason": _no_data_reason(lacking), "no_data_sources": lacking} - comparability = await _comparability(metric_id, str(definition.get("name") or metric_id), inputs, ctx, - comparing, snapshots) metrics.append( { "id": metric_id, @@ -324,14 +347,7 @@ async def run_metrics(state: GraphState, ctx: NodeContext) -> dict[str, Any]: # 输入都齐、是计算式自己交回的空值(分母为 0 时写的 … if 分母 else None):算不出来,不是缺输入。状态照旧是 # missing_input(证据面板、推演、运行对比按状态认的地方不受影响),只多这一个键;有值的指标和以前一字不差 **({"undefined": True} if value is None and not missing else {}), - # 只在不完整时出现:完整的指标和以前一字不差,老运行复核时目录也对得上 - **incomplete, - # 只在来源查询没通过 SQL 检查时出现,同上 - **sql_check, - # 只在输入取自「查询区间里这张表没有数据」的列时出现,同上 - **no_data, - # 只在输入落在两份同形、只差字面量的查询快照上、可比性检查做成了时出现(contract.MetricComparability) - **({"comparability": comparability} if comparability else {}), + **await quality(inputs, paths, use), } ) @@ -339,6 +355,32 @@ async def run_metrics(state: GraphState, ctx: NodeContext) -> dict[str, Any]: # 口径卡是固定层:任何一个指标算不出来都是模板缺陷,不能带病出具 raise NodeError(ctx.node.id, "口径卡计算失败:" + ";".join(errors)) + from app.engine.metric_spec import contract_findings + from app.engine.validation import metric_quality + from app.data.caliber_identity import semantic_hash + + definitions_by_id = {str(d.get("id")): d for d in definitions} + for metric in metrics: + definition = definitions_by_id[metric["id"]] + sources = [] + for item in metric.get("inputs") or []: + artifact = (item.get("origin") or {}).get("artifact") if isinstance(item.get("origin"), dict) else None + artifact = artifact or item.get("query_artifact") or item.get("artifact") or _result_artifact(str(item.get("path") or ""), tctx) + if artifact: + try: + snap = snapshots.get(artifact) or artifact_store.load(artifact) + except (ValueError, OSError): + snap = None + if isinstance(snap, dict) and isinstance(snap.get("rows"), list): + sources.append(snap) + metric["contract_findings"] = list(dict.fromkeys([*(metric.get("contract_findings") or []), + *contract_findings(definition, metric, sources)])) + # 描述文字不会被静默当作已执行的 SQL 约束。 + if any(definition.get(key) for key in ("grain", "time_field", "numerator", "denominator", "dimensions", "filters", "params")): + metric["semantic_unverified"] = True + metric["quality"] = metric_quality(metric) + semantic_fingerprint = semantic_hash(ctx.run.spec.model_dump(mode="json"), ctx.node.id) + # text 是给叙述节点 prompt 用的清单——它是叙述层唯一的数字来源 lines = [ f"- {m['id']}({m['name']})= {_listed(m)}" @@ -363,9 +405,11 @@ async def run_metrics(state: GraphState, ctx: NodeContext) -> dict[str, Any]: message="指标" + "".join(f"「{m['name']}」" for m in lacking) + lacking[0]["no_data_reason"]) card = { "kind": "metric_set", + "validation_version": 1, "caliber": caliber, "caliber_version": caliber_version, "definition_hash": definition_hash, + "semantic_hash": semantic_fingerprint, "metrics": metrics, } if source: @@ -393,6 +437,7 @@ async def run_metrics(state: GraphState, ctx: NodeContext) -> dict[str, Any]: "caliber": caliber, "version": caliber_version, "definition_hash": definition_hash, + "semantic_hash": semantic_fingerprint, "metrics": [m["id"] for m in metrics], **({"source": source} if source else {}), }] @@ -520,10 +565,22 @@ def rename_expression(expression: str, rename: dict[str, str]) -> str: def rename_definition(definition: Any, rename: dict[str, str]) -> Any: - """一个指标定义按映射改写式子(别的项原样)。不是对象的原样交回,后面照常报「指标定义不完整」。""" - if not isinstance(definition, dict) or not isinstance(definition.get("expression"), str): + """改写公式、规则输入和评分维度里的节点引用,不修改被固定的源定义。""" + if not isinstance(definition, dict): return definition - return {**definition, "expression": rename_expression(definition["expression"], rename)} + result = copy.deepcopy(definition) + if isinstance(result.get("expression"), str): + result["expression"] = rename_expression(result["expression"], rename) + rule = result.get("rule") + if isinstance(rule, dict) and isinstance(rule.get("inputs"), dict): + rule["inputs"] = {name: rename_expression(path, rename) if isinstance(path, str) else path + for name, path in rule["inputs"].items()} + assessment = result.get("assessment") + if isinstance(assessment, dict): + for dimension in assessment.get("dimensions") or []: + if isinstance(dimension, dict) and isinstance(dimension.get("input"), str): + dimension["input"] = rename_expression(dimension["input"], rename) + return result def source_expression(expression: str, source: Any) -> str: @@ -549,8 +606,8 @@ def _external_of(definition: dict[str, Any]) -> tuple[Any, dict[str, Any], str | if not isinstance(external, dict): return None, {}, "外部常量指标需要 external 对象(value / source / as_of / stale_after_days)" value = external.get("value") - if isinstance(value, bool) or not isinstance(value, (int, float)): - return None, {}, "external.value 需要是数" + if isinstance(value, bool) or not isinstance(value, (int, float)) or not math.isfinite(value): + return None, {}, "external.value 需要是有限数值" source = str(external.get("source") or "").strip() if not source: return None, {}, "external.source 需要写明来源" @@ -579,8 +636,12 @@ def _assessment_of(definition: dict[str, Any]) -> tuple[dict[str, Any], str | No or not isinstance(dimension.get("input"), str) \ or isinstance(dimension.get("weight", 1), bool) \ or not isinstance(dimension.get("weight", 1), (int, float)) \ + or not math.isfinite(dimension.get("weight", 1)) \ or float(dimension.get("weight", 1)) <= 0: return {}, "评估维度需要 name、input 和正的 weight" + low, high = dimension.get("min", 0), dimension.get("max", 100) + if any(isinstance(v, bool) or not isinstance(v, (int, float)) or not math.isfinite(v) for v in (low, high)) or low >= high: + return {}, "评估维度的 min / max 必须是递增的有限数值" levels = assessment.get("levels") if not isinstance(levels, list) or not levels: return {}, "评估需要至少一个等级" @@ -737,6 +798,7 @@ class _Comparing: def __init__(self) -> None: self.done: dict[tuple[str, str], dict[str, Any] | None] = {} self.kinds: dict[str, str | None] = {} + self.skipped: set[tuple[str, str]] = set() async def _comparability(metric_id: str, name: str, inputs: list[dict[str, Any]], ctx: NodeContext, @@ -768,6 +830,7 @@ async def _comparability(metric_id: str, name: str, inputs: list[dict[str, Any]] except _NotPeriods: found = None except Exception as e: # noqa: BLE001 - 检查是附加的,不能挡住口径卡 + comparing.skipped.add(key) why = e.message if isinstance(e, _Skipped) else describe_exception(e) ctx.emit(EventType.LOG, level="warn", code=COMPARABILITY_SKIPPED, metric=metric_id, metric_name=name, reason=why, message=COMPARABILITY_SKIPPED_TEXT.format(name=name, why=why)) @@ -981,10 +1044,10 @@ def _listed(metric: dict[str, Any]) -> str: return rendered -def _value_of(path: str, tctx: dict[str, Any]) -> Any: +def _value_of(path: str, tctx: dict[str, Any], *, track: TruncatedUse | None = None) -> Any: """单独取一个引用的值。取不到(或取值本身出错)一律当作没有值。""" try: - return eval_expression(path, tctx) + return eval_expression(path, tctx, track=track) except Exception: # noqa: BLE001 return None @@ -1522,7 +1585,8 @@ async def _store(card: dict[str, Any], caliber: str, version: str, ctx: NodeCont try: return await put_json(card, kind="metric_set", run_id=ctx.run.run_id, node_id=ctx.node.id, meta={"caliber": caliber, "version": version, - "definition_hash": str(card.get("definition_hash") or "")}) + "definition_hash": str(card.get("definition_hash") or ""), + "semantic_hash": str(card.get("semantic_hash") or "")}) except Exception as e: # noqa: BLE001 ctx.emit(EventType.LOG, level="warn", code="evidence_store_failed", message=f"口径卡的指标保存失败({describe_exception(e)}),报告将无法引用这些指标") diff --git a/backend/app/engine/review.py b/backend/app/engine/review.py index 74dba4ba..cc82c7f2 100644 --- a/backend/app/engine/review.py +++ b/backend/app/engine/review.py @@ -147,6 +147,7 @@ WARN_CODES: dict[str, tuple[str, str]] = { # 分析能力一轮(00-总方案 §3.7):只写说明、不自动重来(NOTE_ONLY)。答案照常交付,缺口照实说一句就够了: # 报告里判断词没挂对分析结论、原因没有依据,重写后仍有(check 标在报告里,strict 另记出具缺口) "report_analysis_claims": ("report_analysis_claims", DEGRADED), + "investigation_incomplete": ("investigation_incomplete", DEGRADED), # 一份分析算不了(历史不够、本期不完整、截断……):算不了本身是一条结论,写作者照实写原因。一次运行里几次算不了的 # 并成一条「有 n 次分析没做成」(_analysis_signal),不一次一条 REFUSED_CODE: (REFUSED_CODE, DEGRADED), @@ -166,7 +167,7 @@ REBUILD = {"query_no_data", "metric_no_data"} #: 只写说明、不自动重来的信号(00-总方案 §3.7):都是缺口(DEGRADED)、不进 RETRYABLE / REBUILD。一次运行的信号全是 #: 这几种时复核不叫模型,说明就照信号原话写(review):答案照常、报告里已经标出,叫一次模型只是把同一句话换个说法 -NOTE_ONLY = frozenset({"report_analysis_claims", REFUSED_CODE, "metric_comparability_skipped", ANALYSIS_CALL_FAILED}) +NOTE_ONLY = frozenset({"investigation_incomplete", "report_analysis_claims", REFUSED_CODE, "metric_comparability_skipped", ANALYSIS_CALL_FAILED}) #: 「有 n 次分析没做成」那条信号里最多点名几次,其余写「……」 _ANALYSIS_SHOWN = 3 diff --git a/backend/app/engine/schema.py b/backend/app/engine/schema.py index 36bcaa21..b789ffa8 100644 --- a/backend/app/engine/schema.py +++ b/backend/app/engine/schema.py @@ -1106,6 +1106,13 @@ def metric_definition_issues(node: GraphNode, metric: Any, index: int) -> list[V if not isinstance(metric, dict): add("指标定义需要是一个对象") return out + if metric.get("contract") is not None: + from app.engine.metric_spec import MetricContract + + try: + MetricContract.model_validate(metric["contract"]) + except ValueError as exc: + add(f"指标约束无效:{exc}", f"{field}.contract", code="metrics.contract_invalid") if not metric.get("id") or not str(metric.get("id")).strip(): add(f"指标定义缺少 ID:{field}", code="metrics.id_missing") kind = str(metric.get("kind") or "formula") @@ -1120,8 +1127,8 @@ def metric_definition_issues(node: GraphNode, metric: Any, index: int) -> list[V f"{field}.external", code="metrics.external_invalid") else: value = external.get("value") - if isinstance(value, bool) or not isinstance(value, (int, float)): - add("external.value 需要是数", f"{field}.external.value", code="metrics.external_invalid") + if isinstance(value, bool) or not isinstance(value, (int, float)) or not math.isfinite(value): + add("external.value 需要是有限数值", f"{field}.external.value", code="metrics.external_invalid") if not isinstance(external.get("source"), str) or not external["source"].strip(): add("external.source 需要写明来源", f"{field}.external.source", code="metrics.external_invalid") raw = external.get("as_of") @@ -1202,10 +1209,10 @@ def metric_definition_issues(node: GraphNode, metric: Any, index: int) -> list[V add("评估维度的 input 要写成 cell(nodes.…)、vars.… 或 input.…", f"{key}.input", code="metrics.assessment_invalid") weight = dimension.get("weight", 1) - if isinstance(weight, bool) or not isinstance(weight, (int, float)) or weight <= 0: + if isinstance(weight, bool) or not isinstance(weight, (int, float)) or not math.isfinite(weight) or weight <= 0: add("评估维度的 weight 需要是正数", f"{key}.weight", code="metrics.assessment_invalid") low, high = dimension.get("min", 0), dimension.get("max", 100) - if any(isinstance(v, bool) or not isinstance(v, (int, float)) for v in (low, high)) or high <= low: + if any(isinstance(v, bool) or not isinstance(v, (int, float)) or not math.isfinite(v) for v in (low, high)) or high <= low: add("评估维度的 min / max 需要是数,且 max 大于 min", key, code="metrics.assessment_invalid") levels = assessment.get("levels") if not isinstance(levels, list) or not levels: @@ -1221,7 +1228,7 @@ def metric_definition_issues(node: GraphNode, metric: Any, index: int) -> list[V if not isinstance(level.get("name"), str) or not level["name"].strip(): add("评估等级需要名字", f"{key}.name", code="metrics.assessment_invalid") minimum = level.get("min") - if isinstance(minimum, bool) or not isinstance(minimum, (int, float)): + if isinstance(minimum, bool) or not isinstance(minimum, (int, float)) or not math.isfinite(minimum): add("评估等级的 min 需要是数", f"{key}.min", code="metrics.assessment_invalid") elif previous is not None and minimum >= previous: add("评估等级要按 min 从高到低排列", f"{key}.min", code="metrics.assessment_invalid") @@ -1390,6 +1397,14 @@ def validate_graph(spec: GraphSpec) -> ValidationResult: if not spec.outgoing(node.id): result.add("「循环」节点没有循环体", node_id=node.id) + if node.type == NodeType.AGENT and cfg.get("investigation") is not None: + from app.engine.investigation import InvestigationPolicy + + try: + InvestigationPolicy.model_validate(cfg["investigation"]) + except ValueError as exc: + result.add(f"调查策略无效:{exc}", node_id=node.id, field="investigation", code="agent.investigation_invalid") + if node.type in (NodeType.LLM, NodeType.AGENT, NodeType.SUPERVISOR, NodeType.REPORT): if not (cfg.get("model") or spec.defaults.get("model")): # 点名了接入就用那个接入的默认模型,照实说是哪个:检查器、问题面板里只写「默认模型」,人会以为是第一个 diff --git a/backend/app/engine/validation.py b/backend/app/engine/validation.py new file mode 100644 index 00000000..ee109940 --- /dev/null +++ b/backend/app/engine/validation.py @@ -0,0 +1,65 @@ +"""执行状态与质量结论分开;试运行、正式出具和评测共用质量判定。""" +from __future__ import annotations + +from typing import Any + + +def metric_quality(metric: dict[str, Any]) -> dict[str, Any]: + import math + + checks: list[dict[str, str]] = [] + + def add(code: str, status: str, detail: str) -> None: + checks.append({"code": code, "status": status, "detail": detail}) + + if metric.get("status") != "ok" or metric.get("value") is None: + add("value", "fail", "指标没有可用值") + value = metric.get("value") + if isinstance(value, (int, float)) and not math.isfinite(value): + add("value", "fail", "指标值不是有限数值") + for key, reason in (("incomplete", "查询结果不完整"), ("no_data", "查询区间没有数据"), + ("sql_check_failed", "来源 SQL 未通过检查")): + if metric.get(key): + detail_key = "sql_check_reason" if key == "sql_check_failed" else f"{key}_reason" + add(key, "fail", str(metric.get(detail_key) or reason)) + if metric.get("recompute_ok") is False: + add("recompute", "fail", "指标复算不一致") + external = metric.get("external") + if isinstance(external, dict) and external.get("stale"): + add("external_stale", "fail", "外部常量已过期") + comparable = metric.get("comparability") + if isinstance(comparable, dict) and comparable.get("verdict") != "comparable": + add("comparability", "fail" if comparable.get("verdict") == "incomparable" else "unknown", + str(comparable.get("phrase") or "两期可比性尚未验证")) + if metric.get("comparability_skipped"): + add("comparability", "unknown", "两期可比性检查未完成") + for finding in metric.get("contract_findings") or []: + add("metric_contract", "fail", str(finding)) + if metric.get("semantic_unverified"): + add("semantic_definition", "unknown", "业务描述与取数条件的一致性仍需审阅") + status = "fail" if any(c["status"] == "fail" for c in checks) else "unknown" if checks else "pass" + return {"status": status, "checks": checks} + + +def validation_report(metrics: list[dict[str, Any]], *, execution: str, trusted: bool) -> dict[str, Any]: + checks: list[dict[str, Any]] = [] + if execution != "succeeded": + checks.append({"code": "execution", "status": "fail" if execution == "failed" else "unknown", + "detail": "执行未成功完成"}) + if not trusted: + checks.append({"code": "evidence", "status": "unknown", "detail": "运行证据尚未封存或完整性核对未通过"}) + if not metrics: + checks.append({"code": "metrics", "status": "unknown", "detail": "没有可验证的指标结果"}) + for metric in metrics: + quality = metric.get("quality") or metric_quality(metric) + checks.extend({**c, "metric_id": metric.get("id"), "name": metric.get("name")} for c in quality["checks"]) + status = "fail" if any(c["status"] == "fail" for c in checks) else "unknown" if checks else "pass" + return {"schema_version": 1, "execution": execution, "status": status, "checks": checks} + + +def additional_metric_gaps(metrics: list[dict[str, Any]]) -> list[str]: + """既有出具检查之外的语义与可比性缺口。""" + codes = {"metric_contract", "semantic_definition", "comparability", "recompute"} + return [f"指标「{m.get('name') or m.get('id')}」:{c['detail']}" for m in metrics + for c in metric_quality(m)["checks"] if c["code"] in codes + and not (c["code"] == "comparability" and isinstance(m.get("comparability"), dict))] diff --git a/backend/app/eval/cases.py b/backend/app/eval/cases.py index 9753a577..4973ef25 100644 --- a/backend/app/eval/cases.py +++ b/backend/app/eval/cases.py @@ -54,7 +54,7 @@ GOLD_GUARD = "eval_gold_guard" #: 口径题指的卡不能当口径题(422):不存在、不是口径卡、不能复用、取数节点引用了入口字段以外的值 CALIBER_NOT_REUSABLE = "eval_caliber_not_reusable" -EXPECTATION_KINDS = ("scalar", "table", "set", "none", "ask", "text", "contribution", "verdict") +EXPECTATION_KINDS = ("scalar", "table", "set", "none", "ask", "text", "contribution", "verdict", "investigation") #: 要期望答案来源的期望:数都由 SQL / 口径算,分析的判定由算子在期望 SQL 的结果上算 NEEDS_GOLD = ("scalar", "table", "set", "none", "contribution", "verdict") #: 不计入准确率的(评测-D4:文字要点由裁判判,单列) @@ -74,6 +74,7 @@ _EXPECTATION_KEYS: dict[str, tuple[str, ...]] = { "text": ("points",), "contribution": ("op", "mode", "params", "top_n"), "verdict": ("op", "mode", "params"), + "investigation": ("minimum_depth", "max_queries", "status", "stop_reason"), } _COMMON_KEYS = ("id", "kind", "required", "label") #: 期望答案来源的键(客户端能写的);from_run 只能由服务端建 @@ -236,7 +237,7 @@ def _expectation(raw: Any, i: int, *, gold_kind: str | None, taken: set[str]) -> if kind in NEEDS_GOLD and gold_kind is None: raise CaseInvalid(f"{where}要答出数(或答「没有数据」),需要期望答案的来源:写一条期望 SQL、选一个口径," "或从运行里认定一条查询", "gold") - if gold_kind == "caliber" and kind not in ("scalar", "ask", "text"): + if gold_kind == "caliber" and kind not in ("scalar", "ask", "text", "investigation"): raise CaseInvalid(f"{where}({kind})不能用口径题算:口径卡给的是一个个指标,只配「一个数」的期望", f"{path}.kind") if kind == "scalar": @@ -286,6 +287,22 @@ def _expectation(raw: Any, i: int, *, gold_kind: str | None, taken: set[str]) -> if len(points) > POINTS_MAX or any(len(p.strip()) > POINT_MAX for p in points): raise CaseInvalid(f"{where}最多 {POINTS_MAX} 条要点,每条不超过 {POINT_MAX} 个字符", f"{path}.points") out["points"] = [p.strip() for p in points] + elif kind == "investigation": + status = raw.get("status", "complete") + if status not in ("complete", "partial"): + raise CaseInvalid("调查期望 status 只能是 complete 或 partial", f"{path}.status") + out["status"] = status + for key, low, high in (("minimum_depth", 0, 5), ("max_queries", 1, 1000)): + if key in raw: + value = raw[key] + if isinstance(value, bool) or not isinstance(value, int) or not low <= value <= high: + raise CaseInvalid(f"{key} 需要是 {low} 到 {high} 的整数", f"{path}.{key}") + out[key] = value + if raw.get("stop_reason") is not None: + reason = raw["stop_reason"] + if not isinstance(reason, str) or not reason.strip() or len(reason) > 80: + raise CaseInvalid("停止原因需要是简短的机器码", f"{path}.stop_reason") + out["stop_reason"] = reason elif kind in ("contribution", "verdict"): out.update(_analysis_expectation(raw, kind, where, path)) return out diff --git a/backend/app/eval/runner.py b/backend/app/eval/runner.py index 6c3bf88d..04380092 100644 --- a/backend/app/eval/runner.py +++ b/backend/app/eval/runner.py @@ -100,7 +100,7 @@ def leader_of(run: Any) -> str: def prompt_fingerprint() -> str: """助手提示词的指纹:节点手册、输出协议、问数据的请求包法、重来的两种措辞、复核的规则;平台的分析方法(分析工具的 guide:id 带版本,连同全文)、写作者的分析规则(写作目录里分析结论那一段、判断词的写法、重写时补的那句)和说法表的 - 版本;自查判问题意图的词表(版本,改词表要升)和「分析类问题、查库的 Agent 没绑分析工具」时交回模型的那一句。 + 版本;自查判问题意图的词表、默认调查策略及其版本,和「分析类问题、查库的 Agent 没绑分析工具」时交回模型的那一句。 改了任何一段,和上一次比时就标「提示词变了」。""" from app.analysis.intent import INTENT_VERSION from app.api import copilot @@ -108,6 +108,7 @@ def prompt_fingerprint() -> str: from app.chat import turn from app.core.artifact_store import canonical_json, content_hash from app.engine import analysis_claims, evidence, review + from app.engine.investigation import INVESTIGATION_VERSION, InvestigationPolicy from app.tools.builtin.analysis import METHOD_GUIDE parts = [copilot.NODE_REFERENCE, copilot._STREAM_PROTOCOL, @@ -117,7 +118,7 @@ def prompt_fingerprint() -> str: METHOD_GUIDE.id, METHOD_GUIDE.text, evidence.ANALYSIS_PROMPT, evidence.ANALYSIS_SEE_ONLY_PROMPT, analysis_claims.ANALYSIS_RULES_PROMPT, analysis_claims.ANALYSIS_NONE_PROMPT, analysis_claims.ANALYSIS_REPAIR_PROMPT, analysis_claims.LEXICON_VERSION, - INTENT_VERSION, analysis_gaps_fix()] + INTENT_VERSION, analysis_gaps_fix(), INVESTIGATION_VERSION, InvestigationPolicy().model_dump()] return content_hash(canonical_json(parts))[:16] diff --git a/backend/app/eval/score.py b/backend/app/eval/score.py index 7fb78a2e..894c31a8 100644 --- a/backend/app/eval/score.py +++ b/backend/app/eval/score.py @@ -186,12 +186,13 @@ class Answer: problem: str | None = None analyses: list[dict[str, Any]] = field(default_factory=list) sentences: list[dict[str, Any]] = field(default_factory=list) + investigations: list[dict[str, Any]] = field(default_factory=list) def to_dict(self) -> dict[str, Any]: return {"run_id": self.run_id, "text": self.text, "values": self.values, "bare": self.bare, "queries": self.queries, "cited": self.cited, "stats": self.stats, "tier": self.tier, "report": self.report, "problem": self.problem, "analyses": self.analyses, - "sentences": self.sentences} + "sentences": self.sentences, "investigations": self.investigations} @classmethod def from_dict(cls, raw: Mapping[str, Any]) -> Answer: @@ -199,7 +200,7 @@ class Answer: bare=list(raw.get("bare") or []), queries=list(raw.get("queries") or []), cited=list(raw.get("cited") or []), stats=dict(raw.get("stats") or {}), tier=raw.get("tier"), report=raw.get("report"), problem=raw.get("problem"), analyses=list(raw.get("analyses") or []), - sentences=list(raw.get("sentences") or [])) + sentences=list(raw.get("sentences") or []), investigations=list(raw.get("investigations") or [])) def _output_text(output: Any) -> str: @@ -291,6 +292,11 @@ async def read_answer(run_id: str | None, *, reply: str | None = None) -> Answer return Answer(run_id=run_id, problem="作答的运行封存核对不一致,答案不能当证据") cache: dict[str, Any] = {} answer = Answer(run_id=run_id) + for node_id in sealed.nodes_of("agent", nested=True): + payload = sealed.payload_of([node_id]) or {} + investigation = payload.get("investigation") + if isinstance(investigation, dict) and investigation.get("active"): + answer.investigations.append({**investigation, "node_id": node_id}) for artifact, info in sealed.queries.items(): snap = _snapshot(artifact, cache) if snap is None: @@ -615,6 +621,26 @@ def _expectation_scores(expectations: Sequence[Mapping[str, Any]], values: Mappi out.append({**base, "outcome": "traced" if ok else "wrong", **({} if ok else {"reason": "期望反问(问法含糊),助手却没有反问"})}) continue + if kind == "investigation": + findings = answer.investigations + problems = [] + if answer.problem: + out.append({**base, "outcome": "ungradable", "reason": answer.problem}) + continue + if not findings: + problems.append("没有封存的调查过程记录") + for finding in findings: + if finding.get("status") != exp.get("status", "complete"): + problems.append("调查完成状态不符合期望") + if int(finding.get("depth") or 0) < int(exp.get("minimum_depth") or 0): + problems.append("未达到要求的下钻深度") + if exp.get("stop_reason") and finding.get("stop_reason") != exp["stop_reason"]: + problems.append("停止原因不符合期望") + if exp.get("max_queries") is not None and sum(int(f.get("query_count") or 0) for f in findings) > exp["max_queries"]: + problems.append("查询次数超出预算") + out.append({**base, "outcome": "wrong" if problems else "traced", + "reason": ";".join(dict.fromkeys(problems)) or None, "investigations": findings}) + continue gold = values.get(exp["id"]) if gold is None: out.append({**base, "outcome": "ungradable", "reason": "期望没有算出这一项"}) diff --git a/backend/app/ledger/authoring.py b/backend/app/ledger/authoring.py new file mode 100644 index 00000000..dc1e75c9 --- /dev/null +++ b/backend/app/ledger/authoring.py @@ -0,0 +1,504 @@ +"""从文档起草口径的应用服务;HTTP 层只负责权限、参数和任务启动。""" +from __future__ import annotations +import asyncio +import copy +import logging +import re +from typing import Any +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession +from app.db.base import SessionLocal +from app.db.models import CaliberDraft, Chunk, DataSource, Document, Workflow + +logger = logging.getLogger(__name__) +MAX_ROUNDS = 4 +MAX_METRICS = 60 +_QUESTION_KEY = 30 +_CLAUSE = ",,。;;::!!??" +_PUNCT = set(" \t\r\n,。;:、!?,.!?;:'\"()()[]【】《》<>-_/\\|`~") + + +def _question_count(payload: dict[str, Any]) -> int: + """未决问题数:跨指标的 open_questions + 各指标自己的。status 就按它算,PATCH 之后重算也用它。""" + def count(items: Any) -> int: + return len([q for q in items if isinstance(q, str) and q.strip()]) if isinstance(items, list) else 0 + + total = count(payload.get("open_questions")) + metrics = payload.get("metrics") + for metric in metrics if isinstance(metrics, list) else []: + if isinstance(metric, dict): + total += count(metric.get("open_questions")) + return total + + +def _status_for(payload: dict[str, Any]) -> str: + """还有问题 → questions_open;没有问题且有图 → ready;没有图的老草稿 → path_pending。 + + questions_open 现在也有图和数,只是问题还没清完,不阻断试跑;path_pending 只留给这次改版之前起草、还没有取数路径的草稿。 + """ + if _question_count(payload): + return "questions_open" + if payload.get("workflow_id") and payload.get("sql"): + return "ready" + return "path_pending" + + +def _source_tables(source: DataSource) -> dict[str, Any]: + cache = source.schema_cache if isinstance(source.schema_cache, dict) else {} + tables = cache.get("tables") + return tables if isinstance(tables, dict) else {} + + +def _focus_tables(tables: list[str], text: str, limit: int = 8) -> list[str]: + """文档里点到的表优先展开:按它在原文里第一次出现的位置排,没点到的按目录顺序补几张。 + + 目录动辄十万字符,全塞给模型会把文档和别的表挤掉。先给全量列名(见 _columns_digest),再把文档点到的 + 那几张表展开注释和目录——写 SQL 需要的列名一个不少,细节只给用得上的。 + """ + lowered = (text or "").lower() + hit: list[tuple[int, str]] = [] + for table in tables: + name = str(table).lower() + short = name.split(".")[-1] + at = lowered.find(name) + if at < 0: + at = lowered.find(short) + if at >= 0: + hit.append((at, table)) + hit.sort(key=lambda pair: pair[0]) + picked = [table for _, table in hit][:limit] + for table in tables: + if len(picked) >= min(limit, 5): + break + if table not in picked: + picked.append(table) + return picked + + +def _columns_digest(source: DataSource, tables: list[str]) -> str: + """每张表的列名清单(只给名字):要写 SQL,先得知道有哪些列。注释和目录留给下面展开的那几张。""" + from app.data import introspect + + lines = ["各表的列(只列名字;下面展开的表另有类型、注释和数据目录):"] + for table in tables: + meta = introspect.find_table(source, table) + columns = [str(c.get("name")) for c in (meta or {}).get("columns") or [] if c.get("name")] + if columns: + lines.append(f"{table}: {', '.join(columns)}") + return "\n".join(lines) + + +async def _schema_material(session: AsyncSession, source: DataSource, focus_text: str = "" + ) -> tuple[str, set[str]]: + """这个数据源给模型看的表结构 + 数据目录文本,以及 SQL 允许出现的表名集合。 + + 文本按「概览 → 全量表索引 → 全量列名 → 文档点到的表展开」的顺序排:预算不够时先丢的是最后那一段, + 列名不会丢。表名一律照目录原文,模型照着抄。 + """ + from app.data import catalog, schema_view + + cache = source.schema_cache if isinstance(source.schema_cache, dict) else {} + tables = _source_tables(source) + if not tables: + return "", set() + entries = await catalog.read_catalog(session, source.id) + notes = {table: entry.notes for table, entry in entries.items()} + lines = [schema_view.overview_line(source, catalog_tables=sum( + 1 for entry in entries.values() if catalog.visible_notes(entry.notes)))] + lines.append(schema_view.table_index(source, notes)) + lines.append(_columns_digest(source, tables)) + for table in _focus_tables(tables, focus_text): + lines.append(schema_view.table_detail(source, table, catalog_notes=notes)) + schema = cache.get("schema") + allowed: set[str] = set() + for table in tables: + allowed.add(str(table)) + if isinstance(schema, str) and schema: + allowed.add(f"{schema}.{table}") + return "\n\n".join(line for line in lines if line), allowed + + +async def _published_metrics(session: AsyncSession) -> list[dict[str, Any]]: + """已发布口径里的每一项指标,给 docdraft.alignment 用:{workflow_id, version, node_id, name, synonyms, unit, + definition_text}。直接复用 descriptor.list_calibers 的认法(只看最新已发布且未退役版本的源头口径卡)。""" + from app.ledger.descriptor import list_calibers + + out: list[dict[str, Any]] = [] + for described in await list_calibers(session): + for metric in described.get("metrics") or []: + if not isinstance(metric, dict): + continue + definition = metric.get("definition") + text = definition.get("text") if isinstance(definition, dict) else None + name = metric.get("name") or metric.get("id") + if not name: + continue + out.append({"workflow_id": described["workflow_id"], "version": described["version"], + "node_id": described["card"]["node_id"], "name": str(name), + "synonyms": metric.get("synonyms") if isinstance(metric.get("synonyms"), list) else [], + "unit": metric.get("unit") or "", "definition_text": text or ""}) + return out + + +def _merged_fields(blocks: list[dict[str, Any]]) -> list[dict[str, Any]]: + """各组声明的入口字段合成一份,给界面看:它和建图用的是同一套合并(draftplan.merge_entry_fields)。 + + 早先这里是「先出现的为准」的另一份实现,建图那边还会因为同名冲突抛错——两份逻辑说不到一起。现在只有一处。 + """ + from app.ledger import draftplan + + fields, _ = draftplan.merge_entry_fields(blocks) + return fields + + +def _entry_notes(blocks: list[dict[str, Any]]) -> list[str]: + """入口字段在各组里声明不一致的地方(必填取并集、类型取更宽),摆给起草的人看。""" + from app.ledger import draftplan + + return draftplan.merge_entry_fields(blocks)[1] + + +def _question_key(text: str) -> str: + """一句话的「问的是什么」:取第一个分句(问的是同一件事,后面常跟着各轮的补充说明)。""" + head = text + for mark in _CLAUSE: + at = head.find(mark) + if at > 0: + head = head[:at] + return "".join(ch for ch in head if ch not in _PUNCT)[:_QUESTION_KEY] + + +def _merge_questions(kept: list[str], incoming: list[str]) -> list[str]: + """跨轮合并未决问题:同一件事每轮都会重新问一遍(68 条里有一半是重复),按归一化后的前 24 个字去重, + 保留先出现的那一句。措辞不同但意思一样的仍会留下——机器分不出来,交给人决定要不要回。""" + seen = {_question_key(q) for q in kept} + out = list(kept) + for item in incoming: + text = item.strip() if isinstance(item, str) else "" + if not text: + continue + key = _question_key(text) + if key in seen: + continue + seen.add(key) + out.append(text) + return out + + +def _can_repair(group: Any) -> bool: + """这一组有指标可救时才再调一次模型:没有指标就没有可保留的东西,重写等于重新起草。""" + metrics = group.get("metrics") if isinstance(group, dict) else None + return isinstance(metrics, list) and any(isinstance(metric, dict) for metric in metrics) + + +def _fresh_metrics(metrics: list[dict[str, Any]], covered: list[str]) -> list[dict[str, Any]]: + """这一轮里还没起草过的指标,按名字认;同一轮重复交同名指标的只留第一条。 + + 提示词里已经写了「这些起草过、别重复」,但模型偶尔照抄一遍上一轮的指标。照单全收会出现同名两条指标,口径卡上 + 读的是哪条也说不清,所以在这里挡掉。 + """ + seen = set(covered) + fresh: list[dict[str, Any]] = [] + for metric in metrics: + name = str(metric.get("name") or "").strip() + if name: + if name in seen: + continue + seen.add(name) + fresh.append(metric) + return fresh + + +def _draft_payload(*, entries: list[dict[str, Any]], blocks: list[dict[str, Any]], problems: list[dict[str, Any]], + questions: list[str], uncovered: list[str], progress: dict[str, int], + doc_truncated: bool, catalog_truncated: bool, model_id: str, + salvaged: bool) -> dict[str, Any]: + """草稿 payload 的形状:整份指标、各组 SQL、进度。起草中途也按它写回,页面靠 progress 显示第几轮、覆盖了几条。 + + blocks 是组数(不是内容);sql 把各组的取数拼在一起(组间空一行),人看的是「这次一共要跑哪几条」。groups 逐组给出 + sql、入口字段和这一组的指标 id(与 metrics 里出现的顺序一致):页面按组画「这条 SQL 出哪几个指标」,不必再从拼接 + 出来的 sql 里猜边界。truncated 仍是 doc_truncated 与 catalog_truncated 的「或」,老前端只认这一个键。 + + 所有路径(每轮落库、失败、出图)都经过这里,groups 只从同一份 blocks 生成,不另存一份状态。 + """ + return { + "documents": entries, + "metrics": [m for block in blocks for m in block["metrics"]], + "entry_fields": _merged_fields(blocks), + "entry_notes": _entry_notes(blocks), + "sql": "\n\n".join(str(block["sql"]) for block in blocks), + "groups": [{"sql": str(block.get("sql") or ""), + "entry_fields": [dict(field) for field in block.get("entry_fields") or [] + if isinstance(field, dict)], + "metric_ids": [str(metric["id"]) for metric in block.get("metrics") or [] + if isinstance(metric, dict) and metric.get("id")]} + for block in blocks], + "version": 1, + "blocks": len(blocks), + "progress": progress, + "trial": {"run_id": "", "status": "running", "error": ""}, + "dropped": problems, + "truncated": doc_truncated or catalog_truncated, + "doc_truncated": doc_truncated, + "catalog_truncated": catalog_truncated, + "model_id": model_id, + "open_questions": questions, + "uncovered": uncovered, + "salvaged": salvaged, + } + + +async def extract(draft_id: str, *, run_trial: Any, save_trial: Any, trial_binding: Any) -> None: + """后台起草:读文档的段落和数据目录,分组反复调模型直到覆盖完,收成一张多取数的草稿图、建工作流、再跑一次试跑。 + + 用新的会话:请求那一份在响应发出后就关了,后台接着用它写库是错的。没有数据源或没有表结构时起草必须失败——SQL 只能 + 对照真实目录写,没有目录就不让模型猜。个别指标不合格只丢那一条、写进 dropped / open_questions;SQL 本身不合格才整份 + 失败(图跑不了,留着也没有意义)。 + + 每轮可交多组能共用 SQL 的指标,按 uncovered 续写,直到覆盖完、没有新指标或到轮数/条数上限。每轮先提交进度和 + 已校验分组的检查点,再等待模型。材料指纹相同的重试可接续检查点;并行修复各用独立数据库会话。 + """ + from app.core.artifact_store import graph_hash + from app.db.models import WorkflowVersion + from app.ledger import docdraft, draftplan + + async with SessionLocal() as session: + draft = await session.get(CaliberDraft, draft_id) + if draft is None or draft.status != "drafting": + return + origin = draft.origin if isinstance(draft.origin, dict) else {} + wanted = [str(d) for d in origin.get("documents") or []] + docs: list[Document] = [] + for doc_id in wanted: + doc = await session.get(Document, doc_id) + if doc is not None: + docs.append(doc) + chunks: dict[str, dict[str, Any]] = {} + entries: list[dict[str, Any]] = [] + for doc in docs: + rows = (await session.execute(select(Chunk).where(Chunk.document_id == doc.id) + .order_by(Chunk.ordinal, Chunk.id))).scalars() + chunk_ids: list[str] = [] + for chunk in rows: + chunks[chunk.id] = {"ordinal": chunk.ordinal, "content": chunk.content or "", + "document_id": doc.id} + chunk_ids.append(chunk.id) + entries.append({"document_id": doc.id, "title": doc.title or doc.source or doc.id, + "chunk_ids": chunk_ids}) + + source = await session.get(DataSource, draft.datasource_id) if draft.datasource_id else None + if source is None: + draft.error = "没有数据源:生成取数 SQL 必须对照数据目录里的表和列" + draft.status = "failed" + await session.commit() + return + focus_text = "\n".join(str(c.get("content") or "") for c in chunks.values()) + tables_text, allowed_tables = await _schema_material(session, source, focus_text) + if not allowed_tables: + draft.error = "这个数据源还没有表结构:先探查表结构,再起草口径" + draft.status = "failed" + await session.commit() + return + datasource = {"name": source.name, "type": source.kind, "description": source.description} + + guidance = str(origin.get("guidance") or "") + fixed_filters = str(origin.get("fixed_filters") or "") + alignment: dict[str, list[Any]] = {"new": [], "duplicate": [], "conflict": []} + # 每组一组指标:{sql, entry_fields, metrics},循环结束时拼成一张图 + blocks: list[dict[str, Any]] = [] + problems: list[dict[str, Any]] = [] + covered: list[str] = [] + questions: list[str] = [] + uncovered: list[str] = [] + progress: dict[str, int] = {} + diagnostics = "" + doc_truncated = False + catalog_truncated = False + salvaged = False + model_id = "" + + from app.core.artifact_store import canonical_json, content_hash + + material_hash = content_hash(canonical_json({"documents": entries, "chunks": chunks, + "datasource": datasource, "catalog": tables_text, "guidance": guidance, "filters": fixed_filters})) + previous = draft.payload if isinstance(draft.payload, dict) else {} + if previous.get("material_hash") == material_hash: + blocks = copy.deepcopy((previous.get("checkpoint") or {}).get("groups") or []) + covered = [str(m["name"]) for block in blocks for m in block.get("metrics") or [] if m.get("name")] + questions = list(previous.get("open_questions") or []) + problems = list(previous.get("dropped") or []) + + def state() -> dict[str, Any]: + return {**_draft_payload(entries=entries, blocks=blocks, problems=problems, questions=questions, + uncovered=uncovered, progress=progress, doc_truncated=doc_truncated, + catalog_truncated=catalog_truncated, model_id=model_id, salvaged=salvaged), + "material_hash": material_hash, "checkpoint": {"groups": copy.deepcopy(blocks)}} + + try: + for rnd in range(1, MAX_ROUNDS + 1): + await session.commit() + async with SessionLocal() as planning: + result = await draftplan.plan_groups(planning, docs=entries, chunks=chunks, guidance=guidance, + datasource=datasource, tables_text=tables_text, covered=covered, + fixed=fixed_filters) + new_count = 0 + failed: list[tuple[dict[str, Any], list[dict[str, Any]]]] = [] + for raw_group in result.get("groups") or []: + plan, round_problems = draftplan.normalize_plan(raw_group, chunks, + allowed_tables=allowed_tables) + fresh = _fresh_metrics(plan["metrics"], covered) + if plan["sql"] and fresh: + blocks.append({"sql": plan["sql"], "entry_fields": plan["entry_fields"], "metrics": fresh}) + covered += [m["name"] for m in fresh if m.get("name")] + new_count += len(fresh) + problems += round_problems + elif _can_repair(raw_group): + failed.append((raw_group, round_problems)) + else: + problems += round_problems + questions = _merge_questions(questions, plan["open_questions"]) + + if failed: + # 一组修不好不该拖垮别的组:每组独立重写一次,最多三组同时进行 + semaphore = asyncio.Semaphore(3) + + async def repair(raw: dict[str, Any], issues: list[dict[str, Any]]): + async with semaphore: + try: + async with SessionLocal() as repairing: + fixed = await draftplan.repair_group( + repairing, group=raw, problems=issues, docs=entries, chunks=chunks, + guidance=guidance, datasource=datasource, tables_text=tables_text, + fixed=fixed_filters) + except docdraft.DraftUnavailable as e: + return None, [{"name": "重写这一组", "reason": str(e) or "AI 起草不可用"}] + except Exception as e: # noqa: BLE001 - 单组重写出错只记这一组 + return None, [{"name": "重写这一组", "reason": f"重写出错:{e}"}] + if not fixed: + return None, [] + return draftplan.normalize_plan(fixed, chunks, allowed_tables=allowed_tables) + + repaired = await asyncio.gather(*(repair(raw, issues) for raw, issues in failed)) + for (_raw, issues), item in zip(failed, repaired): + plan, repair_problems = item + fresh = _fresh_metrics(plan["metrics"], covered) if plan is not None else [] + if plan is not None and plan["sql"] and fresh: + blocks.append({"sql": plan["sql"], "entry_fields": plan["entry_fields"], + "metrics": fresh}) + covered += [m["name"] for m in fresh if m.get("name")] + new_count += len(fresh) + problems += repair_problems + else: + # 重写没成:原来这一组的问题照实留着,再附上重写这轮的说明 + problems += issues + repair_problems + if plan is not None: + questions = _merge_questions(questions, plan["open_questions"]) + questions = _merge_questions(questions, list(result.get("open_questions") or [])) + uncovered = list(result.get("uncovered") or []) + diagnostics = result.get("diagnostics") or diagnostics + doc_truncated = doc_truncated or bool(result.get("doc_truncated")) + catalog_truncated = catalog_truncated or bool(result.get("catalog_truncated")) + salvaged = salvaged or bool(result.get("salvaged")) + model_id = result.get("model_id") or model_id + progress = {"round": rnd, "covered": len(covered), "uncovered": len(uncovered)} + # 每轮先落一次库:这一轮要等一次模型调用,页面这时就能显示「第几轮、起草了几条、还剩几条」 + draft.payload = state() + await session.commit() + if not new_count or not uncovered or len(covered) >= MAX_METRICS: + break + + # 多组从各自 m1 开始:先把 id 在全图里改成唯一,图、payload、groups 和试跑值才指同一件事 + draftplan.unique_metric_ids(blocks) + + if not blocks: + error = _problems_text(problems) or "模型没有给出可用的取数 SQL 和指标" + if diagnostics: + error = f"{error}({diagnostics})" + if questions: + # 模型把缺什么说清楚了(目录里没有这些列、业务上还没定):这是一份要补信息的草稿,不是失败 + draft.payload = {**state(), "trial": {"run_id": "", "status": "", "error": ""}} + draft.model_id = model_id or None + draft.error = "" + draft.status = "questions_open" + await session.commit() + return + draft.payload = {**state(), "trial": {"run_id": "", "status": "failed", "error": error}} + draft.model_id = model_id or None + draft.error = error + draft.status = "failed" + await session.commit() + return + + try: + graph = draftplan.build_graph(blocks, name=draft.title, question=guidance or draft.title, + datasource=source.name) + except ValueError as e: + # build_graph 现在合并入口字段不再抛错;这条留着兜底:真出现合不了的图,照实记为失败,别留半成品 + draft.payload = {**state(), "dropped": [*problems, {"name": "入口字段", "reason": str(e)}], + "trial": {"run_id": "", "status": "failed", "error": str(e)}} + draft.model_id = model_id or None + draft.error = str(e) + draft.status = "failed" + await session.commit() + return + + # 对齐要扫所有已发布口径:某一份读不出来不该让整份草稿失败,照实不做对齐(alignment 三个空列表) + try: + existing = await _published_metrics(session) + except Exception: # noqa: BLE001 - 对齐是辅助信息,不是草稿本身 + logger.exception("读已发布口径失败,草稿 %s 不做对齐", draft_id) + existing = [] + metrics = [m for block in blocks for m in block["metrics"]] + alignment = docdraft.alignment(metrics, existing) + + asked = list(dict.fromkeys(questions + [ + f"{p['name']}:{p['reason']}" for p in problems + if p.get("name") not in ("sql", "metrics")])) + workflow = Workflow( + name=(draft.title.strip() or "文档口径草稿")[:200], + description=(f"从业务文档起草的口径:{len(metrics)} 个指标、{len(blocks)} 组取数,取数 SQL 由模型对照" + f"数据目录生成,后台自动只读试运行;人工审改后按受管级别发布"), + graph=graph, status="draft", tags=["doc_draft"], + origin={"kind": "doc_draft", "draft_id": draft.id, "datasource_id": source.id, + "documents": [doc.id for doc in docs], "guidance": guidance}) + session.add(workflow) + await session.flush() + session.add(WorkflowVersion(workflow_id=workflow.id, version=1, graph=graph, + graph_hash=graph_hash(graph), note="从业务文档分组生成")) + payload = { + **state(), "workflow_id": workflow.id, "open_questions": asked, + "alignment": alignment, + } + except docdraft.DraftUnavailable as e: + draft.error = str(e) or "AI 起草不可用" + draft.status = "failed" + await session.commit() + return + except Exception as e: # noqa: BLE001 - 后台出错要写回这一行,不然界面永远停在「起草中」 + logger.exception("从文档起草口径出错(草稿 %s)", draft_id) + draft.error = f"起草出错:{e}" + draft.status = "failed" + await session.commit() + return + draft.payload = payload + draft.workflow_id = workflow.id + draft.model_id = model_id or None + draft.error = None + draft.status = _status_for(payload) + await session.commit() + values = {field["name"]: field.get("default") for field in _merged_fields(blocks) + if field.get("default") not in (None, "")} + + try: + trial = await run_trial(draft_id=draft_id, workflow_id=workflow.id, graph=graph, values=values, + started_by=draft.created_by, entry_fields=_merged_fields(blocks)) + except Exception as e: # noqa: BLE001 - 试运行出错也要写进 payload,不能让界面停在「试运行中」 + logger.exception("草稿 %s 的试运行出错", draft_id) + trial = {**trial_binding(1, graph, values), "run_id": "", "status": "failed", "error": f"试运行出错:{e}"} + await save_trial(draft_id, trial) + + +def _problems_text(problems: list[dict[str, Any]]) -> str: + """把校验问题拼成一句给人看的原因(接口的 error 只有一个字段)。""" + return ";".join(f"{p.get('name') or '问题'}:{p.get('reason') or ''}".strip(":") + for p in problems if p.get("reason"))[:2000] diff --git a/backend/app/ledger/descriptor.py b/backend/app/ledger/descriptor.py index d4088085..b09ee93a 100644 --- a/backend/app/ledger/descriptor.py +++ b/backend/app/ledger/descriptor.py @@ -29,7 +29,7 @@ from app.tools.datasource import QUERY_PREFIX #: 指标只写口径卡配置里真有的这几个键 METRIC_KEYS = ("id", "name", "unit", "decimals", "format", "kind", "definition", "params", "external", "rule", - "assessment", "synonyms") + "assessment", "synonyms", "grain", "time_field", "numerator", "denominator", "dimensions", "filters", "contract") async def list_calibers(session: AsyncSession, *, workflow_id: str | None = None) -> list[dict[str, Any]]: @@ -88,6 +88,8 @@ async def _describe(session: AsyncSession, workflow: Workflow, row: Any, node_id if problem := template_problem(str(nid), node.get("config") or {}): reason = problem break + from app.data.caliber_identity import semantic_hash + key = (workflow.id, int(row.version)) if key not in side_cache: side_cache[key] = await graph_side_effect(session, graph, level=level or None) @@ -106,6 +108,7 @@ async def _describe(session: AsyncSession, workflow: Workflow, row: Any, node_id "version": int(row.version), "level": level, "graph_hash": row.graph_hash, + "semantic_hash": semantic_hash(graph, node_id), "card": {"node_id": node_id, "title": card["title"], "caliber": cfg.get("caliber"), "caliber_version": cfg.get("caliber_version")}, "metrics": [{k: m[k] for k in METRIC_KEYS if k in m} diff --git a/backend/app/ledger/docdraft.py b/backend/app/ledger/docdraft.py index 711d150d..024b2936 100644 --- a/backend/app/ledger/docdraft.py +++ b/backend/app/ledger/docdraft.py @@ -21,11 +21,25 @@ import re from collections.abc import Iterable, Mapping from typing import Any -#: 草稿的状态。ready 是主路径的常态:图已生成(含取数 SQL)、没有未决问题、可试跑可签字发布;questions_open 有图也有数, -#: 只是问题还没清完(不阻断试跑);path_pending 只留给这次改版之前起草、还没有取数路径的老草稿;attached 是经辅助入口接上 -#: 一次真实运行之后的。failed 是起草这一步没跑成(没有可用模型、模型回复读不懂、SQL 不合格),和 rejected(人判它不是口径) -#: 不是一回事。 +#: 草稿的状态(机器判别的落点,接口按它返回)。界面上只看得到四种阶段(STAGES):草稿在写、待审、已丢弃、失败。 +#: ready 是主路径的常态:图已生成(含取数 SQL)、没有未决问题、可试跑可签字发布;questions_open 有图也有数,只是问题 +#: 还没清完(不阻断试跑);path_pending 留给这次改版之前起草、还没有取数路径的老草稿;attached 是经辅助入口接上一次 +#: 真实运行之后的。这些在界面上都归到「待审」。failed 是起草这一步没跑成(没有可用模型、模型回复读不懂、SQL 不合格), +#: rejected 是人判它不是口径——两者不是一回事。 STATUSES = ("drafting", "ready", "questions_open", "path_pending", "attached", "rejected", "failed") +#: 界面上的四种阶段。状态仍按 STATUSES 存(接口兼容、画布和试跑按细状态走),页面按阶段画、按阶段筛。 +STAGES = ("drafting", "review", "discarded", "failed") + + +def stage_of(status: str) -> str: + """细状态 → 界面阶段:ready / questions_open / path_pending / attached 都是「待审」,rejected 是「已丢弃」。""" + if status == "drafting": + return "drafting" + if status == "rejected": + return "discarded" + if status == "failed": + return "failed" + return "review" #: 一次发给模型的总字数上限(按 chunk 边界截断,见 extract) MAX_INPUT_CHARS = 40000 @@ -585,6 +599,6 @@ salvage_metrics = _salvage_metrics __all__ = [ "DEFINITION_FIELDS", "DraftUnavailable", "FORBIDDEN_FIELDS", "MAX_INPUT_CHARS", "METRIC_FIELDS", - "MIN_QUOTE_CHARS", "STATUSES", "alignment", "extract", "merge_definition", "normalize_metrics", - "parse_reply", "prompt", "salvage_metrics", "verify_quote", + "MIN_QUOTE_CHARS", "STAGES", "STATUSES", "alignment", "extract", "merge_definition", "normalize_metrics", + "parse_reply", "prompt", "salvage_metrics", "stage_of", "verify_quote", ] diff --git a/backend/app/ledger/draftplan.py b/backend/app/ledger/draftplan.py index 6d9d628c..4a123b32 100644 --- a/backend/app/ledger/draftplan.py +++ b/backend/app/ledger/draftplan.py @@ -43,8 +43,10 @@ MAX_SQL_CHARS = 20000 #: 入口字段能写的类型(apps/spec.FORM_TYPES 的子集;日期用 string + description 说清) ENTRY_TYPES = ("string", "number", "integer", "boolean") -#: 指标里固定出现的字段(顺序照接口契约) -METRIC_FIELDS = ("id", "name", "synonyms", "unit", "decimals", "expression", "definition", "open_questions") +#: 指标里固定出现的字段(顺序照接口契约)。grain、time_field、numerator、denominator、dimensions、filters 是文档起草 +#: 交回来的业务描述(docdraft.METRIC_FIELDS 里也有),校验阶段必须原样留着,草稿页要逐行展示 +METRIC_FIELDS = ("id", "name", "synonyms", "unit", "decimals", "expression", "grain", "time_field", + "numerator", "denominator", "dimensions", "filters", "definition", "open_questions") #: 入口字段最多几个(时间范围占两个)。再多说明把「每次都不变」的条件也做成了参数, #: 人每跑一次都要填一遍,口径就没法用了 @@ -62,20 +64,20 @@ _SYSTEM = """你是业务口径与取数模板的起草助手。读给定的业 硬性约束: 1. 只输出一个 JSON 对象,不要写解释,不要写 Markdown 代码块之外的话。 -2. sql 是取数模板,只能是**一条只读 SELECT**:不要多语句、不要 INSERT/UPDATE/DELETE/DDL,也不要在注释后面再藏一条。 +2. 每组的 sql 是取数模板,只能是**一条只读 SELECT**:不要多语句、不要 INSERT/UPDATE/DELETE/DDL,也不要在注释后面再藏一条。 表和列只能来自「数据目录」,表名、列名照抄,不许猜、不许编。 -3. sql 里需要按入口筛选时写 '{{ input.字段名 }}'(字符串)或 {{ input.字段名 }}(数),并在 entry_fields 里声明同名 +3. sql 里需要按入口筛选时写 '{{ input.字段名 }}'(字符串)或 {{ input.字段名 }}(数),并在该组的 entry_fields 里声明同名 字段;不要把日期、门店之类的字面量写死。 -4. 每条指标都要有 expression,固定写成 cell(nodes.fetch, 0, '列名'),列名是 sql 最外层 SELECT 的输出列名。 +4. 每条指标都要有 expression,固定写成 cell(nodes.fetch, 0, '列名'),列名是这组 sql 最外层 SELECT 的输出列名。 5. 每条指标的定义必须带 citations:引用逐字抄原文,chunk 写材料里方括号内的 id。核对不上、或原文里没有依据的定义 不要写进指标,写进 open_questions。 6. 拿不准的不要猜,写进 open_questions,用一句问得清的话。不要编造文档里没有的数字、单位、生效日期。 -7. **一次只起草能共用同一条 SQL 的一组指标,最多 6 条。** 文档里其余的指标不要硬凑进这一条 SQL,把名称写进 - uncovered(字符串数组),下一轮再起草。不同粒度、不同表、不同时间字段的指标本来就不该共用一条 SQL。 - human 里会列出「已经起草过的指标」:这些不要重复起草,这一组只从未起草的指标里选,剩下的未起草指标都写进 uncovered。 +7. **一次尽量多交几组。** 每组是能共用同一条 SQL 的指标,最多 6 条;不同粒度、不同表、不同时间字段的指标不要硬凑 + 进同一组。文档里其余的指标不要硬凑,把名称写进 uncovered(字符串数组),下一轮再起草。human 里会列出「已经起草 + 过的指标」:这些不要重复起草,剩下的未起草指标都写进 uncovered。 8. **sql 要短。** 能用 GROUP BY 和聚合表达的,不要为每个属性枚举一长串 CASE WHEN 列;输出额度有限,SQL 写长了, 后面的指标就写不出来了。 -9. 写不下时宁可少写几条指标、把剩下的写进 uncovered,**也不要让 JSON 半截断掉**。 +9. 写不下时宁可少写几组、把剩下的写进 uncovered,**也不要让 JSON 半截断掉**。 10. **同一件事只做一个入口。** 工厂、产线这类维度全图只给一个字段,不要为不同的表各造一个 (factory_code / gc_code / scx_code 说的是同一件事);跨表的编码差异在 sql 里用 JOIN 或子查询映射, 不要让填参数的人重复填等价编码。 @@ -88,27 +90,31 @@ _SYSTEM = """你是业务口径与取数模板的起草助手。读给定的业 输出格式: { - "entry_fields": [ - {"name": "start", "type": "string", "required": true, "default": "", "description": "开始日期,格式 YYYY-MM-DD"} - ], - "sql": "SELECT SUM(amount) AS gmv FROM orders WHERE paid_at >= '{{ input.start }}'", - "metrics": [ + "groups": [ { - "id": "m1", - "name": "指标名称", - "synonyms": ["同义词"], - "unit": "单位(文档没写就留空)", - "decimals": 2, - "expression": "cell(nodes.fetch, 0, 'gmv')", - "definition": { - "text": "口径说明", - "scope": "适用范围", - "exclusions": ["不包含的情况"], - "owner": "文档写明的负责人或部门", - "effective_from": "生效日期(文档原文写法)", - "citations": [{"chunk": "c1", "quote": "逐字原文"}] - }, - "open_questions": ["还没说清、需要业务确认的问题"] + "entry_fields": [ + {"name": "start", "type": "string", "required": true, "default": "", "description": "开始日期,格式 YYYY-MM-DD"} + ], + "sql": "SELECT SUM(amount) AS gmv FROM orders WHERE paid_at >= '{{ input.start }}'", + "metrics": [ + { + "id": "m1", + "name": "指标名称", + "synonyms": ["同义词"], + "unit": "单位(文档没写就留空)", + "decimals": 2, + "expression": "cell(nodes.fetch, 0, 'gmv')", + "definition": { + "text": "口径说明", + "scope": "适用范围", + "exclusions": ["不包含的情况"], + "owner": "文档写明的负责人或部门", + "effective_from": "生效日期(文档原文写法)", + "citations": [{"chunk": "c1", "quote": "逐字原文"}] + }, + "open_questions": ["还没说清、需要业务确认的问题"] + } + ] } ], "open_questions": ["跨指标、以及整条 SQL 还没把握的问题"], @@ -325,6 +331,108 @@ async def plan(session: Any, *, docs: list[dict], chunks: dict[str, dict], guida # ========================================================================== +def _reply_groups(data: Mapping) -> list[dict]: + """从模型回复里取分组:有 groups 数组就逐项收;旧形状(顶层 sql + metrics)当成只有一组。""" + raw = data.get("groups") + if isinstance(raw, list): + return [dict(group) for group in raw if isinstance(group, Mapping)] + return [dict(data)] if (data.get("sql") or data.get("metrics")) else [] + + +async def plan_groups(session: Any, *, docs: list[dict], chunks: dict[str, dict], guidance: str = "", + datasource: dict | None = None, tables_text: str = "", model: Any = None, + covered: Sequence[str] = (), fixed: str = "") -> dict: + """一次模型调用,交回**多组**草案(未校验):{groups, open_questions, uncovered, model_id, ...}。 + + 和 plan() 的区别只在输出形状:模型一次尽量交几组「能共用一条 SQL 的指标」,剩下的写 uncovered;调用方不必为一组 + 指标各调一次模型。每组的结构仍是 {sql, entry_fields, metrics},交给 normalize_plan 逐组校验;一组不合格只丢那一组, + 不影响别的组。旧形状(顶层 sql + metrics)也收,当成只有一组——模型不按新格式回时不用整份作废。 + """ + from app.engine.state import message_text + + if model is None: + from app.data.recipe_ai import resolve_draft_model + + model, model_id, availability = await resolve_draft_model(session) + if model is None or not getattr(availability, "available", False): + raise docdraft.DraftUnavailable(getattr(availability, "reason", "") or "AI 起草不可用") + else: + named = getattr(model, "model_name", None) or getattr(model, "model", None) + model_id = named if isinstance(named, str) else "" + + kept, tables, doc_truncated, catalog_truncated = _fit(docs, chunks, guidance, datasource, tables_text, + covered, fixed) + reply = await model.ainvoke(prompt(docs, kept, guidance, datasource, tables, covered, fixed)) + text = message_text(reply) + salvaged = False + try: + data = docdraft.parse_reply(text) + except ValueError: + salvaged_data = _salvage_reply(text) + if salvaged_data.get("sql") or salvaged_data.get("metrics"): + data = salvaged_data + else: + data = {"groups": [], "open_questions": [], "uncovered": []} + salvaged = True + groups = _reply_groups(data) + questions = data.get("open_questions") + uncovered = data.get("uncovered") + diagnostics = "" + if not groups: + keys = "、".join(str(key) for key in data) or "无" + diagnostics = f"模型回复里没有可读的分组(顶层键:{keys})" + if salvaged: + diagnostics += ";回复被截断,没有抢救出完整的一组" + return { + "groups": groups, + "open_questions": [q for q in questions if isinstance(q, str) and q.strip()] + if isinstance(questions, list) else [], + "uncovered": [q.strip() for q in uncovered if isinstance(q, str) and q.strip()] + if isinstance(uncovered, list) else [], + "salvaged": salvaged, + "diagnostics": diagnostics, + "model_id": model_id, + "doc_truncated": doc_truncated, + "catalog_truncated": catalog_truncated, + "truncated": doc_truncated or catalog_truncated, + } + + +async def repair_group(session: Any, *, group: Mapping, problems: list[dict], docs: list[dict], + chunks: dict[str, dict], guidance: str = "", datasource: dict | None = None, + tables_text: str = "", model: Any = None, fixed: str = "") -> dict: + """只重写一组:把这一组原来的指标和没通过校验的原因交回模型,返回修好的原始组(未校验)。 + + 修不出来返回 {},由调用方照实记失败,不让整份草稿跟着作废。修的时候只给这一组的指标名和问题,不让模型重排别的组。 + """ + from app.engine.state import message_text + + if model is None: + from app.data.recipe_ai import resolve_draft_model + + model, _model_id, availability = await resolve_draft_model(session) + if model is None or not getattr(availability, "available", False): + raise docdraft.DraftUnavailable(getattr(availability, "reason", "") or "AI 起草不可用") + kept, tables, _doc_truncated, _catalog_truncated = _fit(docs, chunks, guidance, datasource, tables_text, + (), fixed) + names = [str(metric.get("name") or metric.get("id")) for metric in group.get("metrics") or [] + if isinstance(metric, Mapping) and (metric.get("name") or metric.get("id"))] + reasons = ";".join(f"{problem.get('name') or '问题'}:{problem.get('reason') or ''}" + for problem in problems if problem.get("reason")) + repair = ("上一版这一组没有通过校验:" + (reasons or "原因不明") + "。" + + (f"这一组原来的指标:{'、'.join(names)}。" if names else "") + + "只重写这一组:保留原来的指标名,改正 sql / expression / entry_fields;不要重复别的组,也不要新增指标。") + full_guidance = f"{guidance}\n\n{repair}" if guidance.strip() else repair + reply = await model.ainvoke(prompt(docs, kept, full_guidance, datasource, tables, fixed=fixed)) + text = message_text(reply) + try: + data = docdraft.parse_reply(text) + except ValueError: + data = _salvage_reply(text) + groups = _reply_groups(data) + return groups[0] if groups else {} + + def _entry_fields(value: Any, problems: list[dict]) -> list[dict]: """入口字段归一化:名字必填、不重名;类型落回能认的几种,认不出的按文字。""" out: list[dict] = [] @@ -402,8 +510,24 @@ def _sql_problem(sql: str, allowed_tables: set[str]) -> str | None: return None +def _text(value: Any) -> str: + """文档起草交回的文字字段:去掉首尾空白;不是字符串的当没有。""" + return value.strip() if isinstance(value, str) else "" + + +def _texts(value: Any) -> list[str]: + """文档起草交回的字符串列表:只收非空字符串(去首尾空白),别的类型和空项都丢掉。""" + if not isinstance(value, list): + return [] + return [item.strip() for item in value if isinstance(item, str) and item.strip()] + + def _metric(item: Mapping, position: int, problems: list[dict]) -> dict | None: - """一条已经过 docdraft 引用核对的指标:再查 expression,归一成契约里的字段。""" + """一条已经过 docdraft 引用核对的指标:再查 expression,归一成契约里的字段。 + + grain / time_field / numerator / denominator / dimensions / filters 是文档起草里的业务描述。前端草稿页已经在画这几行, + 这里不能像以前那样重建指标时丢掉;空值不写成空字符串(没有这个键就没有内容),dimensions / filters 只收非空字符串。 + """ name = str(item.get("name") or "").strip() expression = item.get("expression") if not isinstance(expression, str) or not _EXPRESSION.match(expression.strip()): @@ -417,6 +541,9 @@ def _metric(item: Mapping, position: int, problems: list[dict]) -> dict | None: decimals = None synonyms = item.get("synonyms") questions = item.get("open_questions") + grain, time_field = _text(item.get("grain")), _text(item.get("time_field")) + numerator, denominator = _text(item.get("numerator")), _text(item.get("denominator")) + dimensions, filters = _texts(item.get("dimensions")), _texts(item.get("filters")) return { "id": str(item.get("id") or "").strip(), "name": name, @@ -425,6 +552,13 @@ def _metric(item: Mapping, position: int, problems: list[dict]) -> dict | None: "unit": item.get("unit") if isinstance(item.get("unit"), str) else "", "decimals": decimals, "expression": expression.strip(), + "contract": {"shape": "scalar"}, + **({"grain": grain} if grain else {}), + **({"time_field": time_field} if time_field else {}), + **({"numerator": numerator} if numerator else {}), + **({"denominator": denominator} if denominator else {}), + **({"dimensions": dimensions} if dimensions else {}), + **({"filters": filters} if filters else {}), "definition": { "text": definition.get("text") if isinstance(definition.get("text"), str) else "", "scope": definition.get("scope") if isinstance(definition.get("scope"), str) else "", @@ -514,12 +648,12 @@ def _blocks(value: Any) -> list[dict]: return [dict(block) for block in value or [] if isinstance(block, Mapping)] -def _merge_fields(target: dict[str, dict], value: Any, notes: list[str]) -> None: +def _merge_fields(target: dict[str, dict], value: Any, notes: list[str], conflicts: set[str]) -> None: """入口字段按 name 合并,冲突取更保守的一边,**不抛错**。 同一份文档分几轮起草,各组对同一个字段的声明常常对不上:一组说必填、另一组给了缺省;一组按数、另一组按文本。 这不是错误——它们本来就是从不同批指标里各看了一次。合并时必填取并集(有一组要就必须给),类型取更宽的一个 - (能填进文本),缺省和说明取先出现的非空值;每一处不一致记一条 note 交给人看,不悄悄拿一组覆盖另一组。 + (能填进文本)。默认值冲突时清掉默认值、要求人填,不能自动把某一组的范围用于其他组;0 和 False 都是有效值。 早先这里抛 ValueError,结果一份 34 条指标的草稿因为一个字段的 required 不同就整份作废——代价完全不成比例。 """ @@ -545,7 +679,15 @@ def _merge_fields(target: dict[str, dict], value: Any, notes: list[str]) -> None merged["required"] = bool(old.get("required")) or bool(item.get("required")) notes.append(f"入口字段「{name}」在各组里的必填不一致,合并后按" f"{'必填' if merged['required'] else '可选'}") - if not merged.get("default") and item.get("default") not in (None, ""): + old_default, new_default = merged.get("default"), item.get("default") + if old_default not in (None, "") and new_default not in (None, "") \ + and (old_default != new_default or isinstance(old_default, bool) != isinstance(new_default, bool)): + conflicts.add(name) + notes.append(f"入口字段「{name}」在各组里的默认值不一致,已取消默认值,请确认后填写") + if name in conflicts: + merged.pop("default", None) + merged["required"] = True + elif old_default in (None, "") and new_default not in (None, ""): merged["default"] = item["default"] if not merged.get("description") and item.get("description"): merged["description"] = item["description"] @@ -556,11 +698,28 @@ def merge_entry_fields(blocks: Any) -> tuple[list[dict], list[str]]: """各组草案的入口字段合成一份:(字段, 不一致的说明)。调用方把说明摆给起草的人看。""" target: dict[str, dict] = {} notes: list[str] = [] + conflicts: set[str] = set() for block in _blocks(blocks): - _merge_fields(target, block.get("entry_fields"), notes) + _merge_fields(target, block.get("entry_fields"), notes, conflicts) return list(target.values()), notes +def unique_metric_ids(blocks: Any) -> None: + """把各组指标的 id 在全图里改成唯一,就地改调用方的 blocks。调用方在建图前调一次。 + + 模型按组起草,每组都从 m1 开始;多组拼一张图时 id 会撞。build_graph 只改图里的副本,草稿 payload 和 groups + 仍留着撞车的 id,页面的「指标 → 试跑值」就会张冠李戴(第二组的 m1 显示成第一组的数)。这里先改同一份 blocks, + 图、payload、groups 和试跑值用同一组 id;本来就唯一的保持原 id 不动。 + """ + metrics = [metric for block in _blocks(blocks) for metric in block.get("metrics") or [] + if isinstance(metric, dict)] + ids = [str(metric.get("id") or "").strip() for metric in metrics] + if "" not in ids and len(set(ids)) == len(ids): + return + for position, metric in enumerate(metrics, start=1): + metric["id"] = f"m{position}" + + def build_graph(blocks: Any, *, name: str, question: str = "", datasource: str = "") -> dict: """各组草案 → 一张草稿图:start(input) → fetch…(tool,每组一个) → card(metrics) → write(report) → out(output)。 @@ -587,11 +746,12 @@ def build_graph(blocks: Any, *, name: str, question: str = "", datasource: str = return f"fetch_{index + 1}" if multi else "fetch" merged: dict[str, dict] = {} + conflicts: set[str] = set() metrics: list[dict] = [] fetch_nodes: list[dict[str, Any]] = [] source = str(datasource or "") for index, group in enumerate(groups): - _merge_fields(merged, group.get("entry_fields"), []) + _merge_fields(merged, group.get("entry_fields"), [], conflicts) node_id = nid(index) for metric in group.get("metrics") or []: if not isinstance(metric, Mapping): @@ -639,4 +799,4 @@ def build_graph(blocks: Any, *, name: str, question: str = "", datasource: str = __all__ = ["ENTRY_TYPES", "MAX_INPUT_CHARS", "MAX_SQL_CHARS", "MAX_TABLES_CHARS", "METRIC_FIELDS", - "build_graph", "normalize_plan", "plan", "prompt"] + "build_graph", "normalize_plan", "plan", "plan_groups", "prompt", "repair_group", "unique_metric_ids"] diff --git a/backend/app/ledger/jobs.py b/backend/app/ledger/jobs.py new file mode 100644 index 00000000..ce345a90 --- /dev/null +++ b/backend/app/ledger/jobs.py @@ -0,0 +1,99 @@ +"""起草任务的持久化生命周期;重启后标中断,用户重试才继续调用模型。""" +from __future__ import annotations + +from typing import Any, Awaitable, Callable + +from sqlalchemy import select, update + +from app.core import job_runtime as runtime +from app.db.base import SessionLocal, current_owner, utcnow +from app.db.models import CaliberDraft, CaliberDraftJob + +executor: Callable[[str], Awaitable[None]] | None = None + + +async def finish(session: Any, job_id: str, status: str, error: str | None = None) -> None: + changed = await session.execute(update(CaliberDraftJob).where( + CaliberDraftJob.id == job_id, CaliberDraftJob.status.in_(runtime.ACTIVE) + ).values(status=status, error=error, finished_at=utcnow()).execution_options(synchronize_session=False)) + if changed.rowcount and status != "succeeded": + await session.execute(update(CaliberDraft).where(CaliberDraft.id == job_id, CaliberDraft.status == "drafting") + .values(status="failed", error=error).execution_options(synchronize_session=False)) + await session.commit() + + +class DraftJob(runtime.JobRun): + model = CaliberDraftJob + kind = "caliber_draft" + + def lane_of(self, job: Any) -> None: + return None + + async def _run(self) -> None: + if not await self._write(runtime.claim, CaliberDraftJob, self.job_id): + return + if executor is None: + raise runtime.JobFailed("起草服务尚未初始化") + await executor(self.job_id) + async with SessionLocal() as session: + draft = await session.get(CaliberDraft, self.job_id) + error = draft.error if draft else "草稿已删除" + status = "succeeded" if draft is not None and draft.status not in ("failed", "drafting", "rejected") else "failed" + await self._write(finish, self.job_id, status, error) + + async def _stopped(self) -> None: + interrupted = self.stop_reason != "user" + await self._write(finish, self.job_id, "interrupted" if interrupted else "cancelled", + "服务中断,已保留起草进度,可重试" if interrupted else "起草已停止,已保留进度") + + async def _failed(self, reason: str) -> None: + await self._write(finish, self.job_id, "failed", reason) + + async def _crashed(self, exc: Exception) -> None: + await self._failed(f"起草任务失败:{exc}") + + +manager = runtime.JobManager(DraftJob, label="口径起草任务", name="caliber_draft") + + +async def start(job_id: str) -> None: + # 重试复用草稿 id;上一尝试可能刚写完终态、还在释放资源,先等它真正退出。 + await manager.wait(job_id) + manager.start(job_id) + await manager.wait(job_id) + + +async def startup(*, live: Any = None) -> int: + async def close(session: Any, job_id: str) -> None: + await finish(session, job_id, "interrupted", "服务中断,已保留起草进度,可重试") + + count = await runtime.interrupt_on_startup(CaliberDraftJob, close, running=manager.running, live=live) + # 升级前由 BackgroundTasks 启动、没有任务行的草稿也不能永远停在起草中。 + async with SessionLocal() as session: + result = await session.execute(update(CaliberDraft).where( + CaliberDraft.status == "drafting", ~CaliberDraft.id.in_(select(CaliberDraftJob.id)) + ).values(status="failed", error="历史起草任务已中断,可重试").execution_options(synchronize_session=False)) + await session.commit() + return count + result.rowcount + + +async def retry(session: Any, draft: CaliberDraft) -> None: + if draft.status != "failed" or draft.workflow_id: + raise ValueError("只有尚未生成工作流的失败草稿可以重试") + claimed = await session.execute(update(CaliberDraft).where( + CaliberDraft.id == draft.id, CaliberDraft.status == "failed", CaliberDraft.workflow_id.is_(None) + ).values(status="drafting", error=None).execution_options(synchronize_session=False)) + if claimed.rowcount != 1: + raise ValueError("草稿状态已经变化,请刷新后再重试") + job = await session.get(CaliberDraftJob, draft.id) + if job is None: + session.add(CaliberDraftJob(id=draft.id)) + else: + result = await session.execute(update(CaliberDraftJob).where( + CaliberDraftJob.id == draft.id, CaliberDraftJob.status.not_in(runtime.ACTIVE) + ).values(status="queued", owner=current_owner(), attempt=CaliberDraftJob.attempt + 1, + error=None, started_at=None, finished_at=None).execution_options(synchronize_session=False)) + if result.rowcount != 1: + raise ValueError("这份草稿已经在起草,请等待当前任务完成") + draft.status, draft.error = "drafting", None + await session.commit() diff --git a/backend/app/look/calibers.py b/backend/app/look/calibers.py index bef3d5b2..7be9ad8c 100644 --- a/backend/app/look/calibers.py +++ b/backend/app/look/calibers.py @@ -145,25 +145,9 @@ def card_inputs(graph: dict[str, Any], card_id: str) -> tuple[list[dict[str, Any def _metric_paths(metric: Any) -> list[str] | None: - """一个指标读的取值路径:公式的表达式、规则各输入、评估各维度。外部常量没有输入。认不出为 None。""" - if not isinstance(metric, dict): - return None - paths: list[str] = [] - if metric.get("expression"): - paths.append(str(metric["expression"])) - rule = metric.get("rule") - if isinstance(rule, dict): - inputs = rule.get("inputs") - if not isinstance(inputs, dict): - return None - paths.extend(str(path) for path in inputs.values()) - assessment = metric.get("assessment") - if isinstance(assessment, dict): - dimensions = assessment.get("dimensions") - if not isinstance(dimensions, list): - return None - paths.extend(str(d.get("input")) for d in dimensions if isinstance(d, dict) and d.get("input")) - return paths + from app.engine.metric_spec import metric_expressions + + return metric_expressions(metric) def definition_text(definition: Any) -> str: diff --git a/backend/app/main.py b/backend/app/main.py index 026a85a3..fe351e27 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -143,6 +143,9 @@ async def lifespan(_: FastAPI) -> AsyncIterator[None]: from app.chat import job as chat_job from app.eval import runner as eval_runner, snapshots as eval_snapshots + from app.ledger import jobs as draft_jobs + + await draft_jobs.manager.shutdown() await catalog_jobs.manager.shutdown() await catalog_profile_jobs.manager.shutdown() await introspect_jobs.manager.shutdown() @@ -243,6 +246,7 @@ def _register_reapers() -> None: from app.data import catalog_jobs, catalog_profile_jobs, introspect_jobs from app.eval import runner as eval_runner, snapshots as eval_snapshots from app.process import actions, tasks + from app.ledger import jobs as draft_jobs async def documents(live: Any) -> int: return await _reap_documents(live) @@ -250,6 +254,7 @@ def _register_reapers() -> None: for name, fn in ( ("运行", run_manager.reap), ("处理中的文档", documents), + ("口径起草任务", lambda live: draft_jobs.startup(live=live)), ("数据目录起草任务", lambda live: catalog_jobs.startup(live=live)), ("数据剖析任务", lambda live: catalog_profile_jobs.startup(live=live)), ("探查任务", lambda live: introspect_jobs.startup(live=live)), diff --git a/backend/app/sandbox/microvm_sandbox.py b/backend/app/sandbox/microvm_sandbox.py index 4b1d96dc..c50f996b 100644 --- a/backend/app/sandbox/microvm_sandbox.py +++ b/backend/app/sandbox/microvm_sandbox.py @@ -290,8 +290,6 @@ class MicroVMSandbox(Sandbox): env: dict[str, str] | None = None, files: dict[str, str] | None = None, ) -> ExecResult: - import microsandbox as ms - limits = limits or SandboxLimits() language = (language or "python").lower() if language not in _LANG: @@ -300,6 +298,8 @@ class MicroVMSandbox(Sandbox): reason = self.unavailable_reason() or "运行时未就绪" return ExecResult(ok=False, backend=self.name, error=f"microVM 不可用:{reason}") + import microsandbox as ms + ext, cmd = _LANG[language] entry = entry_name(ext) sid = session_id or f"tmp{int(time.time() * 1000)}" diff --git a/backend/tests/test_analysis_bundle.py b/backend/tests/test_analysis_bundle.py index f8794167..71ab2168 100644 --- a/backend/tests/test_analysis_bundle.py +++ b/backend/tests/test_analysis_bundle.py @@ -105,9 +105,9 @@ def test_the_analysis_and_its_input_travel_with_the_file_and_verify(): analysis_item = item(bundle, a) assert analysis_item["kind"] == ARTIFACT_KIND and "b64" in analysis_item and analysis_item["node_id"] == "an" # 导出时服务端真的复算了一遍:按封存的输入快照(这份文件里哈希对得上的原字节)重算,逐字段相同 - assert analysis_item["recheck"] == {"status": "same", "method": "decompose.additive@1", + assert analysis_item["recheck"] == {"status": "same", "method": "decompose.additive@2", "text": "复算一致:按封存的输入重新计算,结论逐项相同"} - # 方法的叫法由服务端带上(和导出页同一份),核对页只显示「变化拆解(可加) · decompose.additive@1」 + # 方法的叫法由服务端带上(和导出页同一份),核对页只显示「变化拆解(可加) · decompose.additive@2」 assert analysis_item["method_label"] == "变化拆解(可加)" [query] = [x for x in bundle["artifacts"] if x["kind"] == "query"] assert "b64" in query diff --git a/backend/tests/test_analysis_comparable.py b/backend/tests/test_analysis_comparable.py index 6dcb8bc3..e2f1d2f5 100644 --- a/backend/tests/test_analysis_comparable.py +++ b/backend/tests/test_analysis_comparable.py @@ -26,7 +26,7 @@ def checks(result): async def test_two_weeks_that_differ_only_in_dates_are_comparable(): result = await compare(period("Q1", "2026-10-05", "2026-10-12"), period("Q2", "2026-10-12", "2026-10-19"), probe=fixed_prober(RANGE), measure="gmv") - assert result["method"] == "comparable@1" and result["mode"] is None + assert result["method"] == "comparable@2" and result["mode"] is None assert result["verdict"] == {"code": "comparable", "subjects": [], "direction": None} assert result["phrase"] == "两期可比" assert checks(result) == {"同口径": "通过", "窗口长度": "通过", "整期": "通过", "星期构成": "通过", "数据问题": "通过"} @@ -107,8 +107,8 @@ async def test_a_failed_probe_falls_back_to_profile_facts_then_says_it_cannot_ch assert fallback["probes"] == [{"source": "shop", "table": "orders", "column": "order_date", "min": "2025-01-01", "max": "2026-10-19", "basis": "profile", "at": "2026-10-20"}] nothing = await compare(base, target, measure="gmv") - assert nothing["verdict"]["code"] == "comparable" - assert nothing["phrase"] == "两期可比,整期核对不了" and nothing["probes"] == [] + assert nothing["verdict"]["code"] == "conditional" + assert nothing["phrase"] == "两期有条件可比:整期核对不了" and nothing["probes"] == [] async def test_bounds_computed_from_the_run_day_are_evaluated_in_the_database_and_recorded(): @@ -127,7 +127,7 @@ async def test_bounds_computed_from_the_run_day_are_evaluated_in_the_database_an assert all(p["basis"] == "live" for p in result["probes"]) # 界算不出来:窗口核对不了,不判不可比 blind = await compare(base, target, probe=fixed_prober(RANGE), measure="gmv") - assert blind["verdict"]["code"] == "comparable" and "窗口长度" in blind["phrase"] + assert blind["verdict"]["code"] == "conditional" and "窗口长度" in blind["phrase"] async def test_data_problems_on_the_snapshots(): @@ -180,7 +180,7 @@ async def test_a_masked_date_column_is_neither_used_nor_probed(): result = await compare(period("Q1", "2026-10-05", "2026-10-12", masked=["order_date"]), period("Q2", "2026-10-12", "2026-10-19", masked=["order_date"]), probe=probe, measure="gmv") assert probe.calls == [] and result["probes"] == [] - assert result["verdict"]["code"] == "comparable" and "窗口长度" in result["phrase"] + assert result["verdict"]["code"] == "conditional" and "窗口长度" in result["phrase"] async def test_one_snapshot_of_sqlite_weeks_uses_the_real_days_of_each_week(): @@ -203,7 +203,8 @@ async def test_one_snapshot_of_sqlite_weeks_uses_the_real_days_of_each_week(): # 不知道方言(认不出周号算法):窗口、整期、星期构成都核对不了,不判可比以外的结论 plain = snap("Q1", sql, ["week", "orders"], rows, types={"week": "text", "orders": "number"}) unknown = await analyze("comparable", snaps=[plain], query="Q1", period="week", base="2026-W41", target="2026-W42") - assert unknown["phrase"] == "两期可比,窗口长度、整期、星期构成核对不了" + assert unknown["verdict"]["code"] == "conditional" + assert unknown["phrase"] == "两期有条件可比:窗口长度、整期、星期构成核对不了" def _same(a, b): diff --git a/backend/tests/test_analysis_decompose.py b/backend/tests/test_analysis_decompose.py index 00024921..089098d6 100644 --- a/backend/tests/test_analysis_decompose.py +++ b/backend/tests/test_analysis_decompose.py @@ -40,7 +40,7 @@ def _rows(base: dict, target: dict) -> list[list]: async def test_a_driver_is_named_with_its_share_and_the_whole_shape_is_filled(): result = await additive(label="销售额", hypothesis="华东拖累") - assert result["method"] == "decompose.additive@1" and result["status"] == "ok" + assert result["method"] == "decompose.additive@2" and result["status"] == "ok" assert result["verdict"] == {"code": "driver", "subjects": [["华东"]], "direction": "down"} assert result["phrase"] == "销售额下降 194(-8.7%),主要来自华东(-120,占 61.9%)" assert result["values"]["delta_total"] == "-194" and result["values"]["top_key"] == "华东" diff --git a/backend/tests/test_analysis_differ.py b/backend/tests/test_analysis_differ.py index bfb5d7d5..5f5c7a45 100644 --- a/backend/tests/test_analysis_differ.py +++ b/backend/tests/test_analysis_differ.py @@ -24,7 +24,7 @@ async def rate(rows, **params): async def test_small_samples_use_fisher_and_say_it_may_be_noise(): result = await rate([["西南", 2, 40], ["华东", 5, 230]]) - assert result["method"] == "differ.rate@1" + assert result["method"] == "differ.rate@2" assert result["verdict"] == {"code": "no_evidence", "subjects": [["西南"], ["华东"]], "direction": None} assert result["values"]["test"] == "fisher" and result["values"]["n_a"] == "40" and result["values"]["n_b"] == "230" assert Fraction(result["values"]["p"]) == pytest.approx(stats.fisher_exact(2, 38, 5, 225), rel=1e-9) @@ -91,7 +91,7 @@ async def test_means_from_sums_and_squares_use_welch(): assert Fraction(result["values"]["p"]) == pytest.approx(expected.p, rel=1e-9) assert result["verdict"]["code"] == "differs" and result["values"]["test"] == "welch" assert result["phrase"] == "线上的客单价(50)高于线下(40),差异站得住" - assert result["method"] == "differ.welch@1" and result["settings"] == {"differ_alpha": 0.05, "min_n": 30} + assert result["method"] == "differ.welch@2" and result["settings"] == {"differ_alpha": 0.05, "min_n": 30} async def test_means_from_mean_sd_and_n_and_small_samples(): @@ -111,7 +111,7 @@ async def test_rows_mode_flags_a_single_value_that_flips_the_verdict(): online[-1] = ["线上", "231.3"] s = snap("Q1", "SELECT channel, amount FROM orders", ["channel", "amount"], online + base) result = await analyze("differ", "rows", snaps=[s], query="Q1", by="channel", value="amount", label="客单价") - assert result["method"] == "differ.welch_rows@1" + assert result["method"] == "differ.welch_rows@2" assert result["verdict"]["code"] == "differs" note = next(c for c in result["caveats"] if c["code"] == "outlier_sensitive") assert note["text"] == "去掉最大的一笔后,差异不再站得住" diff --git a/backend/tests/test_analysis_e2e_claims.py b/backend/tests/test_analysis_e2e_claims.py index 9c3a98eb..99a65f56 100644 --- a/backend/tests/test_analysis_e2e_claims.py +++ b/backend/tests/test_analysis_e2e_claims.py @@ -184,7 +184,8 @@ async def test_check_rewrites_once_flags_the_wrong_source_and_keeps_the_tier(mon # 出口独立复核:核出来的是同一处,只写进出具声明,档位和写对了的那次一样 issuance = row.output["_issuance"] - assert issuance["tier"] == clean.output["_issuance"]["tier"] == "formal" + assert issuance["tier"] == clean.output["_issuance"]["tier"] == "degraded" + assert any("异常调查未完成" in gap for gap in issuance["gaps"]) assert _analysis_gaps(issuance) == [] declared = issuance["analysis_claims"] assert (declared["mode"], declared["counted"], declared["counts"]) == ("check", False, {ac.MISMATCH: 1}) diff --git a/backend/tests/test_analysis_e2e_evidence.py b/backend/tests/test_analysis_e2e_evidence.py index c5d70e54..d4c738ab 100644 --- a/backend/tests/test_analysis_e2e_evidence.py +++ b/backend/tests/test_analysis_e2e_evidence.py @@ -70,7 +70,7 @@ async def test_a_card_conclusion_opens_and_rechecks_everywhere(monkeypatch, shop judged = body["chain"][0] assert (judged["origin"], judged["item"]) == ("card", "where") again = next(s for s in body["chain"] if s["step"] == "analysis_recompute") - assert again["status"] == "same" and again["method"] == "decompose.additive@1", again + assert again["status"] == "same" and again["method"] == "decompose.additive@2", again inputs = [s for s in body["chain"] if s["step"] == "analysis_input"] assert [s["role"] for s in inputs] == ["headline_base", "headline_target", "data"] assert all(s["sealed"] for s in inputs) @@ -157,8 +157,8 @@ async def test_a_metric_comparability_opens_and_rechecks_everywhere(monkeypatch, view = body["chain"][0]["comparability"] # 表里只有周次这一列文字、没有业务日期:窗口长度、整期、星期构成核对不了,不判不可比 assert (view["verdict"], view["phrase"], view["reasons"]) == ( - "comparable", "两期可比,窗口长度、整期、星期构成核对不了", []) - assert view["sealed"] is True and view["hash_ok"] is True and view["method"] == "comparable@1" + "conditional", "两期有条件可比:窗口长度、整期、星期构成核对不了", []) + assert view["sealed"] is True and view["hash_ok"] is True and view["method"] == "comparable@2" steps = view["steps"] assert [s["step"] for s in steps] == ["analysis", "analysis_method", "analysis_calc", "analysis_input", "analysis_input", "analysis_recompute", "analysis_seal"] diff --git a/backend/tests/test_analysis_evidence.py b/backend/tests/test_analysis_evidence.py index 9b4ef31f..2582cacf 100644 --- a/backend/tests/test_analysis_evidence.py +++ b/backend/tests/test_analysis_evidence.py @@ -578,7 +578,7 @@ def test_recheck_with_the_real_operators_says_a_sealed_result_holds(): sealed = real_result(q) assert sealed["phrase"] == "销售额下降 194(-8.7%),主要来自华东(-120,占 61.9%)" out = analysis_recheck(sealed, store.get) - assert out == {"status": "same", "method": "decompose.additive@1", "text": "复算一致:按封存的输入重新计算,结论逐项相同"} + assert out == {"status": "same", "method": "decompose.additive@2", "text": "复算一致:按封存的输入重新计算,结论逐项相同"} assert analysis_recheck({**sealed, "hypothesis": "别的假设"}, store.get)["status"] == "same" @@ -593,7 +593,7 @@ def test_recheck_follows_the_parent_of_a_drill_down_by_hash(): assert analysis_recheck(child, store.get, load_result=store.get)["status"] == "same" # 上一层取不回:照实说取不回哪一份,不说两遍「复算」 out = analysis_recheck(child, store.get, load_result=lambda sha: None) - assert out == {"status": "failed", "method": "decompose.additive@1", "text": "复算失败:取不回上一层的分析结论 A1"} + assert out == {"status": "failed", "method": "decompose.additive@2", "text": "复算失败:取不回上一层的分析结论 A1"} def test_recheck_says_what_differs_and_what_is_missing(): @@ -606,7 +606,7 @@ def test_recheck_says_what_differs_and_what_is_missing(): assert out["text"] == "复算不一致:判定语和封存的不同" # 输入快照取不回(不在封存范围、哈希对不上的调用方都交 None) out = analysis_recheck(sealed, lambda sha: None) - assert out == {"status": "failed", "method": "decompose.additive@1", "text": "复算失败:取不回 Q2 的查询快照"} + assert out == {"status": "failed", "method": "decompose.additive@2", "text": "复算失败:取不回 Q2 的查询快照"} # 输入快照换了一份(同一个 sha256 交回了别的内容):复算照样算,结果和封存的不同 other = {**SNAPSHOT, "rows": [["华东", "W41", 820], ["华东", "W42", 650], *SNAPSHOT["rows"][2:]]} assert analysis_recheck(sealed, lambda sha: other)["status"] == "different" @@ -619,7 +619,7 @@ def test_recheck_says_the_phrase_template_was_upgraded(monkeypatch): sealed = {**real_result(q), "phrase_version": 1, "phrase": "旧模板的判定语"} monkeypatch.setattr("app.analysis.phrases.PHRASE_VERSION", 2) out = analysis_recheck(sealed, store.get) - assert out == {"status": "same", "method": "decompose.additive@1", "phrase_version": 1, "phrase_now": 2, + assert out == {"status": "same", "method": "decompose.additive@2", "phrase_version": 1, "phrase_now": 2, "text": "说法模板升过版(v1 → v2),复算结果相同"} assert "fields" not in out old = {k: v for k, v in sealed.items() if k != "phrase_version"} @@ -632,21 +632,21 @@ def test_recheck_names_both_versions_when_the_method_was_upgraded(monkeypatch): store = Store() q = store.put(SNAPSHOT) sealed = real_result(q) - monkeypatch.setitem(contract.METHODS, ("decompose", "additive"), "decompose.additive@2") + monkeypatch.setitem(contract.METHODS, ("decompose", "additive"), "decompose.additive@3") out = analysis_recheck(sealed, store.get) - assert out == {"status": "same", "method": "decompose.additive@1", "method_now": "decompose.additive@2", - "text": "运行时用的是 decompose.additive@1,现在是 decompose.additive@2,复算结果相同"} + assert out == {"status": "same", "method": "decompose.additive@2", "method_now": "decompose.additive@3", + "text": "运行时用的是 decompose.additive@2,现在是 decompose.additive@3,复算结果相同"} out = analysis_recheck({**sealed, "phrase": "别的判定"}, store.get) assert out["status"] == "different" and out["fields"] == ["phrase"] - assert out["text"] == "运行时用的是 decompose.additive@1,现在是 decompose.additive@2,复算结果不同" + assert out["text"] == "运行时用的是 decompose.additive@2,现在是 decompose.additive@3,复算结果不同" def refuse(result, load, *, load_result=None): raise CallError("unknown_mode", "变化拆解要写模式:additive / ratio / factor") monkeypatch.setattr(analysis, "recompute", refuse) out = analysis_recheck(sealed, store.get) - assert out["status"] == "failed" and out["method_now"] == "decompose.additive@2" - assert out["text"] == ("运行时用的是 decompose.additive@1,现在是 decompose.additive@2:现在的方法按封存的输入复算不了" + assert out["status"] == "failed" and out["method_now"] == "decompose.additive@3" + assert out["text"] == ("运行时用的是 decompose.additive@2,现在是 decompose.additive@3:现在的方法按封存的输入复算不了" "(变化拆解要写模式:additive / ratio / factor)") diff --git a/backend/tests/test_analysis_evidence_api.py b/backend/tests/test_analysis_evidence_api.py index 7a46e3f8..2fac2b87 100644 --- a/backend/tests/test_analysis_evidence_api.py +++ b/backend/tests/test_analysis_evidence_api.py @@ -140,7 +140,7 @@ async def test_the_phrase_opens_judgment_method_calculation_inputs_recompute_and assert judged["hypothesis"] == "华东拖累" and judged["hypothesis_label"] == "分析卡上写的『在验证什么』" assert judged["hypothesis_note"].startswith("分析卡上写的『在验证什么』") assert {c["code"] for c in judged["caveats"]} == {"measure_unlabeled", "headline_basis", "comparability"} - assert method["method"] == "decompose.additive@1" and method["method_label"] == "变化拆解(可加)" + assert method["method"] == "decompose.additive@2" and method["method_label"] == "变化拆解(可加)" assert {s["key"]: s["label"] for s in method["settings"]}["main_share"] == "「主要来源」的占比" assert {s["key"] for s in method["settings"]} >= {"main_share", "concentration", "table_top_k"} assert "k_warn" not in {s["key"] for s in method["settings"]} @@ -154,7 +154,7 @@ async def test_the_phrase_opens_judgment_method_calculation_inputs_recompute_and assert query["alias"] == "Q1" and query["input_of"] == "A1" and query["total_rows"] == 8 assert query["highlight"]["cols"] == ["region", "week", "gmv"] # 真的复算:按 sha256 取回封存的输入快照,逐字段和封存的比 - assert again == {"step": "analysis_recompute", "alias": "A1", "status": "same", "method": "decompose.additive@1", + assert again == {"step": "analysis_recompute", "alias": "A1", "status": "same", "method": "decompose.additive@2", "text": "复算一致:按封存的输入重新计算,结论逐项相同"} assert seal["sealed"] is True and seal["ledger"] is True and seal["hash_ok"] is True assert body["seal"]["covered"] is True @@ -260,11 +260,11 @@ async def test_an_input_outside_the_sealed_scope_is_not_recomputed_from(client): async def test_an_upgraded_method_names_both_versions(client, monkeypatch): made = await sealed_run(TEXT) - monkeypatch.setitem(contract.METHODS, ("decompose", "additive"), "decompose.additive@2") + monkeypatch.setitem(contract.METHODS, ("decompose", "additive"), "decompose.additive@3") body = (await client.get(f"/api/runs/{made['run']}/evidence/segments/{seg_id(made['doc'], 'a:A1')}")).json() again = next(s for s in body["chain"] if s["step"] == "analysis_recompute") - assert again["method"] == "decompose.additive@1" and again["method_now"] == "decompose.additive@2" - assert again["text"] == "运行时用的是 decompose.additive@1,现在是 decompose.additive@2,复算结果相同" + assert again["method"] == "decompose.additive@2" and again["method_now"] == "decompose.additive@3" + assert again["text"] == "运行时用的是 decompose.additive@2,现在是 decompose.additive@3,复算结果相同" async def test_recompute_errors_do_not_break_the_panel(client, monkeypatch): @@ -436,7 +436,7 @@ async def test_the_metric_step_opens_the_comparability_result(client): body = (await client.get(f"/api/runs/{made['run']}/evidence/segments/{seg_id(made['doc'], 'm:wow')}")).json() view = body["chain"][0]["comparability"] assert view["verdict"] == result["verdict"]["code"] and view["phrase"] == result["phrase"] - assert view["sealed"] is True and view["hash_ok"] is True and view["method"] == "comparable@1" + assert view["sealed"] is True and view["hash_ok"] is True and view["method"] == "comparable@2" steps = view["steps"] assert [s["step"] for s in steps] == ["analysis", "analysis_method", "analysis_calc", "analysis_input", "analysis_input", "analysis_recompute", "analysis_seal"] @@ -565,7 +565,7 @@ async def test_the_export_recomputes_each_conclusion_once(client, monkeypatch): "[[table:A1 cols=region,delta,share rows=0-3]]") made = await sealed_run(text, doc_extra=SCOPE) data = data_block((await client.get(f"/api/runs/{made['run']}/report.html")).text) - assert calls == ["decompose.additive@1"] + assert calls == ["decompose.additive@2"] cells = [s["id"] for s in iter_segments(made["doc"]) if str(s.get("ref") or "").startswith("v:A1.")] assert len(cells) == 12 keys = {data["refs"][sid] for sid in [*cells, seg_id(made["doc"], "a:A1"), seg_id(made["doc"], "a:A1.top_share|pct")]} diff --git a/backend/tests/test_analysis_judge_ledger.py b/backend/tests/test_analysis_judge_ledger.py index a95248e1..b4e3de9f 100644 --- a/backend/tests/test_analysis_judge_ledger.py +++ b/backend/tests/test_analysis_judge_ledger.py @@ -32,7 +32,7 @@ def test_the_judge_gets_the_method_the_judgment_and_the_input_sql(): [cand] = request.cands assert cand.aliases == ("A1",) and "A1" in cand.whole and ("A1", 0) in cand.rows and ("A1", "delta") in cand.cols excerpt = request.excerpts["A1"] - assert excerpt.startswith("【A1】分析结论(变化拆解(可加),方法 decompose.additive@1)") + assert excerpt.startswith("【A1】分析结论(变化拆解(可加),方法 decompose.additive@2)") assert "判定:销售额下降 194 万(-8.7%),主要来自华东(-120 万,占 62%)" in excerpt assert "判定标准:一段占总变化一半以上算主要来源" in excerpt assert "关键数:base_total=2,220 | target_total=2,026 | delta_total=-194" in excerpt diff --git a/backend/tests/test_analysis_pages.py b/backend/tests/test_analysis_pages.py index a7d19398..a9624fb8 100644 --- a/backend/tests/test_analysis_pages.py +++ b/backend/tests/test_analysis_pages.py @@ -213,7 +213,7 @@ async def test_the_bundle_page_shows_the_analysis_detail_and_the_scope(): phrase = next(text for text in out["opened"].values() if "出处分析结论 A1 的判定语" in text) assert "核对结果✓ 通过" in phrase and "分析结论里的值「销售额下降 194(-8.7%)" in phrase # 方法写服务端带来的叫法和 id(和导出页同一个写法),不只写 id - assert "判定语销售额下降 194(-8.7%)" in phrase and "方法与版本变化拆解(可加) · decompose.additive@1" in phrase + assert "判定语销售额下降 194(-8.7%)" in phrase and "方法与版本变化拆解(可加) · decompose.additive@2" in phrase assert "输入数据 · Q1" in phrase # 输入的角色写叫法,不写 data # 这份结论是分析卡做的(台账条目带 item):那一句「在验证什么」写「分析卡上写的『在验证什么』」,不标成 Agent 写的假设 assert "分析卡上写的『在验证什么』" in phrase and "Agent 写的假设" not in phrase @@ -247,7 +247,7 @@ def test_the_bundle_page_shows_the_comparability_in_the_metric_detail(): out = run_node(RUN_VERIFIER, VERIFIER, {"data": json.dumps(bundle), "seal": bundle["meta"]["manifest_hash"]}) assert out["verified"] == "" [detail] = out["opened"].values() - for text in ("两期可比性", "判定两期可比", "判定标准只差日期", "方法与版本可比性检查 · comparable@1", "各项检查", "同口径", + for text in ("两期可比性", "判定两期有条件可比", "判定标准只差日期", "方法与版本可比性检查 · comparable@2", "各项检查", "同口径", "复算一致:按封存的输入重新计算,结论逐项相同(导出时服务端按封存的输入复算的结论"): assert text in detail, text diff --git a/backend/tests/test_architecture_contracts.py b/backend/tests/test_architecture_contracts.py new file mode 100644 index 00000000..86607755 --- /dev/null +++ b/backend/tests/test_architecture_contracts.py @@ -0,0 +1,154 @@ +"""验证口径约束、执行身份和调查完成条件,覆盖会造成错误业务结论的输入。""" +from __future__ import annotations + +import copy +from types import SimpleNamespace + +import pytest + +from app.core.artifact_store import execution_hash, graph_hash +from app.data.caliber_identity import definition_hash, semantic_hash +from app.engine.investigation import InvestigationPolicy, assess +from app.engine.metric_spec import MetricContract, contract_findings, metric_expressions +from app.engine.validation import metric_quality, validation_report +from tests import ledger_samples as S + + +def test_execution_and_semantic_hashes_distinguish_layout_from_sql(): + graph = S.caliber_graph() + layout = copy.deepcopy(graph) + layout["nodes"][1]["position"] = {"x": 999, "y": 777} + layout["nodes"].reverse() + assert graph_hash(layout) != graph_hash(graph) # 历史封存算法不改写 + assert execution_hash(layout) == execution_hash(graph) + assert semantic_hash(layout, "card") == semantic_hash(graph, "card") + sql = copy.deepcopy(graph) + fetch = next(n for n in sql["nodes"] if n["id"] == "fetch") + fetch["data"]["config"]["args"]["sql"] = "SELECT SUM(amount) AS gmv FROM orders WHERE status='paid'" + assert semantic_hash(sql, "card") != semantic_hash(graph, "card") + assert execution_hash(sql) != execution_hash(graph) + + +@pytest.mark.parametrize("key,value", [("grain", "order"), ("filters", ["status=paid"]), + ("time_field", "paid_at"), ("contract", {"shape": "scalar"})]) +def test_declared_semantics_are_part_of_definition_identity(key, value): + metric = {"id": "n", "expression": "input.n"} + assert definition_hash("", "", [metric]) != definition_hash("", "", [{**metric, key: value}]) + + +def test_inactive_formula_is_not_a_rule_dependency(): + assert metric_expressions({"kind": "rule", "expression": "nodes.old.value", + "rule": {"inputs": {"n": "input.n"}}}) == ["input.n"] + + +def test_contract_catches_multiline_first_cell_and_duplicate_grain(): + snapshot = {"columns": ["shop", "gmv"], "rows": [["east", 4], ["east", 7]]} + metric = {"value": 4} + bad = contract_findings({"contract": {"shape": "scalar"}}, metric, [snapshot]) + assert any("恰好一行" in problem for problem in bad) + bad = contract_findings({"contract": {"shape": "table", "grain": ["shop"]}}, metric, [snapshot]) + assert any("重复行" in problem for problem in bad) + + +@pytest.mark.parametrize("rows,value,problem", [([[2, 10]], .2, None), ([[2, 0]], 0, "分母"), + ([[12, 10]], 1.2, "子集"), ([[2, 10]], 20, "倍率"), + ([[None, 10]], .2, "有限")]) +def test_ratio_contract_verifies_value_and_subset(rows, value, problem): + definition = {"contract": {"ratio": {"numerator": "refunds", "denominator": "orders"}}} + found = contract_findings(definition, {"value": value}, [{"columns": ["refunds", "orders"], "rows": rows}]) + assert any(problem in p for p in found) if problem else not found + + +def test_contract_rejects_unknown_fields_and_nonfinite_bounds(): + for raw in ({"minimum": float("nan")}, {"shape": "scalar", "typo": True}, {"minimum": 4, "maximum": 3}): + with pytest.raises(ValueError): + MetricContract.model_validate(raw) + + +def test_value_range_contract_can_validate_non_query_inputs(): + definition = {"expression": "input.score", "contract": {"minimum": 0, "maximum": 100}} + assert contract_findings(definition, {"value": 85}, []) == [] + assert contract_findings(definition, {"value": float("nan")}, []) + assert contract_findings({"contract": {"shape": "scalar"}}, {"value": 85}, []) + + +@pytest.mark.parametrize("flag", ["incomplete", "no_data", "sql_check_failed"]) +def test_successful_execution_does_not_hide_data_quality_failure(flag): + metric = {"id": "n", "status": "ok", "value": 0, flag: True} + report = validation_report([metric], execution="succeeded", trusted=True) + assert report["status"] == "fail" and report["execution"] == "succeeded" + + +def test_unknown_comparability_and_unsealed_evidence_never_pass(): + metric = {"id": "n", "status": "ok", "value": 0} + assert validation_report([metric], execution="succeeded", trusted=True)["status"] == "pass" + assert validation_report([metric], execution="succeeded", trusted=False)["status"] == "unknown" + assert metric_quality({**metric, "comparability": {"verdict": "conditional"}})["status"] == "unknown" + assert metric_quality({**metric, "value": float("nan")})["status"] == "fail" + + +def scope(*results): + return SimpleNamespace(queries={"Q1": {}}, results={f"A{i}": r for i, r in enumerate(results)}) + + +def decomposition(*, depth=0, code="driver", caveats=None, share="0.9"): + return {"status": "ok", "op": "decompose", "mode": "additive", "params": { + "depth": depth, **({"parent": {"of": "A0", "key": ["east"]}} if depth else {})}, + "table": {"columns": ["region", "share"], "rows": [["east", share]]}, + "verdict": {"code": code}, "values": {"top_share": share}, "caveats": caveats or []} + + +def test_investigation_requires_closure_and_drill_before_completion(): + policy = InvestigationPolicy() + question = "销售额为什么下降" + assert assess(scope(), question, policy)["stage"] == "decompose" + assert assess(scope(decomposition(code="unclosed")), question, policy)["stage"] == "closure" + assert assess(scope(decomposition()), question, policy)["stage"] == "drill" + done = assess(scope(decomposition(), decomposition(depth=1)), question, policy) + assert done["status"] == "complete" and done["coverage"] == pytest.approx(.81) + unknown = assess(scope(decomposition(), decomposition(depth=1, caveats=[{"code": "comparability"}])), question, policy) + assert unknown["status"] == "partial" and unknown["stage"] == "comparability" + + +def test_local_drill_coverage_does_not_count_as_coverage_of_the_whole_change(): + found = assess(scope(decomposition(share="0.6"), decomposition(depth=1)), "销售额为什么下降", InvestigationPolicy()) + assert found["status"] == "partial" and found["stage"] == "coverage" + assert found["coverage"] == pytest.approx(.54) + unclosed = assess(scope(decomposition(code="unclosed"), decomposition(depth=1)), "为什么下降", InvestigationPolicy()) + assert unclosed["stage"] == "closure" + unlinked = assess(scope(decomposition(depth=1)), "为什么下降", InvestigationPolicy()) + assert unlinked["stage"] == "lineage" + + +def test_anomaly_activates_investigation_but_plain_value_request_does_not(): + policy = InvestigationPolicy() + assert not assess(scope(), "本周销售额是多少", policy)["active"] + anomaly = {"op": "baseline", "status": "ok", "verdict": {"code": "outside_band"}} + assert assess(scope(anomaly), "本周销售额是多少", policy)["active"] + assert not assess(scope(anomaly), "本周销售额是多少", InvestigationPolicy(enabled=False))["active"] + + +def test_eval_fingerprint_tracks_investigation_policy_changes(monkeypatch): + from app.engine import investigation + from app.eval.runner import prompt_fingerprint + + before = prompt_fingerprint() + monkeypatch.setattr(investigation, "INVESTIGATION_VERSION", investigation.INVESTIGATION_VERSION + 1) + assert prompt_fingerprint() != before + + +def test_investigation_eval_checks_depth_budget_and_stopping_reason(): + from app.eval.cases import _expectation + from app.eval.score import Answer, score_case + + exp = _expectation({"id": "i", "kind": "investigation", "minimum_depth": 1, "max_queries": 4}, + 0, gold_kind=None, taken=set()) + answer = Answer("run", investigations=[{"status": "complete", "depth": 1, "query_count": 3}]) + case = {"expectations": [exp]} + assert score_case(case, None, answer, path="ran").verdict == "pass" + answer.investigations[0]["query_count"] = 5 + assert score_case(case, None, answer, path="ran").verdict == "fail" + answer.investigations = [{"status": "partial", "depth": 0, "query_count": 2, "stop_reason": "evidence_insufficient"}] + partial = _expectation({"id": "i", "kind": "investigation", "status": "partial", + "stop_reason": "evidence_insufficient"}, 0, gold_kind=None, taken=set()) + assert score_case({"expectations": [partial]}, None, answer, path="ran").verdict == "pass" diff --git a/backend/tests/test_architecture_impact.py b/backend/tests/test_architecture_impact.py new file mode 100644 index 00000000..0c574aa3 --- /dev/null +++ b/backend/tests/test_architecture_impact.py @@ -0,0 +1,18 @@ +from app.apps.impact import dependents +from app.db.base import SessionLocal +from tests.test_app_versions import chain, node, workflow + + +async def test_impact_follows_caliber_and_subgraph_dependencies_transitively(): + root = await workflow({1: "published", 2: "published"}, current=2) + one = chain(node("in", "input"), node("card", "metrics", caliber_from={ + "workflow_id": root, "workflow_version": 1, "node_id": "card"})) + parent = await workflow({1: "published"}, current=1, graphs={1: one}) + two = chain(node("in", "input"), node("child", "subgraph", workflow_id=parent, workflow_version=1)) + grandparent = await workflow({1: "published"}, current=1, graphs={1: two}) + async with SessionLocal() as session: + impact = await dependents(session, root, 1) + unaffected = await dependents(session, root, 2) + found = {(r["workflow_id"], r["depth"]) for r in impact["workflows"]} + assert (parent, 1) in found and (grandparent, 2) in found + assert not unaffected["workflows"] diff --git a/backend/tests/test_architecture_investigation.py b/backend/tests/test_architecture_investigation.py new file mode 100644 index 00000000..d6c6720e --- /dev/null +++ b/backend/tests/test_architecture_investigation.py @@ -0,0 +1,52 @@ +"""真实 Agent 循环:模型提前结束时继续调查,不能补齐证据时明确降级。""" +from langchain_core.messages import AIMessage, HumanMessage, ToolMessage + +from app.core import artifact_store +from app.engine.evidence_reader import read_sealed +from app.eval.score import read_answer +from app.providers import mock_model +from tests.test_analysis_tools import (DECOMPOSE, DECOMPOSE_ARGS, SQL, TOOL, agent_graph, engine_up, + events, run_graph, shop) + + +async def test_runtime_continues_after_premature_answer_then_records_the_remaining_gap(monkeypatch, shop): + calls = [(TOOL, {"sql": SQL}), (DECOMPOSE, DECOMPOSE_ARGS)] + nudges = [] + + def decide(model, messages): + continued = [m for m in messages if isinstance(m, HumanMessage) and "调查尚未完成" in str(m.content)] + nudges[:] = continued + if not continued: + return AIMessage(content="销售额下降。") + done = sum(isinstance(m, ToolMessage) for m in messages) + if done < len(calls): + name, args = calls[done] + return AIMessage(content="", tool_calls=[{"id": f"c{done}", "name": name, "args": args}]) + return AIMessage(content="已按地区拆解,仍需核对总数。") + + monkeypatch.setattr(mock_model.MockChatModel, "_decide", decide) + row = await run_graph(agent_graph(max_steps=8)) + sealed = await read_sealed(row.id) + bot = artifact_store.load(sealed.outputs["bot"]) + assert len(nudges) == 2 + assert [c["tool"] for c in bot["tool_calls"]] == [TOOL, DECOMPOSE] + assert bot["investigation"]["status"] == "partial" + assert bot["investigation"]["stage"] == "closure" + assert bot["investigation"]["stop_reason"] == "evidence_insufficient" + assert "现有证据不足以确认根因" in bot["text"] + assert any(e.get("code") == "investigation_incomplete" for e in await events(row.id, "log", "bot")) + answer = await read_answer(row.id) + assert answer.investigations[0]["query_count"] == 1 + + +async def test_unfinished_investigation_cannot_receive_formal_issuance(monkeypatch, shop): + monkeypatch.setattr(mock_model.MockChatModel, "_decide", lambda *_: AIMessage(content="尚未取得证据。")) + graph = agent_graph(max_steps=4) + graph["nodes"].insert(-1, {"id": "card", "type": "metrics", "data": {"config": { + "caliber": "调查", "metrics": [{"id": "n", "expression": "1"}]}}}) + graph["edges"] = [{"source": a["id"], "target": b["id"]} + for a, b in zip(graph["nodes"], graph["nodes"][1:])] + graph["nodes"][-1]["data"]["config"]["contract"] = {"metrics_from": "card"} + row = await run_graph(graph) + assert row.output["_issuance"]["tier"] != "formal" + assert any("异常调查未完成" in g for g in row.output["_issuance"]["gaps"]) diff --git a/backend/tests/test_architecture_lifecycle.py b/backend/tests/test_architecture_lifecycle.py new file mode 100644 index 00000000..63ab935c --- /dev/null +++ b/backend/tests/test_architecture_lifecycle.py @@ -0,0 +1,176 @@ +from __future__ import annotations + +import asyncio +import copy +import json + +import pytest +from sqlalchemy import select + +from app.db.base import SessionLocal +from app.db.models import CaliberDraftJob, Workflow +from app.ledger import draftplan, jobs +from tests.test_ledger_docdraft_api import (client, datasource, documents, extract, fake_model, fake_trial, plan_reply) +from tests.test_caliber_regressions import draft + + +@pytest.mark.sqlite_only +async def test_concurrent_draft_edit_is_not_overwritten_by_trial_completion( + client, documents, datasource, monkeypatch): + from sqlalchemy.ext.asyncio import AsyncSession + + from app.api.ledger import _save_trial + from app.db.models import CaliberDraft + + body = await draft(client, documents, datasource, monkeypatch) + execute = AsyncSession.execute + raced = False + + async def concurrent(session, statement, *args, **kwargs): + nonlocal raced + if (not raced and getattr(statement, "is_update", False) + and statement.table.name == CaliberDraft.__tablename__): + raced = True + async with SessionLocal() as other: + row = await other.get(CaliberDraft, body["id"]) + row.payload = {**row.payload, "guidance": "刚提交的审阅意见"} + await other.commit() + return await execute(session, statement, *args, **kwargs) + + monkeypatch.setattr(AsyncSession, "execute", concurrent) + assert not await _save_trial(body["id"], {**body["trial"], "error": "较早试跑的结果"}) + assert raced + async with SessionLocal() as session: + current = await session.get(CaliberDraft, body["id"]) + assert current.payload["guidance"] == "刚提交的审阅意见" + assert current.payload["trial"] == body["trial"] + + +async def test_layout_save_keeps_trial_evidence_but_sql_change_invalidates(client, documents, datasource, monkeypatch): + body = await draft(client, documents, datasource, monkeypatch) + published = await client.post(f"/api/workflows/{body['workflow_id']}/publish", json={"level": "published"}) + assert published.status_code == 200 and published.json()["ok"] + async with SessionLocal() as session: + workflow = await session.get(Workflow, body["workflow_id"]) + graph = copy.deepcopy(workflow.graph) + graph["nodes"][0]["position"] = {"x": 100, "y": 200} + saved = await client.patch(f"/api/workflows/{body['workflow_id']}", json={"graph": graph}) + assert saved.status_code == 200 + assert saved.json()["same_as_published"] is True + result = (await client.get(f"/api/ledger/drafts/{body['id']}/trial")).json() + assert result["status"] == "succeeded" and result["version"] == 1 + fetch = next(n for n in graph["nodes"] if n["id"] == "fetch") + fetch["data"]["config"]["args"]["sql"] = "SELECT 9 AS refund_rate FROM orders" + changed = await client.patch(f"/api/workflows/{body['workflow_id']}", json={"graph": graph}) + assert changed.json()["same_as_published"] is False + result = (await client.get(f"/api/ledger/drafts/{body['id']}/trial")).json() + assert result["status"] == "stale" and result["metrics"] == [] + + +async def test_interrupted_authoring_resumes_completed_groups_only_for_same_material( + client, documents, datasource, monkeypatch): + doc = await documents("architecture-resume") + source = await datasource() + group = json.loads(plan_reply(f"{doc}-2")) + calls = [] + + async def interrupted(_session, **kwargs): + calls.append(list(kwargs["covered"])) + if len(calls) == 1: + return {"groups": [group], "uncovered": ["第二个指标"]} + raise asyncio.CancelledError + + monkeypatch.setattr(draftplan, "plan_groups", interrupted) + made = await extract(client, doc, source) + draft_id = made["draft_id"] + body = (await client.get(f"/api/ledger/drafts/{draft_id}")).json() + assert body["status"] == "failed" and body["job"]["status"] == "interrupted" + assert body["groups"] and body["material_hash"] + second = copy.deepcopy(group) + second["metrics"][0].update(id="m2", name="第二个指标") + + async def resume(_session, **kwargs): + assert kwargs["covered"] == [group["metrics"][0]["name"]] + return {"groups": [second], "uncovered": []} + + monkeypatch.setattr(draftplan, "plan_groups", resume) + reply = await client.post(f"/api/ledger/drafts/{draft_id}/retry", json={}) + assert reply.status_code == 200, reply.text + body = (await client.get(f"/api/ledger/drafts/{draft_id}")).json() + assert body["status"] == "ready" and len(body["metrics"]) == 2 + assert body["job"]["attempt"] == 2 and body["job"]["status"] == "succeeded" + assert (await client.post(f"/api/ledger/drafts/{draft_id}/retry", json={})).status_code == 409 + + +async def test_parallel_group_repairs_have_independent_sessions(client, documents, datasource, monkeypatch): + doc = await documents("architecture-sessions") + source = await datasource() + group = json.loads(plan_reply(f"{doc}-2")) + broken = [{**copy.deepcopy(group), "sql": "SELECT * FROM unknown_table"} for _ in range(2)] + broken[1]["metrics"][0].update(id="m2", name="另一个指标") + sessions = [] + + async def plan(_session, **kwargs): + return {"groups": broken, "uncovered": []} + + async def repair(session, **kwargs): + sessions.append(session) + await session.execute(select(Workflow.id).limit(1)) + await asyncio.sleep(.01) + return {**kwargs["group"], "sql": group["sql"]} + + monkeypatch.setattr(draftplan, "plan_groups", plan) + monkeypatch.setattr(draftplan, "repair_group", repair) + made = await extract(client, doc, source) + body = (await client.get(f"/api/ledger/drafts/{made['draft_id']}")).json() + assert len(sessions) == 2 and sessions[0] is not sessions[1] + assert body["status"] == "ready" and len(body["metrics"]) == 2 + + +async def test_startup_marks_orphaned_job_interrupted_and_preserves_progress(client, documents, datasource, monkeypatch): + from app.db.models import CaliberDraft + + async with SessionLocal() as session: + row = CaliberDraft(title="中断任务", payload={"progress": {"round": 1}}, status="drafting") + session.add(row) + await session.flush() + session.add(CaliberDraftJob(id=row.id, status="running")) + await session.commit() + draft_id = row.id + await jobs.startup() + body = (await client.get(f"/api/ledger/drafts/{draft_id}")).json() + assert body["status"] == "failed" and body["job"]["status"] == "interrupted" + assert body["progress"]["round"] == 1 and body["job"]["retryable"] + + +async def test_stop_api_cancels_the_worker_and_preserves_checkpoint(client, monkeypatch): + from app.db.models import CaliberDraft + + checkpoint = {"groups": [{"metrics": [{"id": "m1", "name": "已完成指标"}]}]} + async with SessionLocal() as session: + row = CaliberDraft(title="停止任务", payload={"checkpoint": checkpoint}, status="drafting") + session.add(row) + await session.flush() + session.add(CaliberDraftJob(id=row.id)) + await session.commit() + draft_id = row.id + entered = asyncio.Event() + + async def generating(_draft_id): + entered.set() + await asyncio.Event().wait() + + monkeypatch.setattr(jobs, "executor", generating) + jobs.manager.start(draft_id) + try: + await asyncio.wait_for(entered.wait(), 3) + edit = await client.patch(f"/api/ledger/drafts/{draft_id}", json={"metrics": []}) + assert edit.status_code == 409, edit.text + result = await client.post(f"/api/ledger/drafts/{draft_id}/stop", json={}) + assert result.status_code == 200, result.text + body = result.json() + assert body["status"] == "failed" and body["job"]["status"] == "cancelled" + assert body["checkpoint"] == checkpoint and body["job"]["retryable"] + assert not jobs.manager.running(draft_id) + finally: + await jobs.manager.stop(draft_id, "user") diff --git a/backend/tests/test_caliber_from_nodes.py b/backend/tests/test_caliber_from_nodes.py index b7285c9b..882b41b1 100644 --- a/backend/tests/test_caliber_from_nodes.py +++ b/backend/tests/test_caliber_from_nodes.py @@ -155,7 +155,7 @@ async def test_old_graphs_without_the_mapping_are_unchanged(): card = artifact_store.load(entry["artifact"]) assert card["source"] == pinned and entry["source"] == pinned assert [m["expression"] for m in card["metrics"]] == ["cell(nodes.fetch,0,'gmv')"] - assert set(card) == {"kind", "caliber", "caliber_version", "definition_hash", "metrics", "source"} + assert set(card) == {"kind", "caliber", "caliber_version", "definition_hash", "semantic_hash", "validation_version", "metrics", "source"} assert card["definition_hash"] diff --git a/backend/tests/test_caliber_regressions.py b/backend/tests/test_caliber_regressions.py new file mode 100644 index 00000000..221cd910 --- /dev/null +++ b/backend/tests/test_caliber_regressions.py @@ -0,0 +1,337 @@ +"""口径审查回归:走真实保存/发布接口和隔离数据库,验证版本、只读试跑、质量门禁和组合复用。""" +from __future__ import annotations + +import copy +import json +import sqlite3 +import uuid + +import pytest +from sqlalchemy import select + +from app.api import ledger as api_ledger +from app.db.base import SessionLocal +from app.db.models import CaliberDraft, DataSource, Run, Workflow, WorkflowVersion +from app.engine.runner import run_manager +from app.engine.schema import GraphSpec, validate_graph +from app.ledger import draftplan +from tests import ledger_samples as S +from tests.test_ledger_docdraft_api import ( + REAL_RUN_TRIAL, client, datasource, documents, extract, fake_model, fake_trial, groups_reply, plan_reply, +) +from tests.test_runtime_sqlcheck import ( + FANOUT_SQL, _drop_created_sources, _source, engine_up, output_of, +) +from tests.test_metric_no_data import plant, board as no_data_board + + +def cfg(graph, nid): + return next(n for n in graph["nodes"] if n["id"] == nid)["data"]["config"] + + +async def draft(client, documents, datasource, monkeypatch, *, multi=False): + doc = await documents("regression-" + uuid.uuid4().hex) + source = await datasource() + one = json.loads(plan_reply(doc + "-2")) + if multi: + two = copy.deepcopy(one) + two["metrics"][0].update(id="m2", name="订单金额", expression="cell(nodes.fetch, 0, 'gmv')") + two["sql"] = "SELECT SUM(amount) AS gmv FROM orders WHERE store = '{{ input.store }}'" + reply = groups_reply(one, two) + else: + reply = json.dumps(one, ensure_ascii=False) + fake_model(monkeypatch, reply) + created = await extract(client, doc, source) + got = await client.get("/api/ledger/drafts/" + created["draft_id"]) + assert got.status_code == 200, got.text + body = got.json() + assert body["status"] == "ready", body + return body + + +async def snapshots(wid): + async with SessionLocal() as session: + workflow = await session.get(Workflow, wid) + versions = list((await session.execute(select(WorkflowVersion).where( + WorkflowVersion.workflow_id == wid).order_by(WorkflowVersion.version))).scalars()) + return workflow, versions + + +async def test_draft_edit_preserves_published_snapshot_and_updates_canvas(client, documents, datasource, monkeypatch): + body = await draft(client, documents, datasource, monkeypatch) + wid, did = body["workflow_id"], body["id"] + published = await client.post(f"/api/workflows/{wid}/publish", json={"level": "published"}) + assert published.status_code == 200 and published.json()["ok"], published.text + _, before = await snapshots(wid) + metrics = copy.deepcopy(body["metrics"]) + metrics[0]["expression"] += " * 100" + changed = await client.patch(f"/api/ledger/drafts/{did}", json={"metrics": metrics}) + assert changed.status_code == 200, changed.text + workflow, after = await snapshots(wid) + assert [v.version for v in after] == [1, 2] + assert after[0].level == "published" and after[0].graph == before[0].graph + assert after[0].graph_hash == before[0].graph_hash + assert after[1].level is None and workflow.published_version == 1 and workflow.status == "draft" + assert cfg(after[1].graph, "card")["metrics"][0]["expression"].endswith("* 100") + assert workflow.graph == after[1].graph and changed.json()["version"] == 2 + + +async def test_trial_is_invalidated_by_edit_and_late_results_cannot_replace_new_trial( + client, documents, datasource, monkeypatch): + body = await draft(client, documents, datasource, monkeypatch) + did = body["id"] + metrics = copy.deepcopy(body["metrics"]) + metrics[0]["expression"] += " * 100" + changed = (await client.patch(f"/api/ledger/drafts/{did}", json={"metrics": metrics})).json() + assert changed["trial"]["status"] == "stale" + assert not next(c for c in changed["checks"] if c["key"] == "trial")["ok"] + old = (await client.get(f"/api/ledger/drafts/{did}/trial")).json() + assert old["status"] == "stale" and old["metrics"] == [] and old["queries"] == [] + first = (await client.post(f"/api/ledger/drafts/{did}/trial", json={"inputs": {}})).json() + latest = (await client.post(f"/api/ledger/drafts/{did}/trial", json={"inputs": {"store": "east"}})).json() + assert first["version"] == latest["version"] == 2 + assert first["inputs_hash"] != latest["inputs_hash"] + await api_ledger._save_trial(did, body["trial"]) + await api_ledger._save_trial(did, first) + current = (await client.get(f"/api/ledger/drafts/{did}")).json() + assert current["trial"]["run_id"] == latest["run_id"] + assert current["trial"]["status"] == "succeeded" + assert next(c for c in current["checks"] if c["key"] == "trial")["status"] == "unknown" + + +async def test_pending_trial_failure_cannot_replace_a_newer_attempt(client, documents, datasource, monkeypatch): + body = await draft(client, documents, datasource, monkeypatch) + first = {**body["trial"], "trial_id": "first", "run_id": "", "status": "running"} + second = {**first, "trial_id": "second"} + assert await api_ledger._save_trial(body["id"], first, replace=True) + assert await api_ledger._save_trial(body["id"], second, replace=True) + assert not await api_ledger._save_trial(body["id"], {**first, "status": "failed"}) + current = (await client.get(f"/api/ledger/drafts/{body['id']}/trial")).json() + assert current["trial_id"] == "second" and current["status"] == "running" + + +async def test_metric_edits_preserve_canvas_sql_and_node_ids(client, documents, datasource, monkeypatch): + body = await draft(client, documents, datasource, monkeypatch, multi=True) + wid, did = body["workflow_id"], body["id"] + workflow, _ = await snapshots(wid) + graph = copy.deepcopy(workflow.graph) + cfg(graph, "fetch_2")["args"]["sql"] = "SELECT SUM(amount) * 2 AS gmv FROM orders" + cfg(graph, "start")["fields"].append({"name": "region", "type": "string", "required": True}) + saved = await client.patch(f"/api/workflows/{wid}", json={"graph": graph}) + assert saved.status_code == 200, saved.text + kept = copy.deepcopy(body["metrics"][1]) + kept["name"] = "编辑第二组" + patched = await client.patch(f"/api/ledger/drafts/{did}", json={"metrics": [kept]}) + assert patched.status_code == 200, patched.text + workflow, versions = await snapshots(wid) + latest = versions[-1].graph + assert workflow.version == 3 and workflow.graph == latest + assert cfg(latest, "fetch_2") == cfg(graph, "fetch_2") + assert cfg(latest, "start") == cfg(graph, "start") + assert cfg(latest, "card")["metrics"][0]["expression"] == kept["expression"] + assert validate_graph(GraphSpec.model_validate(latest)).ok + assert cfg(latest, "out")["contract"]["required"] == [kept["id"]] + empty = await client.patch(f"/api/ledger/drafts/{did}", json={"metrics": []}) + assert empty.status_code == 200 and empty.json()["metrics"] == [], empty.text + + +async def test_draft_list_counts_current_canvas_metrics(client, documents, datasource, monkeypatch): + body = await draft(client, documents, datasource, monkeypatch) + workflow, _ = await snapshots(body["workflow_id"]) + graph = copy.deepcopy(workflow.graph) + metrics = cfg(graph, "card")["metrics"] + metrics.append({**metrics[0], "id": "m2", "name": "另一个指标"}) + saved = await client.patch(f"/api/workflows/{workflow.id}", json={"graph": graph}) + assert saved.status_code == 200 + items = (await client.get("/api/ledger/drafts")).json()["drafts"] + assert next(item for item in items if item["id"] == body["id"])["metric_count"] == 2 + + +@pytest.mark.parametrize("draft_trial", [False, True]) +async def test_trials_never_write_even_when_source_is_writable_and_approval_disabled( + client, tmp_path, engine_up, monkeypatch, draft_trial): + path = tmp_path / "writable.db" + name = await S.fresh_source(path) + async with SessionLocal() as session: + source = (await session.execute(select(DataSource).where(DataSource.name == name))).scalar_one() + source.readonly = False + await session.commit() + graph = S.caliber_graph(sql="UPDATE orders SET amount = amount + 1 WHERE id = 1 RETURNING amount AS gmv") + cfg(graph, "fetch")["approval"] = "never" + wid = await S.publish(name="writable caliber " + uuid.uuid4().hex, graph=graph, level="published") + published = await client.post(f"/api/workflows/{wid}/publish", json={"level": "published"}) + assert published.status_code == 200 and published.json()["ok"], published.text + if draft_trial: + async with SessionLocal() as session: + workflow = await session.get(Workflow, wid) + workflow.origin = {"kind": "doc_draft"} + row = CaliberDraft(title="只读回归", workflow_id=wid, status="ready", payload={"version": 1}) + session.add(row) + await session.commit() + did = row.id + monkeypatch.setattr(api_ledger, "_run_trial", REAL_RUN_TRIAL) + url = f"/api/ledger/drafts/{did}/trial" + else: + url = f"/api/ledger/calibers/{wid}/1/card/trial" + result = await client.post(url, json={"inputs": {"start": "2026-09-01", "end": "2026-09-08"}}) + assert result.status_code == 200, result.text + assert result.json()["status"] == "failed", result.text + with sqlite3.connect(path) as db: + assert db.execute("SELECT amount FROM orders WHERE id = 1").fetchone()[0] == 100 + async with SessionLocal() as session: + run = await session.get(Run, result.json()["run_id"]) + assert run.options["read_only_data"] is True + + +def definition(kind, path="cell(nodes.fetch, 0, 'gmv')"): + if kind == "rule": + return {"kind": "rule", "rule": {"inputs": {"x": path}, + "all": [{"input": "x", "op": "gte", "value": 50}], "then": 1, "else": 0}} + if kind == "assessment": + return {"kind": "assessment", "assessment": { + "dimensions": [{"name": "x", "input": path, "min": 0, "max": 100000000}], + "levels": [{"name": "normal", "min": 0}]}} + return {"expression": path} + + +@pytest.mark.parametrize("kind", ["formula", "rule", "assessment"]) +async def test_all_metric_kinds_degrade_when_the_query_fails_sql_checks(scenic_db, engine_up, kind): + _, source = await _source(scenic_db) + graph = S.chain( + S.node("start", "input"), + S.node("fetch", "tool", tool=f"db_query__{source}", args={"sql": FANOUT_SQL}), + S.node("card", "metrics", caliber="review", metrics=[{"id": "m", "name": "metric", **definition(kind)}]), + S.node("out", "output", fields=[{"name": "value", "value": "{{ nodes.card.text }}"}], + contract={"metrics_from": ["card"], "required": ["m"]}), + ) + started = await run_manager.start(graph=graph, input_payload={}) + run = await S.wait(started.id) + assert run.status == "succeeded", run.error + metric = (await output_of(run.id, "card"))["metrics"][0] + assert metric["sql_check_failed"] is True + assert run.output["_issuance"]["tier"] == "degraded" + assert any("SQL 检查" in gap for gap in run.output["_issuance"]["gaps"]) + + +@pytest.mark.parametrize("first,second", [(0, 100), (False, True), ("2026-09-01", "2026-10-01")]) +def test_conflicting_defaults_require_explicit_input_even_after_more_groups(first, second): + blocks = [{"entry_fields": [{"name": "scope", "type": "string", "default": value}]} + for value in (first, second, second)] + fields, notes = draftplan.merge_entry_fields(blocks) + assert "default" not in fields[0] and fields[0]["required"] is True + assert any("默认值不一致" in note for note in notes) + graph = draftplan.build_graph(blocks, name="conflict") + assert cfg(graph, "start")["fields"] == fields + fields, notes = draftplan.merge_entry_fields([blocks[0], blocks[0]]) + assert fields[0]["default"] == first and not notes + + +async def test_real_trial_runs_current_canvas_version(client, documents, datasource, monkeypatch, tmp_path, engine_up): + body = await draft(client, documents, datasource, monkeypatch) + path = tmp_path / "draft.db" + with sqlite3.connect(path) as db: + db.execute("CREATE TABLE orders (status TEXT, store TEXT, amount REAL, paid_at TEXT)") + db.execute("INSERT INTO orders VALUES ('paid', '', 5, '2026-01-01')") + async with SessionLocal() as session: + source = await session.get(DataSource, body["datasource_id"]) + source.database, source.readonly, source.enabled = str(path), True, True + await session.commit() + workflow, _ = await snapshots(body["workflow_id"]) + graph = copy.deepcopy(workflow.graph) + cfg(graph, "card")["metrics"][0]["expression"] += " * 100" + saved = await client.patch("/api/workflows/" + body["workflow_id"], json={"graph": graph}) + assert saved.status_code == 200 and saved.json()["version"] == 2, saved.text + monkeypatch.setattr(api_ledger, "_run_trial", REAL_RUN_TRIAL) + result = await client.post("/api/ledger/drafts/" + body["id"] + "/trial", json={"inputs": {}}) + assert result.status_code == 200 and result.json()["status"] == "succeeded", result.text + assert result.json()["metrics"][0]["value"] == 100 + async with SessionLocal() as session: + run = await session.get(Run, result.json()["run_id"]) + assert run.origin["version"] == result.json()["version"] == 2 + current = (await client.get("/api/ledger/drafts/" + body["id"])).json() + assert next(c for c in current["checks"] if c["key"] == "trial")["status"] == "pass" + + +@pytest.mark.parametrize("kind", ["rule", "assessment"]) +async def test_prefixed_reuse_preserves_nonformula_inputs(kind, tmp_path, engine_up): + from app.ledger.derive import assemble, caliber_block, caliber_probe, load_pinned + + await S.fresh_source(tmp_path / (kind + ".db")) + graph = S.caliber_graph(sql="SELECT amount AS gmv FROM orders WHERE id = 1") + cfg(graph, "card")["metrics"] = [{"id": "m", "name": "metric", **definition(kind)}] + # 编辑器切类型会保留旧公式;依赖分析只认当前类型,不把未使用的旧输入也拷进来。 + cfg(graph, "card")["metrics"][0]["expression"] = "cell(nodes.former_fetch, 0, 'gmv')" + original_graph = copy.deepcopy(graph) + wid = await S.publish(name="regression " + kind, graph=graph) + async with SessionLocal() as session: + pinned = await load_pinned(session, wid, 1, "card") + inputs = {"start": "2026-09-01", "end": "2026-09-08"} + original = await run_manager.start(graph=caliber_probe(pinned).graph, input_payload=inputs) + direct = await S.wait(original.id) + combined = assemble("caliber_probe", [caliber_block(pinned, prefix="c1_")], fields=pinned.entry_fields) + renamed = await run_manager.start(graph=combined.graph, input_payload=inputs) + reused = await S.wait(renamed.id) + assert direct.status == reused.status == "succeeded", reused.error + before = (await output_of(direct.id, "card"))["metrics"][0] + after = (await output_of(reused.id, "c1_card"))["metrics"][0] + assert after["value"] == before["value"] + assert all("c1_fetch" in item["path"] for item in after["inputs"]) + _, versions = await snapshots(wid) + assert versions[0].graph == original_graph + + +@pytest.mark.parametrize("kind", ["rule", "assessment"]) +async def test_nonformula_metrics_keep_no_data_gaps(kind, plant, engine_up): + graph = no_data_board(plant) + cfg(graph, "card")["metrics"] = [{"id": "m", "name": "metric", + **definition(kind, "cell(nodes.q_kpi, 0, '上周到访人数')")}] + cfg(graph, "out")["contract"]["required"] = ["m"] + started = await run_manager.start(graph=graph, input_payload={}) + run = await S.wait(started.id) + assert run.status == "succeeded", run.error + metric = (await output_of(run.id, "card"))["metrics"][0] + assert metric["no_data"] is True and metric["no_data_sources"] + assert run.output["_issuance"]["tier"] == "degraded" + + +@pytest.mark.parametrize("kind", ["rule", "assessment"]) +@pytest.mark.parametrize("copied", [False, True]) +async def test_nonformula_metrics_keep_truncation_gaps(kind, copied, tmp_path, engine_up): + await S.fresh_source(tmp_path / "truncated.db") + nodes = [S.node("start", "input"), + S.node("fetch", "tool", tool=S.tool(), args={"sql": "SELECT id FROM orders ORDER BY id", "limit": 2}), + S.node("parse", "transform", mode="json", template="{{ nodes.fetch }}", assign_to="rows")] + path = "len(vars.rows.rows)" + if copied: + nodes.append(S.node("upstream", "metrics", metrics=[{"id": "n", "expression": path}])) + path = "nodes.upstream.metrics[0].value" + nodes.extend([ + S.node("card", "metrics", caliber="truncated", metrics=[{"id": "m", "name": "metric", **definition(kind, path)}]), + S.node("out", "output", fields=[{"name": "value", "value": "{{ nodes.card.text }}"}], + contract={"metrics_from": ["card"], "required": ["m"]}), + ]) + started = await run_manager.start(graph=S.chain(*nodes), input_payload={}) + run = await S.wait(started.id) + assert run.status == "succeeded", run.error + metric = (await output_of(run.id, "card"))["metrics"][0] + assert metric["incomplete"] is True and metric["truncated_sources"] + assert run.output["_issuance"]["tier"] == "degraded" + + +async def test_a_stale_writer_cannot_overwrite_a_new_workflow_revision(): + from app.apps.versions import WorkflowChanged, save_graph + + graph = S.chain(S.node("start", "input"), S.node("out", "output")) + wid = await S.publish(name="concurrent " + uuid.uuid4().hex, graph=graph) + async with SessionLocal() as first, SessionLocal() as second: + stale = await first.get(Workflow, wid) + current = await second.get(Workflow, wid) + changed = copy.deepcopy(graph) + cfg(changed, "out")["fields"] = [{"name": "value", "value": "latest"}] + assert await save_graph(second, current, changed) + await second.commit() + with pytest.raises(WorkflowChanged): + await save_graph(first, stale, {**graph, "viewport": {"x": 1, "y": 1}}) + await first.rollback() + workflow, versions = await snapshots(wid) + assert workflow.graph == changed and len(versions) == 2 diff --git a/backend/tests/test_contract_analysis_claims.py b/backend/tests/test_contract_analysis_claims.py index 1b5859df..f10215d7 100644 --- a/backend/tests/test_contract_analysis_claims.py +++ b/backend/tests/test_contract_analysis_claims.py @@ -2,7 +2,7 @@ - 分析说法:出口按报告文档记下的那一项独立再核一遍(verify_doc)。strict 有违规记一条缺口(降档),报告节点报上来的同一条 不重复收;check 只写进出具声明(analysis_claims,counted 为 false),不影响档位;没记这一项的老文档不核 -- 可比性:报告引用了的、契约必需的指标两期不可比记缺口(降档),有条件可比的只写进出具声明(comparability) +- 可比性:报告引用了的、契约必需的指标两期不可比或有条件可比都记缺口(降档),并写进出具声明(comparability) - 血缘:报告引用的指标([[m:]] 片段、句末 [[see:m:…]])输入追不到原始数据的,受管级别的正式运行记缺口,其余写进出具声明的 untraced(counted 为 false) """ @@ -190,7 +190,7 @@ def test_the_exit_policy_takes_the_stricter_of_the_doc_and_the_node(): # -------------------------------------------------------------------------- -def test_comparability_gaps_only_for_incomparable_metrics(): +def test_comparability_gaps_for_incomparable_and_conditional_metrics(): metrics = [ {"id": "wow", "name": "环比增幅", "comparability": {"verdict": "incomparable", "phrase": "两期不可比:本期只有 3 天数据", "reasons": ["本期只有 3 天数据"], "artifact": "c" * 64}}, @@ -200,7 +200,7 @@ def test_comparability_gaps_only_for_incomparable_metrics(): {"id": "orders", "name": "订单数"}, ] gaps, declared = _comparability(metrics + metrics[:1]) - assert gaps == ["指标「环比增幅」两期不可比:本期只有 3 天数据"] + assert gaps == ["指标「环比增幅」两期不可比:本期只有 3 天数据", "指标「客单价」两期有条件可比:两段的周末天数不同:4 天对 6 天"] assert [(d["metric"], d["verdict"]) for d in declared] == [("wow", "incomparable"), ("aov", "conditional")] assert declared[0]["reasons"] == ["本期只有 3 天数据"] and declared[0]["artifact"] == "c" * 64 @@ -222,7 +222,7 @@ def comparability(monkeypatch): return marks -async def test_a_cited_incomparable_metric_degrades_and_a_conditional_one_is_noted(monkeypatch, comparability): +async def test_a_cited_incomparable_or_conditional_metric_degrades(monkeypatch, comparability): comparability["wow"] = {"verdict": "incomparable", "phrase": "两期不可比:本期只有 3 天数据", "reasons": ["本期只有 3 天数据"]} writes(monkeypatch, "本周销售额 [[m:gmv]],环比 [[m:wow]]。[[see:m:gmv,m:wow]]") @@ -231,7 +231,7 @@ async def test_a_cited_incomparable_metric_degrades_and_a_conditional_one_is_not assert [c["metric"] for c in issuance["comparability"]] == ["wow"] comparability["wow"] = {"verdict": "conditional", "phrase": "两期有条件可比:两段的周末天数不同"} issuance = await issued(weekly()) - assert issuance["tier"] == "formal" and issuance["comparability"][0]["verdict"] == "conditional" + assert issuance["tier"] == "degraded" and issuance["comparability"][0]["verdict"] == "conditional" async def test_an_incomparable_metric_nobody_cites_or_requires_is_left_alone(monkeypatch, comparability): diff --git a/backend/tests/test_eval_analysis.py b/backend/tests/test_eval_analysis.py index 25cefb34..64b4baf5 100644 --- a/backend/tests/test_eval_analysis.py +++ b/backend/tests/test_eval_analysis.py @@ -368,7 +368,12 @@ async def test_an_estimate_that_cannot_be_computed_still_marks_the_calls_unprice async def no_estimate(*args, **kwargs): return None + async def no_sources(*args, **kwargs): + return [] + monkeypatch.setattr(runner, "estimate", no_estimate) + # 这道反问题只验证记账;不把其他测试留下的数据源纳入目录快照,以免先触发数据源数量上限。 + monkeypatch.setattr(runner.EvalExecution, "_sources", no_sources) before = await eval_spend.daily() suite_id = await es.make_suite("none", cases=[{"question": "问 1", "expectations": [{"kind": "ask"}]}], budget={"max_parallel": 1}) diff --git a/backend/tests/test_job_runtime.py b/backend/tests/test_job_runtime.py index 742e3fe2..76a3710a 100644 --- a/backend/tests/test_job_runtime.py +++ b/backend/tests/test_job_runtime.py @@ -77,3 +77,28 @@ def test_a_job_without_a_lane_skips_the_queue(): run = _NoLane("whatever") assert run.lane_of(object()) is None + + +def test_starting_an_active_job_twice_does_not_orphan_the_first_task(): + async def go(): + calls = [] + finish = asyncio.Event() + + class Job: + def __init__(self, job_id): + self.job_id, self.task = job_id, None + + async def run(self): + calls.append(self.job_id) + await finish.wait() + + manager = job_runtime.JobManager(Job, label="幂等启动测试", name="test_start_once") + manager.start("one") + manager.start("one") + await asyncio.sleep(0) + assert calls == ["one"] and manager.running("one") + finish.set() + await manager.wait("one") + assert not manager.running("one") + + asyncio.run(go()) diff --git a/backend/tests/test_ledger_descriptor.py b/backend/tests/test_ledger_descriptor.py index 324d0921..c15d65cf 100644 --- a/backend/tests/test_ledger_descriptor.py +++ b/backend/tests/test_ledger_descriptor.py @@ -143,3 +143,15 @@ async def test_metric_synonyms_reach_the_descriptor(client): [made] = await descriptors(client, wf) [metric] = made["metrics"] assert metric["synonyms"] == ["月活", "MAU"] + + +async def test_metric_constraints_and_semantic_identity_reach_the_descriptor(client): + graph = S.caliber_graph() + definition = {"shape": "scalar", "minimum": 0, "unit": "元"} + for node in graph["nodes"]: + if node["type"] == "metrics": + node["data"]["config"]["metrics"][0].update(contract=definition, grain="汇总", time_field="支付时间") + [made] = await descriptors(client, await S.publish(graph=graph)) + [metric] = made["metrics"] + assert metric["contract"] == definition and metric["grain"] == "汇总" + assert metric["time_field"] == "支付时间" and made["semantic_hash"] diff --git a/backend/tests/test_ledger_docdraft_api.py b/backend/tests/test_ledger_docdraft_api.py index 85292c83..9bc5e150 100644 --- a/backend/tests/test_ledger_docdraft_api.py +++ b/backend/tests/test_ledger_docdraft_api.py @@ -57,6 +57,16 @@ def plan_reply(chunk_id: str, *, questions=None, sql: str | None = None, metrics }, ensure_ascii=False) +def groups_reply(*plans: dict) -> str: + """一次交多组:每组沿用 plan_reply 的 sql / entry_fields / metrics。""" + return json.dumps({ + "groups": [{"sql": plan["sql"], "entry_fields": plan["entry_fields"], "metrics": plan["metrics"]} + for plan in plans], + "open_questions": [], + "uncovered": [], + }, ensure_ascii=False) + + class FakeModel: """起草模型:ainvoke 返回带 .content 的对象;按顺序交回复,交完重复最后一份。""" @@ -96,16 +106,19 @@ async def fake_trial(monkeypatch): """试跑换成写一行 succeeded 的运行:不启动引擎、不查库,接口和 payload 的形状照真的一样。""" made: list[str] = [] - async def start(*, draft_id, workflow_id, graph, values, started_by, entry_fields=()): + async def start(*, draft_id, workflow_id, graph, values, started_by, entry_fields=(), version=1): run_id = f"run-draft-{uuid.uuid4().hex[:10]}" async with SessionLocal() as session: session.add(Run(id=run_id, workflow_id=workflow_id, status="succeeded", graph=graph, input=values, run_class="exploratory", origin_kind="caliber_trial", origin={"kind": "caliber_trial", "draft_id": draft_id, - "workflow_id": workflow_id, "version": 1})) + "workflow_id": workflow_id, "version": version})) await session.commit() made.append(run_id) - return {"run_id": run_id, "status": "succeeded", "error": ""} + trial = {**api_ledger._trial_binding(version, graph, values), + "run_id": run_id, "status": "succeeded", "error": ""} + await api_ledger._save_trial(draft_id, trial, replace=True) + return trial monkeypatch.setattr(api_ledger, "_run_trial", start) yield made @@ -213,6 +226,10 @@ async def test_extract_produces_a_ready_draft_with_a_graph_and_a_trial(client, d assert made["alignment"]["new"] == ["已支付退款率"] assert made["truncated"] is False and made["model_id"] == "fake-model" assert made["datasource_id"] == source_id + # 发布前检查:五项都从图和试跑状态算,不另起一份判断 + assert {check["key"] for check in made["checks"]} == {"graph", "sql", "entries", "trial", "citations"} + assert all(check["ok"] for check in made["checks"] if check["key"] != "trial"), made["checks"] + assert next(check for check in made["checks"] if check["key"] == "trial")["status"] == "unknown" # 工作流和 v1 已建:合法的草稿图,取数指向这个数据源 async with SessionLocal() as session: @@ -225,10 +242,27 @@ async def test_extract_produces_a_ready_draft_with_a_graph_and_a_trial(client, d assert [n["id"] for n in workflow.graph["nodes"]] == ["start", "fetch", "card", "write", "out"] assert validate_graph(GraphSpec.model_validate(workflow.graph)).ok - # 试跑只读接口:契约里的形状 + # 试跑只读接口:契约里的形状(queries 是取数节点交回的查询快照;这次是假试跑,没有快照) trial = (await client.get(f"/api/ledger/drafts/{body['draft_id']}/trial")).json() - assert set(trial) == {"run_id", "status", "metrics", "error"} + assert set(trial) == {"run_id", "status", "metrics", "queries", "error", "version", "graph_hash", "execution_hash", "inputs_hash", "validation"} assert trial["run_id"] == made["trial"]["run_id"] and trial["status"] == "succeeded" + assert trial["queries"] == [] + + +async def test_publish_checks_point_at_an_entry_field_the_sql_never_uses(client, documents, datasource, monkeypatch): + """发布前检查照实说:声明了入口但 SQL 没用,也算不一致;其他项不跟着判失败。""" + doc = await documents("dd-checks") + source_id = await datasource() + plan = json.loads(plan_reply(f"{doc}-2", sql="SELECT COUNT(*) * 1.0 AS refund_rate FROM orders " + "WHERE status = 'paid'")) + fake_model(monkeypatch, json.dumps(plan, ensure_ascii=False)) + draft_id = (await extract(client, doc, source_id))["draft_id"] + + made = (await client.get(f"/api/ledger/drafts/{draft_id}")).json() + by_key = {check["key"]: check for check in made["checks"]} + assert by_key["entries"]["ok"] is False and "store" in by_key["entries"]["detail"] + assert by_key["graph"]["ok"] and by_key["sql"]["ok"] and by_key["citations"]["ok"] + assert by_key["trial"]["status"] == "unknown" async def test_open_questions_keep_the_draft_in_questions_open_but_still_run_it( @@ -240,6 +274,7 @@ async def test_open_questions_keep_the_draft_in_questions_open_but_still_run_it( body = await extract(client, doc, source_id) made = (await client.get(f"/api/ledger/drafts/{body['draft_id']}")).json() assert made["status"] == "questions_open" + assert made["stage"] == "review" assert made["open_questions"] == ["退款算在哪一天"] assert made["workflow_id"] and made["sql"] assert made["trial"]["status"] == "succeeded" @@ -318,6 +353,15 @@ async def test_patch_replaces_metrics_and_clears_resolved_questions(client, docu assert [m["name"] for m in made["metrics"]] == ["已支付退款率(按支付日)"] assert made["open_questions"] == [] and made["question_count"] == 0 + # PATCH 和画布一样新增版本,读取新版本而不是覆盖 v1 + async with SessionLocal() as session: + draft_row = await session.get(CaliberDraft, draft_id) + version_row = (await session.execute(select(WorkflowVersion).where( + WorkflowVersion.workflow_id == draft_row.workflow_id, + WorkflowVersion.version == made["version"]))).scalars().one() + card = next(n for n in version_row.graph["nodes"] if n["type"] == "metrics") + assert [m["name"] for m in card["data"]["config"]["metrics"]] == ["已支付退款率(按支付日)"] + # 结构不对的指标 422,草稿不动 bad = await client.patch(f"/api/ledger/drafts/{draft_id}", json={"metrics": [{"synonyms": ["x"]}]}) assert bad.status_code == 422 @@ -330,6 +374,94 @@ async def test_patch_replaces_metrics_and_clears_resolved_questions(client, docu assert res.json()["status"] == "questions_open" and res.json()["question_count"] == 1 +async def test_draft_view_reads_the_graph_so_canvas_edits_show_up(client, documents, datasource, monkeypatch): + """画布改了指标名和取数 SQL 之后,草稿页从图里读最新的那几项,不再显示起草时的 payload 快照。""" + doc = await documents("dd-live") + source_id = await datasource() + fake_model(monkeypatch, plan_reply(f"{doc}-2")) + draft_id = (await extract(client, doc, source_id))["draft_id"] + + # 必须走画布的真实保存接口:它新增 v2,不能直接覆盖 v1 来模拟 + async with SessionLocal() as session: + draft = await session.get(CaliberDraft, draft_id) + row = (await session.execute(select(WorkflowVersion).where( + WorkflowVersion.workflow_id == draft.workflow_id, + WorkflowVersion.version == 1))).scalars().one() + graph = copy.deepcopy(row.graph) + for node in graph["nodes"]: + if node["type"] == "metrics": + node["data"]["config"]["metrics"][0]["name"] = "已支付退款率(画布改过)" + if node["type"] == "tool" and (node["data"]["config"].get("args") or {}).get("sql"): + node["data"]["config"]["args"]["sql"] = "SELECT 1 AS refund_rate" + workflow_id = draft.workflow_id + saved = await client.patch(f"/api/workflows/{workflow_id}", json={"graph": graph}) + assert saved.status_code == 200 and saved.json()["version"] == 2, saved.text + + made = (await client.get(f"/api/ledger/drafts/{draft_id}")).json() + assert made["metrics"][0]["name"] == "已支付退款率(画布改过)" + assert made["sql"] == "SELECT 1 AS refund_rate" + assert [group["sql"] for group in made["groups"]] == ["SELECT 1 AS refund_rate"] + assert made["version"] == 2 and made["trial"]["status"] == "stale" + + +async def test_resolving_a_metric_question_writes_it_back_to_the_graph(client, documents, datasource, monkeypatch): + """答清一条指标自己的未决问题之后,图里那条指标的 open_questions 也要去掉:草稿页从图读,不能又显示出来。""" + doc = await documents("dd-resolve") + source_id = await datasource() + plan = json.loads(plan_reply(f"{doc}-2")) + plan["metrics"][0]["open_questions"] = ["按哪个门店口径"] + fake_model(monkeypatch, json.dumps(plan, ensure_ascii=False)) + draft_id = (await extract(client, doc, source_id))["draft_id"] + + made = (await client.get(f"/api/ledger/drafts/{draft_id}")).json() + assert made["status"] == "questions_open" + assert made["metrics"][0]["open_questions"] == ["按哪个门店口径"] + + res = await client.patch(f"/api/ledger/drafts/{draft_id}", json={"resolved": ["按哪个门店口径"]}) + assert res.status_code == 200, res.text + fixed = res.json() + assert fixed["status"] == "ready" and fixed["question_count"] == 0 + assert fixed["metrics"][0]["open_questions"] == [] + + async with SessionLocal() as session: + draft = await session.get(CaliberDraft, draft_id) + row = (await session.execute(select(WorkflowVersion).where( + WorkflowVersion.workflow_id == draft.workflow_id, + WorkflowVersion.version == fixed["version"]))).scalars().one() + card = next(n for n in row.graph["nodes"] if n["type"] == "metrics") + assert not card["data"]["config"]["metrics"][0].get("open_questions") + + +async def test_trial_validates_against_graph_entry_fields_after_canvas_edit( + client, documents, datasource, monkeypatch): + """画布上给入口加了必填字段之后,试跑按图里的字段校验;payload 的旧入口字段不能把新字段漏掉。""" + doc = await documents("dd-entry-live") + source_id = await datasource() + fake_model(monkeypatch, plan_reply(f"{doc}-2")) + draft_id = (await extract(client, doc, source_id))["draft_id"] + + async with SessionLocal() as session: + draft = await session.get(CaliberDraft, draft_id) + row = (await session.execute(select(WorkflowVersion).where( + WorkflowVersion.workflow_id == draft.workflow_id, + WorkflowVersion.version == 1))).scalars().one() + graph = copy.deepcopy(row.graph) + for node in graph["nodes"]: + if node["type"] == "input": + node["data"]["config"]["fields"].append( + {"name": "region", "type": "string", "required": True, "default": "", + "description": "区域"}) + workflow_id = draft.workflow_id + saved = await client.patch(f"/api/workflows/{workflow_id}", json={"graph": graph}) + assert saved.status_code == 200 and saved.json()["version"] == 2, saved.text + + missing = await client.post(f"/api/ledger/drafts/{draft_id}/trial", json={"inputs": {}}) + assert missing.status_code == 422 and "region" in missing.text + ok = await client.post(f"/api/ledger/drafts/{draft_id}/trial", json={"inputs": {"region": "华东"}}) + assert ok.status_code == 200, ok.text and ok.text[:200] + assert ok.json()["status"] == "succeeded" + + async def test_dismiss_marks_rejected_and_writes_an_audit(client, documents, datasource, monkeypatch): doc = await documents("dd-7") source_id = await datasource() @@ -337,7 +469,7 @@ async def test_dismiss_marks_rejected_and_writes_an_audit(client, documents, dat draft_id = (await extract(client, doc, source_id))["draft_id"] res = await client.post(f"/api/ledger/drafts/{draft_id}/dismiss", json={"note": "已经有口径了"}) - assert res.status_code == 200 and res.json()["status"] == "rejected" + assert res.status_code == 200 and res.json()["status"] == "rejected" and res.json()["stage"] == "discarded" async with SessionLocal() as session: event = (await session.execute(select(AuditEvent).where( AuditEvent.action == "ledger.docdraft.dismiss", AuditEvent.subject_id == draft_id))).scalars().one() @@ -358,14 +490,20 @@ async def test_list_counts_orders_by_last_change_and_filters_by_status(client, d listed = (await client.get("/api/ledger/drafts")).json()["drafts"] assert [d["id"] for d in listed] == [two, one] latest, earlier = listed - assert (latest["title"], latest["status"], latest["metric_count"], latest["question_count"]) == \ - ("二.md", "ready", 1, 0) - assert (earlier["status"], earlier["metric_count"], earlier["question_count"]) == ("questions_open", 1, 1) + assert (latest["title"], latest["status"], latest["stage"], latest["metric_count"], latest["question_count"]) == \ + ("二.md", "ready", "review", 1, 0) + assert (earlier["status"], earlier["stage"], earlier["metric_count"], earlier["question_count"]) == \ + ("questions_open", "review", 1, 1) assert earlier["workflow_id"] and earlier["created_at"] and earlier["error"] is None only = (await client.get("/api/ledger/drafts", params={"status": "questions_open"})).json()["drafts"] assert [d["id"] for d in only] == [one] + # 界面按阶段筛:ready 和 questions_open 都是「待审」 + review = (await client.get("/api/ledger/drafts", params={"stage": "review"})).json()["drafts"] + assert {d["id"] for d in review} == {one, two} + assert (await client.get("/api/ledger/drafts", params={"stage": "discarded"})).json()["drafts"] == [] assert (await client.get("/api/ledger/drafts", params={"status": "nope"})).status_code == 422 + assert (await client.get("/api/ledger/drafts", params={"stage": "nope"})).status_code == 422 assert (await client.get("/api/ledger/drafts/d-nope")).status_code == 404 @@ -382,9 +520,11 @@ async def test_trial_endpoint_returns_metric_values_from_the_run(client, documen res = await client.get(f"/api/ledger/drafts/{draft_id}/trial") assert res.status_code == 200, res.text body = res.json() - assert body == {"run_id": body["run_id"], "status": "succeeded", + assert body == {"run_id": body["run_id"], "status": "succeeded", "version": 1, + "graph_hash": body["graph_hash"], "execution_hash": body["execution_hash"], + "validation": body["validation"], "inputs_hash": body["inputs_hash"], "metrics": [{"id": "m1", "name": "已支付退款率", "rendered": "3.2%", "value": 0.032}], - "error": ""} + "queries": [], "error": ""} # 没有试跑的老草稿 async with SessionLocal() as session: @@ -489,6 +629,14 @@ async def test_a_real_trial_run_feeds_the_trial_endpoint(client, documents, tmp_ assert [m["id"] for m in trial["metrics"]] == ["m1"] assert trial["metrics"][0]["name"] == "已支付销售额" assert float(trial["metrics"][0]["value"]) == 156.5 + # 试跑结果集:取数节点交回的查询快照,给列和行、不给 SQL + assert [query["node_id"] for query in trial["queries"]] == ["fetch"] + query = trial["queries"][0] + assert set(query) == {"node_id", "tool", "source", "columns", "rows", "total_rows", + "truncated", "masked", "source_truncated", "preview_truncated"} + assert query["columns"] == ["gmv"] and query["rows"] == [[156.5]] + assert query["total_rows"] == 1 and query["truncated"] is False and query["masked"] == [] + assert "sql" not in query async with SessionLocal() as session: run = await session.get(Run, run_id) assert run.origin_kind == "caliber_trial" and run.run_class == "exploratory" @@ -510,6 +658,64 @@ async def test_a_real_trial_run_feeds_the_trial_endpoint(client, documents, tmp_ await session.commit() +async def test_one_reply_can_bring_several_independent_groups(client, documents, datasource, monkeypatch): + """模型一次交两组:只调一次模型,两组各自成块,不再一组一轮。""" + doc = await documents("dd-groups") + source_id = await datasource() + first = json.loads(plan_reply(f"{doc}-2")) + second = json.loads(plan_reply(f"{doc}-2")) + second["sql"] = ("SELECT COUNT(*) AS orders FROM orders WHERE status = 'paid' " + "AND store = '{{ input.store }}'") + second["metrics"] = [{**second["metrics"][0], "id": "m1", "name": "已支付订单数", + "expression": "cell(nodes.fetch, 0, 'orders')"}] + model = fake_model(monkeypatch, groups_reply(first, second)) + + body = await extract(client, doc, source_id) + made = (await client.get(f"/api/ledger/drafts/{body['draft_id']}")).json() + assert made["status"] == "ready" and made["blocks"] == 2 and made["metric_count"] == 2 + assert [group["sql"] for group in made["groups"]] == [first["sql"], second["sql"]] + assert [m["id"] for m in made["metrics"]] == ["m1", "m2"] + assert made["progress"] == {"round": 1, "covered": 2, "uncovered": 0} + assert len(model.calls) == 1 + + +async def test_one_bad_group_is_repaired_without_failing_the_others(client, documents, datasource, monkeypatch): + """一组 SQL 不合格只重写这一组;修好后照常落图,好的那组不受影响。""" + doc = await documents("dd-groups-bad") + source_id = await datasource() + good = json.loads(plan_reply(f"{doc}-2")) + bad = json.loads(plan_reply(f"{doc}-2", sql="SELECT * FROM secrets")) + bad["metrics"] = [{**bad["metrics"][0], "id": "m1", "name": "已支付订单数", + "expression": "cell(nodes.fetch, 0, 'orders')"}] + fixed = {**bad, "sql": ("SELECT COUNT(*) AS orders FROM orders WHERE status = 'paid' " + "AND store = '{{ input.store }}'")} + model = fake_model(monkeypatch, groups_reply(good, bad), groups_reply(fixed)) + + body = await extract(client, doc, source_id) + made = (await client.get(f"/api/ledger/drafts/{body['draft_id']}")).json() + assert made["status"] == "ready" and made["blocks"] == 2 and made["metric_count"] == 2 + assert made["workflow_id"] and len(model.calls) == 2 + assert [group["sql"] for group in made["groups"]] == [good["sql"], fixed["sql"]] + assert not any("secrets" in item["reason"] for item in made["dropped"]) + + +async def test_a_bad_group_that_still_cannot_be_repaired_is_dropped(client, documents, datasource, monkeypatch): + """重写也没修好:只丢这一组,问题照实写下来,不把整份草稿判死。""" + doc = await documents("dd-groups-unfixable") + source_id = await datasource() + good = json.loads(plan_reply(f"{doc}-2")) + bad = json.loads(plan_reply(f"{doc}-2", sql="SELECT * FROM secrets")) + bad["metrics"] = [{**bad["metrics"][0], "id": "m1", "name": "已支付订单数", + "expression": "cell(nodes.fetch, 0, 'orders')"}] + model = fake_model(monkeypatch, groups_reply(good, bad)) + + body = await extract(client, doc, source_id) + made = (await client.get(f"/api/ledger/drafts/{body['draft_id']}")).json() + assert made["status"] == "ready" and made["blocks"] == 1 and made["metric_count"] == 1 + assert len(model.calls) == 2 + assert any("secrets" in item["reason"] for item in made["dropped"]) + + async def test_a_big_document_is_drafted_in_several_rounds_onto_one_multi_fetch_graph( client, documents, datasource, monkeypatch): """第一轮交一组、uncovered 还剩一条:续跑第二轮,收成一张两个取数节点、卡上两条指标的图。""" @@ -531,6 +737,13 @@ async def test_a_big_document_is_drafted_in_several_rounds_onto_one_multi_fetch_ assert made["status"] == "ready" assert [m["name"] for m in made["metrics"]] == ["已支付退款率", "已支付订单数"] assert made["blocks"] == 2 + # groups 逐组给出 SQL、入口字段和指标 id:页面按组画「这条 SQL 出哪几个指标」,不必从拼接的 sql 里猜边界 + assert [set(group) for group in made["groups"]] == [{"sql", "entry_fields", "metric_ids"}] * 2 + assert [group["sql"] for group in made["groups"]] == [first["sql"], second["sql"]] + assert [group["metric_ids"] for group in made["groups"]] == [["m1"], ["m2"]] + # 各组都从 m1 起草,拼图前重排:payload 的指标 id 和 groups 指同一组指标,页面按 id 配试跑值才不会张冠李戴 + assert [m["id"] for m in made["metrics"]] == ["m1", "m2"] + assert [f["name"] for f in made["groups"][0]["entry_fields"]] == ["store"] assert made["progress"] == {"round": 2, "covered": 2, "uncovered": 0} assert made["truncated"] is False and made["doc_truncated"] is False and made["catalog_truncated"] is False # 第二轮提示词写明上一轮起草过什么:模型据此不再重复起草 @@ -641,6 +854,8 @@ async def test_a_real_trial_run_reads_values_from_both_fetch_nodes(client, docum trial = (await client.get(f"/api/ledger/drafts/{draft_id}/trial")).json() assert trial["status"] == "succeeded" and trial["error"] == "" assert {m["id"]: float(m["value"]) for m in trial["metrics"]} == {"m1": 156.5, "m2": 4.0} + assert [query["node_id"] for query in trial["queries"]] == ["fetch_1", "fetch_2"] + assert [query["columns"] for query in trial["queries"]] == [["gmv"], ["orders"]] async with SessionLocal() as session: run = await session.get(Run, run_id) tools = [n["id"] for n in (run.graph or {}).get("nodes") or [] if n.get("type") == "tool"] @@ -712,25 +927,26 @@ async def test_running_the_trial_with_inputs_typed_on_the_page(client, documents "AND paid_at >= '{{ input.start }}'") fake_model(monkeypatch, json.dumps(reply, ensure_ascii=False)) - async def waiting(*, draft_id, workflow_id, graph, values, started_by, entry_fields=()): - return {"run_id": "", "status": "needs_input", "error": "缺少必填输入:start"} + async def waiting(*, draft_id, workflow_id, graph, values, started_by, entry_fields=(), version=1): + return {**api_ledger._trial_binding(version, graph, values), + "run_id": "", "status": "needs_input", "error": "缺少必填输入:start"} monkeypatch.setattr(api_ledger, "_run_trial", waiting) body = await extract(client, doc, source_id) made = (await client.get(f"/api/ledger/drafts/{body['draft_id']}")).json() assert made["trial"]["status"] == "needs_input" trial = (await client.get(f"/api/ledger/drafts/{body['draft_id']}/trial")).json() - assert trial["status"] == "needs_input" and trial["metrics"] == [] + assert trial["status"] == "needs_input" and trial["metrics"] == [] and trial["queries"] == [] missing = await client.post(f"/api/ledger/drafts/{body['draft_id']}/trial", json={"inputs": {}}) assert missing.status_code == 422 seen: dict[str, object] = {} - async def started(*, draft_id, workflow_id, graph, values, started_by, entry_fields=()): + async def started(*, draft_id, workflow_id, graph, values, started_by, entry_fields=(), version=1): seen.clear() seen.update(values) - return {"run_id": "", "status": "running", "error": ""} + return {**api_ledger._trial_binding(version, graph, values), "run_id": "", "status": "running", "error": ""} monkeypatch.setattr(api_ledger, "_run_trial", started) res = await client.post(f"/api/ledger/drafts/{body['draft_id']}/trial", json={"inputs": {"start": "2026-01-01"}}) @@ -746,13 +962,13 @@ async def test_extract_hands_the_fixed_filters_to_every_round(client, documents, source_id = await datasource() fake_model(monkeypatch, plan_reply(f"{doc}-2")) seen: dict[str, object] = {} - real_plan = draftplan.plan + real_plan = draftplan.plan_groups async def spy(session, **kwargs): seen.update(kwargs) return await real_plan(session, **kwargs) - monkeypatch.setattr(draftplan, "plan", spy) + monkeypatch.setattr(draftplan, "plan_groups", spy) body = await client.post("/api/ledger/drafts/extract", json={ "documents": [doc], "datasource_id": source_id, "fixed_filters": "line_code = 'FOAM01'"}) made = (await client.get(f"/api/ledger/drafts/{body.json()['draft_id']}")).json() diff --git a/backend/tests/test_ledger_draftplan.py b/backend/tests/test_ledger_draftplan.py index dee6f7b0..a52f0887 100644 --- a/backend/tests/test_ledger_draftplan.py +++ b/backend/tests/test_ledger_draftplan.py @@ -135,6 +135,33 @@ def test_metric_ids_are_unique_and_entry_fields_are_normalized(): 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 # ========================================================================== diff --git a/backend/tests/test_ledger_trial.py b/backend/tests/test_ledger_trial.py index 39ec69bf..aa4b15cf 100644 --- a/backend/tests/test_ledger_trial.py +++ b/backend/tests/test_ledger_trial.py @@ -40,6 +40,15 @@ async def test_trial_runs_the_pinned_caliber_and_returns_its_metrics(client): assert [m["id"] for m in body["metrics"]] == ["gmv"] assert float(body["metrics"][0]["value"]) == 170.5 assert body["metrics"][0]["unit"] == "元" + # 试跑结果集:取数节点交回的查询快照,给列和行、不给 SQL + assert [query["node_id"] for query in body["queries"]] == ["fetch"] + query = body["queries"][0] + assert set(query) == {"node_id", "tool", "source", "columns", "rows", "total_rows", + "truncated", "masked", "source_truncated", "preview_truncated"} + assert query["tool"] == S.tool() and query["source"] == S.CURRENT["source"] + assert query["columns"] == ["gmv"] and query["rows"] == [[170.5]] + assert query["total_rows"] == 1 and query["truncated"] is False and query["masked"] == [] + assert "sql" not in query async with SessionLocal() as session: run = await session.get(Run, body["run_id"]) assert run is not None and run.origin_kind == "caliber_trial" and run.run_class == "exploratory" @@ -55,3 +64,33 @@ async def test_trial_refuses_missing_required_inputs_and_unknown_fields(client): unknown = await client.post(f"/api/ledger/calibers/{wf}/1/card/trial", json={"inputs": {"start": "2026-09-01", "end": "2026-09-08", "nope": 1}}) assert unknown.status_code == 422 and "nope" in unknown.text + + +async def test_trial_queries_mask_the_configured_columns_and_never_return_sql(client): + """试跑结果集把数据源设了遮罩的列换成「已遮罩」(改了名字的结果列也认);响应里不出现 SQL,SQL 里可能写着遮罩值。""" + from sqlalchemy import select + + from app.db.models import DataSource + + async with SessionLocal() as session: + source = (await session.execute(select(DataSource).where( + DataSource.name == S.CURRENT["source"]))).scalar_one() + source.options = {"mask_columns": ["phone"]} + await session.commit() + wf = await S.publish(graph=S.caliber_graph(sql="SELECT phone AS tel FROM orders ORDER BY id LIMIT 3", + expression="cell(nodes.fetch, 0, 'tel')")) + res = await client.post(f"/api/ledger/calibers/{wf}/1/card/trial", + json={"inputs": {"start": "2026-09-01", "end": "2026-09-08"}}) + assert res.status_code == 200, res.text + body = res.json() + assert body["status"] == "succeeded", body + query = body["queries"][0] + assert query["columns"] == ["tel"] and query["masked"] == ["tel"] + assert query["rows"] == [["已遮罩"]] * 3 + assert "value" not in body["metrics"][0] + assert body["metrics"][0]["rendered"] == "已遮罩" + assert body["metrics"][0]["masked"] is True + assert body["validation"]["status"] == "unknown" + assert body.get("output") is None + assert query["total_rows"] == 3 and query["truncated"] is False + assert "sql" not in query diff --git a/backend/tests/test_metric_definition.py b/backend/tests/test_metric_definition.py index d4e02caa..c2b74851 100644 --- a/backend/tests/test_metric_definition.py +++ b/backend/tests/test_metric_definition.py @@ -4,6 +4,8 @@ """ from __future__ import annotations +import pytest + from app.engine.schema import GraphSpec, validate_graph from tests import ledger_samples as S @@ -208,3 +210,21 @@ def test_an_assessment_metric_is_labelled_assessed(): assert label["kind"] == "assessed" and label["queries"] == ["q1"] assert label["assessment"]["level"] == "B" and label["definition_hash"] == "abc" assert "19900000501" not in str(label) + + +@pytest.mark.parametrize("raw", ["nan", "inf", "-inf"]) +async def test_nonfinite_assessment_input_cannot_be_clamped_into_a_normal_score(raw): + from app.engine.runner import run_manager + + await run_manager.setup() + try: + graph = S.chain(S.node("start", "input"), S.node("card", "metrics", caliber="评估", metrics=[{ + "id": "quality", "kind": "assessment", "assessment": { + "dimensions": [{"name": "得分", "input": "input.raw", "weight": 1}], + "levels": [{"name": "合格", "min": 60}, {"name": "不合格", "min": 0}], + }}]), S.node("out", "output", fields=[{"name": "v", "value": "{{ nodes.card.text }}"}])) + started = await run_manager.start(graph=graph, input_payload={"raw": raw}) + row = await S.wait(started.id) + assert row.status == "failed" and "输入必须是有限数值" in row.error + finally: + await run_manager.shutdown() diff --git a/backend/tests/test_metric_value_artifact.py b/backend/tests/test_metric_value_artifact.py index 665270c0..b180ef02 100644 --- a/backend/tests/test_metric_value_artifact.py +++ b/backend/tests/test_metric_value_artifact.py @@ -86,6 +86,11 @@ def without_new_keys(card: dict) -> dict: """去掉这个功能之前没有的两个键:value_artifact 和 definition_hash(口径的内容指纹)。""" out = without_value_artifacts(card) out.pop("definition_hash", None) + out.pop("semantic_hash", None) + out.pop("validation_version", None) + for metric in out["metrics"]: + metric.pop("quality", None) + metric.pop("contract_findings", None) return out diff --git a/backend/tests/test_microvm_kvm.py b/backend/tests/test_microvm_kvm.py index 451a303a..dfea31a5 100644 --- a/backend/tests/test_microvm_kvm.py +++ b/backend/tests/test_microvm_kvm.py @@ -7,7 +7,9 @@ selftest 是部署脚本用的:真起一台 VM,起得来才把 /dev/kvm 交 from __future__ import annotations import asyncio +import sys from pathlib import Path +from types import ModuleType import pytest @@ -22,6 +24,8 @@ from app.sandbox.local_sandbox import LocalSandbox def linux(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> Path: """在 Linux 上、运行时已装好、镜像已缓存;返回假的 /dev/kvm 路径,默认不存在。""" kvm = tmp_path / "kvm" + # 此组模拟运行时已经就绪,SDK 是否安装不应影响 KVM 分支的测试。 + monkeypatch.setitem(sys.modules, "microsandbox", ModuleType("microsandbox")) monkeypatch.setattr(mv, "_on_linux", lambda: True) monkeypatch.setattr(mv, "_KVM_DEVICE", kvm) monkeypatch.setattr(mv.MicroVMSandbox, "_runtime_installed", staticmethod(lambda: True)) @@ -35,6 +39,12 @@ def test_no_kvm_device_means_unavailable(linux: Path) -> None: assert "/dev/kvm" in reason and "挂进容器" in reason +def test_missing_sdk_returns_a_result_without_import_crashing(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setitem(sys.modules, "microsandbox", None) + result = asyncio.run(mv.MicroVMSandbox().run("print(1)")) + assert not result.ok and "未安装 microsandbox" in result.error + + def test_kvm_without_permission_means_unavailable(linux: Path, monkeypatch: pytest.MonkeyPatch) -> None: linux.touch() monkeypatch.setattr(mv.os, "access", lambda path, mode: False) diff --git a/backend/tests/test_provenance_contract.py b/backend/tests/test_provenance_contract.py index 806e0953..4618a2ee 100644 --- a/backend/tests/test_provenance_contract.py +++ b/backend/tests/test_provenance_contract.py @@ -145,7 +145,7 @@ SHAPES: dict[str, list[tuple[str, object]]] = { "LocateProblem": [("code", REQ), ("detail", "")], "SumEqRule": [("table", REQ), ("total", REQ), ("parts", REQ), ("types", REQ)], "RelatedCheckPlan": [("check", REQ), ("rule", REQ)], - "CompileFacts": [("tables", REQ), ("result_rows", REQ), ("yields", REQ)], + "CompileFacts": [("tables", REQ), ("result_rows", REQ), ("yields", REQ), ("selects", 1)], } RESPONSE = ("ReportRef", "CellRef", "Reason", "Alert", "TableRef", "PeriodView", "AcceptanceView", "StateNote", "PartView", "VersionView", "FromCell", "YearSource", "CanonicalView", "Recheck", "CellSource", diff --git a/backend/tests/test_provenance_db.py b/backend/tests/test_provenance_db.py index a1d5bd15..c62fcd69 100644 --- a/backend/tests/test_provenance_db.py +++ b/backend/tests/test_provenance_db.py @@ -351,14 +351,15 @@ async def test_rehash_is_false_without_a_hash_or_a_file(snapshot): ]) async def test_compile_facts_reports_tables_and_result_rows(snapshot, sql, tables, rows, yields): facts = await provenance_db.compile_facts(await _view(snapshot), sql) - assert facts == CompileFacts(tables=tables, result_rows=rows, yields=yields) + selects = 3 if 'UNION ALL' in sql else 2 if ' IN (SELECT' in sql else 1 + assert facts == CompileFacts(tables=tables, result_rows=rows, yields=yields, selects=selects) @pytest.mark.parametrize("op", ["UNION", "INTERSECT", "EXCEPT"]) -async def test_compile_facts_sees_yield_in_set_operations(snapshot, op): +async def test_compile_facts_sees_multiple_selects_in_set_operations(snapshot, op): facts = await provenance_db.compile_facts( await _view(snapshot), f'SELECT "日期","全日客流" FROM "日客流" {op} SELECT "日期","分区甲" FROM "日客流"') - assert facts.yields >= 1 + assert facts.selects >= 2 # 不依赖 SQLite 是否将复合查询编译成 Yield 协程 async def test_compile_facts_runs_in_a_thread_on_an_immutable_read_only_connection(snapshot, monkeypatch): diff --git a/backend/tests/test_review.py b/backend/tests/test_review.py index 301b291e..5f0da248 100644 --- a/backend/tests/test_review.py +++ b/backend/tests/test_review.py @@ -495,7 +495,7 @@ def test_analysis_signals_are_registered_as_gaps_that_only_need_a_note(): ("report_analysis_claims", rv.DEGRADED), ("analysis_refused", rv.DEGRADED), ("metric_comparability_skipped", rv.DEGRADED)] # NOTE_ONLY 另有一种不是日志代码的:分析调用写错了一直没改对(analysis_call_failed,见 tests/test_review_analysis.py) - assert {s.kind for s in signals} == rv.NOTE_ONLY - {rv.ANALYSIS_CALL_FAILED} + assert {s.kind for s in signals} == rv.NOTE_ONLY - {rv.ANALYSIS_CALL_FAILED, "investigation_incomplete"} assert not rv.should_retry(signals) and not rv.NOTE_ONLY & (rv.RETRYABLE | rv.REBUILD) diff --git a/backend/tests/test_sandbox_errors.py b/backend/tests/test_sandbox_errors.py index 52240209..4f474bbc 100644 --- a/backend/tests/test_sandbox_errors.py +++ b/backend/tests/test_sandbox_errors.py @@ -8,6 +8,7 @@ from __future__ import annotations import asyncio import re +import sys import types import pytest @@ -72,6 +73,10 @@ def test_a_process_that_cannot_spawn_has_no_class_name(backend, monkeypatch) -> def _microvm(monkeypatch) -> MicroVMSandbox: + sdk = types.ModuleType("microsandbox") + sdk.ExecTimeoutError = TimeoutError + monkeypatch.setitem(sys.modules, "microsandbox", sdk) + async def nothing(*args, **kwargs): # noqa: ANN002, ANN003, ANN202 return None diff --git a/deploy/agentlab.env b/deploy/agentlab.env index 97d6335d..ce4a3978 100644 --- a/deploy/agentlab.env +++ b/deploy/agentlab.env @@ -119,15 +119,15 @@ ENABLE_BWRAP=0 # 行首的 # 再 ./deploy.sh install。 # # 库的镜像(小版本固定;离线包里带着同一个标签的 postgres-image.tar) -# POSTGRES_IMAGE=postgres:16.15 +POSTGRES_IMAGE=postgres:16.15 # 库的容器名,缺省 -postgres -# POSTGRES_CONTAINER=agentlab-postgres +POSTGRES_CONTAINER=agentlab-postgres # 两个容器共用的 docker 网络,缺省 -net(库的端口不对外,只在这个网络里可见) -# DOCKER_NETWORK=agentlab-net +DOCKER_NETWORK=agentlab-net # 库的数据放在这个 docker 数据卷里,缺省 -pgdata(卸载不删) -# POSTGRES_VOLUME=agentlab-pgdata +POSTGRES_VOLUME=agentlab-pgdata # upgrade 前自动备份库(SQLite 拷到 backups/sqlite/,内置 PostgreSQL 用 pg_dump 存到 backups/postgres/),只留最近几份 -# BACKUP_KEEP=5 +BACKUP_KEEP=5 # ---- 外接 PostgreSQL 与多进程(可选)---- # 库放在你自己的 PostgreSQL 上(库要先建好):去掉 AGENTLAB_DATABASE_URL 行首的 # 填好地址,再 ./deploy.sh install。 diff --git a/docs/design/analysis-round/00-总方案.md b/docs/design/analysis-round/00-总方案.md new file mode 100644 index 00000000..fa258a42 --- /dev/null +++ b/docs/design/analysis-round/00-总方案.md @@ -0,0 +1,234 @@ +# 分析能力一轮总方案:算、想、核 + +> 状态:已拍板(2026-10-21):六项能力 + 评测一轮做完;问数据里的说法核对打回重写一次,仍不过就标出。开工。代码基准 main `9e5debfb`。 +> 依据:五路只读调研(结论落到文件:行号)。细案 01-算子与判定.md 定算子、判定、说法表和阈值;本篇定范围、理念、跨块决定、接入、分包与关口,冲突以本篇为准。真实库名、表名、计数都不进本文;示例、测试、验收一律用虚构零售库。 + +## 0. 范围 + +| 块 | 编号 | 一句话 | +|---|---|---| +| 算 | ① 可比性检查 | 比两期之前先确认能比:只差日期、窗口一样长、是整期、星期构成相同 | +| 算 | ② 变化拆解 | 变化从哪来:可加按维度算贡献(闭合核对)、比率拆结构与自身、乘积拆因子、逐层下钻 | +| 算 | ③ 放回历史看 | 波动基线(算不算异常)+ 趋势(是不是持续) | +| 算 | ④ 差异与样本量 | 两组 / 两期的差异站不站得住:区间、检验、小样本、单值敏感、多重比较校正 | +| 想 | ⑤ 分析计划与假设台账 | 分析型问数、平台内置的分析方法、候选维度来自目录、系统写「查了什么、排除了什么、哪些没查」 | +| 核 | ⑥ 分析说法核对 | 判断词要挂对应的分析结论且强度对得上;判定语由系统渲染;因果词没依据标推测 | +| 配套 | 评测 | 两种新期望(贡献、判定),期望由期望 SQL + 同一套算子算出;埋了效应的虚构题集 | +| 配套 | 修口子 | 引用模式下被引指标的输入追不到原始数据时,受管级记缺口,其余标注 | + +不在本轮:预测;相关分析;维度层级进目录;数值列剖析;核查 agent(双盲、反方、质询)使用分析工具;分析结论参与口径账本比对与召回。 + +## 1. 调研结论(为什么要做) + +- **能算的太少**:报告里站得住的数只来自 SQL 返回的格、口径卡的白名单标量式(`engine/expressions.py:528`,没有分组、按键排名、方差、检验)、运行输入和知识库原话。沙箱只有标准库(`sandbox/interpreter.py`),算出的数按设计不能引用(`engine/evidence.py:758`)。 +- **分析结论没有来源**:维度归因只在两次运行对比里(`engine/compare.py`),不进台账;`data/value_drift.py` 只看目录的行数和码值。 +- **要求说原因、却不给原因**:写作者的角色是「挑重点、组织结构、说清变化和原因」(`engine/nodes/report.py:107`);裁判被告知不要复算、只看写作者挂的证据(`engine/judge.py:154`),挑过的证据照样过;问数据建图明写不加反方审稿、claims 不用写(`api/copilot.py:411`)。开发库 160 轮问数据里反方审稿、双盲复算一次都没出现。 +- **没有方法**:建图和 agent 提示词里没有基期对比、拆解、下钻、假设验证;种子里的「结构化分析」Skill 从没被挂过。 +- **量不出来**:评测「为什么」只能写 text 要点、不计分(`eval/cases.py:9`)。 + +## 2. 守住的理念 + +- **照实说**:算不了就说为什么(截断、不闭合、历史不够、本期不完整、不可比、样本小),不硬给结论;判定语写出判定标准。 +- **留痕**:每份分析结论是内容寻址的工件,记方法版本、参数、阈值、输入格、现查的表范围、结果、判定;进证据台账、随运行封存、能复算。 +- **不挡生成**:算不了是一条结论,不让 agent、节点失败;说法核对在问数据里只重写一次、仍不过就标出。 +- **只读与遮罩**:算子只读快照,唯一的现查(表的日期范围)走 data/coverage 的只读探查;遮罩列不当维度、不当度量。 +- **一种说法**:一套算子、一种结论形状、一个证据种类、一张判定语表、一张说法表;问数据(agent 工具)和模板(分析卡)两个入口共用。 +- **模板定骨架、agent 填关节**:模板里分析卡固定做哪些分析;问数据里 agent 运行时决定拆哪个维度,但方法和阈值由平台定。 +- **不可回退,照旧**:叙述层无算术;不做「从指标定义编译 SQL」的语义层;数据目录只放事实,口径公式在口径卡里;口径升版是被迫处置事件。本轮再往前一步:**叙述层也不下分析判定**——「主要来自」「超出常规波动」这类判定和数字一样由系统渲染,写作者只引用。 + +## 3. 设计 + +### 3.1 分析结论(契约) + +形状见细案 01 §1。要点:kind `analysis_result` 工件、台账 kind `analysis`、写作目录编号 A1…;纯函数可复算;判定语、具名标量、结果表三种引用面。契约放在 `app/analysis/contract.py`(骨架提交),各包按它写。 + +### 3.2 两个入口 + +**agent 分析工具**(问数据、画布上的 agent):五个平台工具 `analysis_decompose`、`analysis_baseline`、`analysis_trend`、`analysis_differ`、`analysis_comparable`。 + +- 参数只有「算什么」:引用本节点查过的结果(`query: "Q3"`,和交回模型的「【证据 Q3】」同一个编号)、列的角色、期次、`headline`、`parent`、`label`、`hypothesis`;没有阈值参数。 +- 工具建在查询列表之前(`engine/nodes/llm.py:428` 早于 `:442`),所以经 `ToolContext.extra` 传一个可变的「本节点查询」容器(同 `MASKED_VALUES_KEY` 的做法,`tools/datasource.py:56`),查一次往里放一次。 +- 交回 agent:判定语、关键数、caveats、算不了的原因、编号「【分析 A2】」;台账记 kind `analysis`(`_record_call` 的写法,`engine/nodes/llm.py:871-912`)。结果确定、落成工件(tool_step 进 checkpoint,续跑回放同一份)。 +- `ToolSpec` 加 `guide` 字段(`tools/registry.py:58`),绑了任一分析工具就把平台的「分析方法」注入 system(`engine/nodes/llm.py:_build_messages:113-141`,supervisor 成员在 `engine/nodes/multi.py:329`),同一份只注入一次。不用 Skill:Skill 是用户可改、可停用的库里的行,seed 只在同名不存在时插入(`seed.py:344`),平台方法要随代码升版。 +- `is_platform_query`(`capabilities/registry.py:305`)不认分析工具:它不查库。使用者的问数据白名单(`auth/chat_policy.py:35`)加上分析工具。 + +**分析卡**(节点类型 `analysis`,和口径卡平级):`items: [{id, op, mode, from: , params...}]`,可写阈值覆盖(写进图、随版本固定)。按合并查询节点的做法从 `state.nodes` 取快照 id、load 复验(`engine/nodes/merge.py:48-79`)。产出 `nodes..results.` = {status, verdict, values, phrase},下游 branch 能按判定分支(周报「超出常规波动就转人工」)。登记处按 merge 节点的先例(细案见 §5 W1-c 清单)。 + +### 3.3 证据接入 + +- 台账循环(`build_catalog`,`engine/evidence.py:937-976`)加 A 计数器;`report_catalog` 的 evidence_from 过滤(`engine/nodes/report.py:153`)放行 analysis。 +- 引用:`[[a:A3]]` 判定语(一个可点片段),`[[a:A3.top_share|pct]]` 具名标量,`[[v:A3.r0.share]]`、`[[table:A3 cols=… rows=…]]` 结果表,`[[see:A3]]` 句末依据。改 `_CELL_REF`、`_ROW_REF` 认 A,`_query_entry` 放行 kind analysis,`_Snapshots` 加取 A 结果表的适配;只认 kind query 的几处(`_header_masks`、`_text_names`、`_fields_prompt`、`masked_cells`)逐个过一遍。 +- 写作目录加「分析结论」一段:编号、判定语、具名标量、表的前 `table_top_k` 行、caveats、算不了的原因,外加怎么引用、说法规则一句。 +- 出处链(`api/evidence.py:_chain:918`)加「分析」一步:判定与判定标准 → 方法与阈值 → 计算(结果表、关键数的代入式,如「占比 = 华东变化 / 总变化 = −120 / −194」)→ 输入(落到 Q 的格,闭合核对对着 headline)→ 复算 → 封存。证据图(`_graph_of:477`)加「派生自」的边。 +- 审计、导出 HTML(`report_export/build.py`、`runtime.js` 的 PARTS)、可验证报告(`bundle/build.py` 的 `_CITED_KINDS`、`_card_input_queries`;`verifier.js` 的 KIND_LABEL、引用链、`checkNumber`)都认 A,带上结论工件和它的输入快照;浏览器里只核哈希、展示服务端的复算结果,不在 JS 里重写统计。 +- 血缘:分析结论的输入只能是本次运行台账里 kind query 的快照(解析器只认它们),复算即血缘:输入哈希都核对得上、复算逐字段相同才算追到。分析卡的结果**不作为口径卡的输入**(口径卡读 `nodes.<分析卡>.results` 按「追不到原始数据」处理,受管级静态检查给出提示)——分析是叙述的依据,不是口径的原料,见 D16。写死 `("tool_cell", "agent_field")` 的几处(`api/evidence.py:1026/1713`、`bundle/build.py:1040`、`engine/judge.py:527`、`engine/recompute.py:401/427/534/590`、`engine/nodes/dual_check.py:99`、`ledger/labels.py:58`)不用改。 +- 裁判:`_excerpt` 加 analysis 一支(方法、判定、关键数、判定标准、输入 SQL),`_candidate`、`_query_alias`、`_judge_loader` 同步。 +- 口径账本:分析结论里的数不和口径比对(它们是派生的),摘要行不计它们;前端 `LedgerTurn.tsx:30` 只认四种标签,服务端不新增标签。 + +### 3.4 可比性挂在哪 + +1. agent 单独调用 `analysis_comparable`。 +2. decompose 两份快照时、baseline / trend 判本期完整时自动做。 +3. **口径卡**:一个指标的表达式用到两份不同查询快照里的格、而且两份 SQL 同形只差字面量(本周 vs 上周)时,自动做可比性检查,结果挂在指标上(指标条目加 `comparability`)。写作目录照实提醒;带契约的出口:`incomparable` 记缺口降档(同 sql_check_failed),`conditional` 只标注。两份 SQL 结构不同(退款额 / 销售额)不是同一个量的两期,不查。 + +### 3.5 分析说法核对 + +- 说法表、否定、来源与因果的分界、违规代码与处置见细案 01 §9。实现在 `engine/analysis_claims.py`,挂点和缺失断言同一层(`engine/evidence.py:compose_doc` 追加违规,和 `absence_claims:3538` 并列),复用 `iter_units`、`_split_sentences` / `_SEE_TAIL`(句末依据挂到前一句)、按片段 span 涂白引文和实体的办法。 +- 报告节点 `analysis_claims`:`check`(缺省)重写一次后标出、不影响档位;`strict` 按 max_repairs 重写、仍有的进出具缺口;`off` 不核。受管级要求 `strict`(G3 加一条,旧版本照常运行、下次发布按新规则)。 +- **出口独立复核**:判断词核对进 `verify_doc`(组装和出口共用),报告文档加版本字段,只有新版本文档才核这一项——老文档复核结果不变。 +- 写作者的角色改成「挑重点、组织结构、说清变化;有分析结论时说清变化来自哪里;原因只按证据写」,写作规则加一句判断词怎么挂;`claim_priority` 的预筛词(`_DIRECTIONAL`,`engine/evidence.py:2667`)补上「主要、异常、持续、显著」等,裁判优先判这类句子。 + +### 3.6 分析范围(系统写的一段) + +报告里只要用到了分析结论(或这次运行有分析结论),文档末尾由系统追加「分析范围」一段(写作者改不了,随文档封存,点得开): + +- **现象**:可比性、基线、趋势结论的判定语。 +- **拆过**:每份拆解按链列出维度和判定(主要来自华东 / 分散 / 算不了的原因)。 +- **排除**:判定为分散、没有差异的那些——拆了没发现集中也是结论。 +- **没拆**:候选维度里没拆过的。候选维度确定地从目录算:拆解所用查询里的事实表上标成 attribute / status、不是标识、不遮罩、取值不多(剖析的取值数 ≤ 50 或码值表完整)的列,加上它关联的维度表;拆解用的列对不上目录时只说「候选维度核对不了」,不乱列。 +- **检验**:这次做了几次差异检验、是否已校正。 + +agent 写的 `hypothesis` 不进这一段(模型写的字不进系统渲染的正文,细案 01 §7.3),只在证据面板显示。报告节点 `analysis_scope: auto`(缺省)/ `off`。 + +### 3.7 想:分析型问数与平台方法 + +- **分析方法**(随代码升版,进评测的 prompt_fingerprint,`eval/runner.py:97-110`): + 1. 先定现象:取本期和问题对应的基期,做可比性检查;有 8 期以上历史就放回历史看——是常规波动就照实说,不硬找原因。 + 2. 指标是乘积或比率时先拆因子、拆结构。 + 3. 按目录里的候选维度由粗到细拆,每次给总数核对闭合;找到主要来源再下钻一层。 + 4. 每次调用写明在验证什么;拆了没发现集中也是结论。 + 5. 不作因果断言:数据只说明变化来自哪里;原因要有知识库原话或输入作依据,否则写成待核实的推测。 + 6. 阈值、方法不由你定;算不了照实说。 +- **建图**:问题问「为什么、原因、怎么变了、哪里拖累、是否异常、正常吗、趋势、持续」时,agent 绑分析工具(问数据包装 `api/copilot.py:403-416` 加一种形状,节点手册加分析卡一段、agent 段补分析工具、搭图规则加一条)。问题意图用 `app/analysis/intent.py` 的确定词表判,传进 GraphChecker(`api/copilot_gen.py:712`),自查规则「分析类问题,agent 要绑分析工具」只挡一轮(照 coverage 的样板);`_evidence_source`(`api/copilot.py:1239`)认分析卡。 +- **复核**:新日志代码(分析算不了、判断词核对重写后仍有)登记进 `engine/review.py:WARN_CODES`,只写说明、不自动重来。 +- **内置模板 ⑪「异动分析(可追溯)」**:取数(整体周序列 + 按地区两周)→ 分析卡(基线 + 拆解)→ 报告(`analysis_claims: strict`)→ 出口(契约)。⑨ 可追溯周报不动。 + +### 3.8 评测 + +- 新期望种类(细节在 W2-b 提示词里,按 `eval/cases.py`、`gold.py`、`score.py` 的现有结构): + - `contribution`:期望 SQL + 拆解参数 → 期望的主要来源(driver 或前 k 段)和方向。 + - `verdict`:期望 SQL + 基线 / 趋势 / 差异 / 可比性参数 → 期望的判定类(基线:常规 / 异常;趋势:上升 / 下降 / 无;差异:站得住 / 不足以下结论;可比性:可比 / 不可比)。 + - 期望值仍不许人填(`HUMAN_VALUE_KEYS` 加 segment、direction、verdict);作答前后各算一次,算子输出规范、稳定(`_fingerprint` 逐字比较)。 +- 评分沿用五种结论:答案引用了判定对得上的分析结论 → 对且可追溯;正文按说法表写对了、没引用 → 对但没引用;判定、来源对不上或答成相反 → 错;期望算不了 → 不能评分。分析结论的输入查询并进 `answer.cited`(逐题 E1)。SCORER_VERSION 加一。 +- **新的虚构库与题集**:另建 `tests/fixtures/acceptance/retail_trends.py`(数据源名 `retail_trends`,固定种子,从今天往前 26 周、截止昨天),不改现有零售库(现有测试断言了它的比例、地区排序,埋效应会改掉后面全部随机序列)。埋的效应(相对「上一个整周」):华东主导的下降(贡献)、渠道结构变化造成的退款率辛普森(结构)、订单数的常规波动(不该叫异常)、取消单激增(异常)、线上销售额持续上升(趋势)、销售额变化主要来自订单数(因子)、样本很少的门店退款率(差异不足以下结论)、本周不完整(不可比)。题集 `analysis_suite.json` 每种至少一题。 + +### 3.9 修口子:引用模式的血缘 + +引用模式的出口(`engine/nodes/io.py:209` 只调 `_check_citations`)收齐被引的指标(`[[m:]]` 片段和句末依据里的 m:),用口径卡上记的输入算 untraced:受管级的正式运行记缺口(降档),其余写进出具声明的 `untraced`、证据面板照旧标「追不到原始数据」。这样「代码节点自称取数」在受管报告里也不再能完整出具,和证据面板的说法一致。只在受管时降档:影响的现有测试只有两条受管运行的缺口列表(档位不变),见 W1-d 提示词。 + +### 3.10 设置 + +「设置 → 偏好设置 → 分析」一节(照 `pages/settings/LedgerSettings.tsx` 和 `api/settings.py` ledger 组的写法),键与缺省见细案 01 §10。发起运行时照 `_with_verify_snapshot`(`engine/launch.py:290`)写 `options["analysis"]`,只在图(含子图)里有分析卡、agent 绑了分析工具时写,别的图的 run.started 一字不变。 + +## 4. 跨块决定 + +| 编号 | 决定 | 否决的备选与代价 | +|---|---|---| +| D1 | 分析是平台的确定性代码,模型只选算什么 | 沙箱装 pandas 让 agent 写分析代码:算的数按设计引不了,任意代码不可复核,阈值能调到想要的结论 | +| D2 | 算子只吃实际执行过的 SQL 快照,下钻 SQL 由 agent 写,系统用闭合从数上核对 | 改写 SQL 自动下钻:就是语义层,已明确不做;改写带关联、窗口函数的 SQL 也容易错 | +| D3 | 新节点类型「分析卡」,不扩口径卡的函数 | 扩口径卡:口径卡编图时定死,问数据里该拆哪个维度要看了数才知道;分析产出是表和判定;会把受管口径(账本、召回)和一次性分析混在一起 | +| D4 | 阈值只来自设置快照和分析卡上的覆盖,工具参数里没有阈值 | 让 agent 传阈值:能调出想要的结论 | +| D5 | 判定语由系统渲染,`[[a:]]` 引用 | 让写作者照判定自己写:同一个判定十种写法,说法表也核不过来 | +| D6 | 说法核对是确定的词表 + 绑定,不调模型 | 交给裁判:花钱、慢,只看挂了的证据;裁判仍保留,判剩下的细处 | +| D7 | 因果词要知识库原话或输入作依据,否则写推测;拆解里点名的段按「来源」核 | 一律禁因果词:「主要由华东造成」这种记账说法太常见;一律放行:拆解被读成原因 | +| D8 | 问数据缺省 `check`(重写一次、标出、不影响档位),受管要求 `strict`;已发布的版本照常运行 | 缺省 strict:探索运行里多出整篇重写;缺省 off:问数据里得不到这层保障 | +| D9 | 说法核对进 verify_doc,文档加版本字段 | 只在组装时查(像缺失断言):strict 下出口不独立复核,档位凭写作节点一面之词 | +| D10 | 分析卡的结论总能引用;agent 工具的结论在受管正式出具里和 agent 查询的格同一规矩(契约 `cells`) | 一律放行:受管报告里 agent 现场决定的分析绕过 cells 的约束 | +| D11 | 分析结论的数不进口径账本比对,摘要不计 | 给它们新标签:前端摘要只认四种(会算出 NaN),而且它们不是口径 | +| D12 | 平台方法挂在 ToolSpec.guide,随代码升版 | 用 Skill:库里的行、用户可改可停、seed 不更新已有库 | +| D13 | 统计纯 Python,Wilson 从 eval/stats 移到 analysis/stats 共用;测试用预先算好的参考值钉住 | 加 scipy:几十 MB 依赖只为几个函数,本机装包慢;和评测已有的纯 Python 统计也不是一套 | +| D14 | 新建虚构库 retail_trends 承载分析题 | 在现有零售库里埋效应:共用一条随机序列,改一处后面全变,现有断言要大改;60 天也不够 8 周基线 | +| D15 | 引用模式的血缘缺口只在受管时降档 | 所有级别降档:会改动约十条现有测试的档位,探索运行也没必要 | +| D16 | 分析卡的结果不作口径卡的输入 | 让口径卡读分析值:血缘要穿过分析再穿回查询,口径的等价证明、召回、双盲复算都得认一种新来源;分析结论本身已能引用 | +| D17 | 写作目录里 A{n} 条目带整份结论(`contract.catalog_result` 读);口径卡指标的可比性记在 `comparability` 字段;「分析范围」是报告文档的结构化字段 `analysis_scope`,不进正文 | 范围段写成正文:要过裸数字和说法核对,写作者也可能改它 | + +## 5. 分包与归属 + +集成分支 `an/w1`(cd-integ)= main `9e5debfb` + 骨架提交(`app/analysis/contract.py`、`__init__.py` 的 run / recompute 签名、`testing.py` 的假结论、设置缺省)。各包在自己的工作区从骨架拉分支;**不许 git stash**(各工作区共享同一个 stash),临时保存用 `git diff > 自己目录下的补丁`。 + +### 第 1 波(并行四包) + +| 包 | 工作区 / 分支 | 内容 | 主要文件(归属) | +|---|---|---|---| +| W1-a 算子库 | cd-be / an1/a | 五个算子、统计函数、判定语、可比性(含探查)、复算;Wilson 移到 analysis/stats | `app/analysis/*`(除 contract 外全归它)、`eval/stats.py`(只改引用) | +| W1-b 证据接入 | cd-fe / an1/b | 台账 A、写作目录、引用 a: / v:A / table:A / see:A、出处链、证据图、审计、导出 HTML、可验证报告、裁判摘录、血缘 via analysis、账本跳过 | `engine/evidence.py`(除 W1-d 的挂点)、`api/evidence.py`、`engine/judge.py`、`engine/lineage.py`、`report_export/*`、`bundle/*`、`ledger/*`、`engine/recompute.py`、`engine/sql_problems.py`;`engine/nodes/report.py` 只动 evidence_from 一处 | +| W1-c 入口 | nr-c / an1/c | 五个分析工具与 guide、llm 里的查询容器与台账记录与方法注入、分析卡全套登记、口径卡可比性挂钩、运行对比里分析卡按项对齐、设置组与快照、使用者白名单 | `tools/*`、`engine/nodes/llm.py`、`engine/nodes/multi.py`、`engine/nodes/analysis.py`(新)、`engine/nodes/metrics.py`、`engine/schema.py`、`engine/compiler.py`、`engine/governance.py`、`engine/labels.py`、`core/events.py`、`engine/upgrade.py`、`engine/variables.py`、`engine/compare.py`、`data/catalog_impact.py`、`apps/*`、`auth/chat_policy.py`、`plain.py`、`api/settings.py`、`engine/launch.py` | +| W1-d 说法核对 | nr-d / an1/d | 说法表与绑定、check / strict / off、出口独立复核与文档版本、写作者角色与规则、分析范围段(候选维度)、预筛词、修血缘口子、G3 一条 | `engine/analysis_claims.py`(新)、`engine/analysis_scope.py`(新)、`engine/nodes/report.py`、`engine/nodes/io.py`;在 `engine/evidence.py` 的 compose_doc / verify_doc / `_DIRECTIONAL` 只加挂点;`engine/schema.py`、`engine/governance.py`、`plain.py` 只加报告节点配置与 G3 那几行 | + +共享文件的规矩:非归属包只在约定的挂点加几行、不重排不改名;合并冲突由编排者解。`api/copilot.py` 第 1 波谁都不动(第 2 波 W2-a 归属)。 + +### 第 2 波(并行三包,第 1 波合进集成分支后开) + +| 包 | 工作区 / 分支 | 内容 | 主要文件 | +|---|---|---|---| +| W2-a 想 + 文档 | cd-be / an2/a | 分析型问数、节点手册、自查与问题意图、方法文本定稿、复核信号、内置模板 ⑪、中英文档(新增 analysis 一篇 + 七对文档 + 升级说明 + README 索引) | `api/copilot.py`、`api/copilot_gen.py`、`app/analysis/intent.py`、`engine/review.py`、`chat/*`、`seed.py`、`docs/zh-CN/*`、`docs/en/*`、`README*.md` | +| W2-b 评测 | cd-fe / an2/b | 两种期望全链路、SCORER_VERSION、虚构库 retail_trends 与题集、评测前端 | `eval/*`、`tests/fixtures/acceptance/retail_trends.py`、`tests/fixtures/eval/analysis_suite.json`、`frontend/src/typesEval.ts`、`lib/termsEval.ts`、`pages/evals/*`、`scripts/check-evals.mjs` | +| W2-c 前端 | nr-c / an2/c | 分析卡节点(定义、表单、卡片、配色)、证据面板分析一步、正文 a: 片段与说法违规、分析范围段、问数据时间线的分析步骤、设置「分析」一节、摘要行防 NaN、检查脚本 | `frontend/src/*`(除评测)、`scripts/check-*.mjs`(除 check-evals)、`scripts/check-all.mjs` | + +W2-c 改了检查的项数时,把数告诉编排者,`docs/*/development.md` 由 W2-a 一处改。 + +### 复核与修复 + +每波一个复核代理(第 2 波接着用同一个,SendMessage 续上):按 a → b → c → d 试合并、跑合并后的相关测试、读改动找问题;意见用 SendMessage 交回原包代理修,不另起修复代理。 + +## 6. 验证与关口 + +- 包代理:开发中只跑相关测试文件(`-n 8`);交付前后端全量一遍;第 1 波不跑浏览器检查,第 2 波只跑自己改到的单项检查;check-all 加锁 `mkdir /tmp/agentlab-fix/checkall.lock`。跑 pytest 去掉模型相关环境变量(`env -u ANTHROPIC_API_KEY -u ANTHROPIC_BASE_URL -u ANTHROPIC_AUTH_TOKEN -u OPENAI_API_KEY`)。 +- 编排者在第 2 波合并后跑一次完整关口:后端全量(SQLite);本轮不加表、不改迁移的话 PostgreSQL 全量不跑(有就跑 `scripts/test-postgres.sh`);tsc;check-copy;check-docs;相关浏览器检查(evidence、report-export、bundle、chat、decode、stream、studio、tokens、evals、manage、ledger);泄露扫描(含本会话见过的真实名做指纹)。 +- 真实模型验收:用 retail_trends 题集跑评测(分析题 当前 vs 关 分析工具),人工核对几题的证据链;验收前问用户模型接入怎么配。 +- **合 main 前问用户**。 + +## 7. 风险 + +| 风险 | 应对 | +|---|---| +| 说法表误判(正常句子被打回) | 只收强判断词;否定、引文、系统渲染的字不扫;check 档只重写一次、不失败;评测里看重写率和标出率 | +| agent 不用分析工具、或乱用 | 方法注入 + 自查规则 + 调用错误文案教它改;`max_calls` 上限;评测「当前 vs 关」量效果 | +| 探查拖慢问数据 | 只在可比性检查时探、走 coverage 的总时限与 5 分钟缓存,失败退回剖析事实、再不行就「核对不了」 | +| 大文件冲突(evidence.py 4441 行、copilot.py 2828 行) | 归属到人、挂点约定、第 1 波不碰 copilot.py | +| 统计函数写错 | 参考值钉住(含边界:并列、n ≤ 10 精确分布、Fisher 小样本、t 分布尾部);拆解恒等式逐位测 | +| 老报告复核结果变化 | 文档版本字段,只核新文档 | + +## 8. 实施记录 + +### 第 1 波交付时认可的偏离(2026-10-21) + +- W1-a:differ 三组以上每组和「其余各组合计」比(不和含它自己的整体比,不独立);整月对整月、整季对整季、整年对整年算一样长;同口径不通过只写差在哪些方面、不引 SQL 原文和字面量;新高新低按值本身判;趋势取最后连着的一段;label 消毒也用于目录叫法和当叫法用的列名;比率零分母判 zero_base;比率结果表加 n_base、n_target;乘积因子取整时按同一比例缩放;周次只认 ISO `YYYY-Www`(SQLite `%W` 不是 ISO 周);比率各段分子不超过分母时按百分点写。契约加可选字段:InputSnapshot 的 dialect / checks / no_data / mask_columns / masked_at_query,AnalysisInput 的 dialect / masked,Probe 的 expr / value。parent 按内容哈希认,入口必须原样存结论。 +- W1-c:runner 不改(已有 run_options);apps 的 lint / derive 不改(没有对应的绑定种类);compare 的配置键不登记;受管规则分工(输入不对各级报错、非 db_query 的输入画布提醒受管挡、口径卡读分析卡两级都只提示);协作成员能用分析工具但结论不进台账;口径卡的可比性结论不进台账、只挂在指标的 comparability 上(封存加载器要放行它)。 + +- W1-b:evidence_from 按「谁做的这条分析」过滤(和查询同一口径);报告文档里存的目录只留 A 条目摘要、不带整份结论(需要时用 compose / verify 收到的 catalog 或按工件取回);裁判规则原文不改,摘录里有分析结论时才追加 ANALYSIS_JUDGE_PROMPT(老判定不过期);具名标量的片段种类照数字 / 取值分,只有判定语是 analysis;可验证报告里任一输入快照只给 id 时结论也只给 id;复算的遮罩按快照里记下的查询当时的遮罩列;代入式先做可加拆解、比率拆解、波动基线三类;`see:a:A3` 宽松认;差异检验 0 次时分析范围不显示那一项。契约加 OP_LABELS / MODE_LABELS / method_label / is_result。 + +### 第 1 波复核后的决定(2026-10-21,复核意见 R1–R37) + +- 分析卡里随数据变化的调用错误(上一层没做成、下钻的段这期没有、期次这期没有、总数格越界或为空、要比的组这期没有)不让卡失败:`analysis.run(..., refuse_data_errors=True)` 把它们记成算不了的结论,契约 REASONS 加 `parent_refused`、`input_unavailable`;agent 工具照旧交回调用错误(让它改调用);复算一律按 True 复现。结构性错误(列不存在、遮罩列、比率当可加、重复行、参数写错)照旧让卡失败。 +- 差异检验一对都拿不到 p 值(两组各 1 个样本、分母为 0)→ 算不了,REASONS 加 `untestable`。 +- 单份快照按期次分组的拆解也核本期完不完整:不完整算不了(partial_target),核对不了附说明。 +- 周次:`YYYY-Www` 不再一律按 ISO 算;按 SQL 认出期次列的算法(SQLite strftime %W / %U、MySQL DATE_FORMAT %U / %u、to_char WW / IW),能算真实起止就算,算不了就起止置空、判「核对不了」,绝不判完整;W00 / W53 不走 ISO。 +- `_classify` 的结论句判定回到原来的词表,新增的判断词只用于裁判的预筛排序(不改已发布版本的档位)。 +- 说法表按复核第 4 节调整(静态构成、建议计划预测、业务上的「异常」名词、「来源于」、「表结构」、「相当」不再误报;补高频漏报写法);还没有线上文档,不升 LEXICON_VERSION。表格正文的格不核。 +- 写作者提示词在没有分析的图里也变(新 ROLE + 「没有分析结论就不下这类判断」):照 D8 保留,这是本轮要的效果;升级说明写清。 +- 候选维度只认码值表(剖析目前没有每列取值数)、模板 ⑨ 补 analysis_claims strict:记为偏离,认可。 + +### 合并时要统一的 + +- 算子的叫法有三份(契约 OP_LABELS / MODE_LABELS、engine/labels.py 的 ANALYSIS_OP_LABEL / ANALYSIS_MODE_LABEL、phrases.py 的「没有做成」那句)→ 只留契约一份。 +- tests/bundle_samples.py 补一页带分析结论的样本(第 2 波 check-bundle、check-report-export 要覆盖)。 + +### 第 2 波要带上的事 + +- W2-a:review 的 WARN_CODES 登记 analysis_refused、metric_comparability_skipped(以及 W1-b / W1-d 新增的日志代码);方法 guide 定稿时升 id 里的版本号;方法文本写明周序列的期次用每周第一天的日期或 ISO `YYYY-Www`;助手的工具清单从第 1 波起已经列出五个分析工具;`_evidence_source`(`api/copilot.py:1251`)认分析卡。 +- W2-b:评测指纹排除 `probes[*].at`(现查时刻)。 +- W2-c:节点叫法「分析卡」、字段叫法「分析项」「阈值覆盖」、方法与模式的叫法在 engine/labels.py(ANALYSIS_OP_LABEL / ANALYSIS_MODE_LABEL);新事件 analysis.end;tool.error 新字段 call_error;运行对比新字段 analyses;设置组 analysis。 + +### 第 2 波交付时的决定(2026-10-21) + +- W2-b:期望指纹不比 inputs[*].artifact(查询快照记着 elapsed_ms,同一份数据每次查出来工件都不同);期望 SQL 只返回两期时可以不写基期、本期;算期望的「今天」用服务端当天;编辑器保留多个期望(以前会丢第一个以外的)。说法表不加「可比 / 不可比」一类(不打回谨慎的提醒),评分另用一张小词表认正文里「两期不能直接比」的说法。评测变体加「分析工具」开 / 关和「分析工具贡献」预设(真实模型验收用它量效果)。 +- W2-a:自查只在问数据里、只针对查库却没绑分析工具的 agent;分析调用写错后又做成了不再报、一直没改对的记 analysis_call_failed(只写说明,不让整轮重来);模板 ⑪ 的 SQL 只按 SQLite 写;文档里内置工具 23 个、模板 11 个、节点类型 19 种。关分析工具时节点手册连分析卡一整条一起去掉(对照才干净)。 + +### 第 2 波复核要带上的 + +- `[[table:An cols=…,share]]` 里占比按原始小数渲染(1.0133380758),不是百分数:结果表要带每列的显示格式(占比、变化率、权重按百分数),证据渲染照它。 +- analysis_trend 的工具说明开头写「趋势:」,契约叫法是「趋势判断」。 +- termsEval(W2-b)里算子与模式的叫法一份,W2-c 若在 lib/terms.ts 另建一份要统一成一份。 +- 新 code 前端要认:analysis_call_failed、analysis_tools_missing、analysis_tools_off。 +- development.md / README 的前端检查项数等 W2-c 交数后改;评测文档和 an2/b 最终代码核一遍。 diff --git a/docs/design/analysis-round/01-算子与判定.md b/docs/design/analysis-round/01-算子与判定.md new file mode 100644 index 00000000..2e82e2da --- /dev/null +++ b/docs/design/analysis-round/01-算子与判定.md @@ -0,0 +1,303 @@ +# 细案 01:分析算子、判定与说法表 + +> 总方案见 00-总方案.md,冲突以总方案为准。本篇定「算什么、怎么算、怎么判、怎么说」:五个算子的输入、方法、判定、算不了的情形,统一的结果形状,判定语模板,说法表与绑定规则,设置缺省值。例子一律用虚构零售库(地区:华东、华南、华北、西南;渠道:线上、线下)。 + +## 1. 统一的结果形状:分析结论 + +一次分析(agent 调一次分析工具,或分析卡里的一项)产出一份**分析结论**,内容寻址存成工件(kind `analysis_result`),在证据台账里记一条 kind `analysis`,写作目录编号 A1、A2……(和 Q、K 同一套编号规则,按台账先后)。 + +``` +{ + "op": "decompose" | "baseline" | "trend" | "differ" | "comparable", + "mode": "additive" | "ratio" | "factor" | "rate" | "mean" | null, # 只有 decompose、differ 有 + "method": "decompose.additive@1", # 方法 + 版本;改算法就升版本号 + "params": {...}, # 规范化后的调用参数(列名、期次、维度、标签……) + "settings": {...}, # 用到的阈值(发起时的快照 + 分析卡上写的覆盖) + "inputs": [ # 输入:都是本次运行里交回过的查询快照 + {"ref": "Q3", "artifact": "", "columns": [...], "rows": "all" | [i, j, ...]}, + {"ref": "Q1.r0.gmv", "artifact": "", "value": "1234.5", "role": "headline_target"} + ], + "probes": [...], # 可比性检查现查的表范围(列、最小、最大、来源:现查 / 剖析 / 取值观察、时刻) + "status": "ok" | "refused", + "reason_code": null | "truncated" | "history_short" | ..., # 见 §8 + "reason": null | "(中文,一种说法)", + "table": {"columns": [...], "labels": {...}, "rows": [[...]]}, # 结果表,形状同查询快照,[[v:]] / [[table:]] 能引 + "values": {"total_delta": "...", "top_share": "...", ...}, # 具名标量,[[a:A3.top_share]] 能引 + "verdict": {"code": "driver", "subjects": [["华东"]], "direction": "down"} | null, + "criterion": "(判定标准的中文,一句)", + "phrase": "(判定语,由系统按模板渲染,§7)", + "caveats": [{"code": "offset", "text": "..."}], + "hypothesis": "(agent 写的这次在验证什么,只在证据面板显示,§9.3)" +} +``` + +规则: + +- **纯函数**:结论只由 inputs(快照里的格)、probes、params、settings、method 决定;证据面板、导出、可验证报告用同一个函数复算,复算结果和封存的逐字段相同才算核对通过。方法升版后复算按当前版本算,不同就照实写「运行时用的是 v1,现在是 v2,复算结果不同」。 +- **数用十进制**:decompose 全程 `Decimal`(同 engine/compare.py 的做法),各段之和逐位等于总变化;统计量(z、p、斜率)用浮点,渲染按 cell_format 的规则。 +- **只读快照、不发查询**:算子本身不查库。唯一的例外是可比性检查要现查表的日期范围(§6),走 data/coverage 现成的探查(只读守卫、遮罩列不探、总时限、5 分钟缓存、失败退回剖析事实),探查结果作为 probes 记进结论,复算用记下的值。 +- **遮罩**:遮罩列(数据源 mask_columns,以及按 SQL 认出改了名字的遮罩列,同 compare.py `_hidden`)不能当维度、不能当度量;拿它们调用是调用错误(§8.1),结论里绝不出现遮罩值。 +- **截断的快照不算**:任一输入快照 `truncated` → 算不了(reason_code `truncated`),原因写「Q3 只取回了前 1000 行,各段之和不是完整的和」。 +- **行要唯一**:同一维度键、同一期次只能有一行(还有别的列在拆行就是 SQL 没按这些维度汇总)。重复 → 调用错误,提示「在 SQL 里按 地区、周 汇总」。不在算子里替人求和:存量跨天相加这类错误只有 SQL 那边知道。 +- **空值**:度量格为空按「这一段这一期没有值」处理:decompose 记为 0 并在 caveats 写明哪几段为空;统计类算子跳过这一期,跳过后历史不够的照算不了处理。 + +## 2. 变化拆解(decompose) + +回答「变化从哪来」。三种模式,agent 按指标的性质选,算子核对选得对不对。 + +### 2.1 可加(additive)——按维度算贡献 + +**输入** + +- 一份快照,含维度列 `by`(1–2 列)、期次列 `period`、度量列 `measure`,`base`、`target` 是期次列里的两个值;或者两份快照(`base_query`、`target_query`),各自只有维度列和度量列,此时先做可比性检查(§6),不可比的照样算、判定后附上可比性结论。 +- 可选 `headline`:基期、本期总数各一格(如 `Q1.r1.gmv`、`Q1.r0.gmv`),用来核对**拆解闭合**。 +- 可选 `parent`:上一层拆解的结论和那一段(如 `{"of": "A3", "key": ["华东"]}`),用来下钻:这一层各段之和要等于 A3 里华东那一段的基期、本期值。 +- 目录里度量列标成 ratio(比率)的 → 调用错误:「比率不能按段相加,改用 ratio 模式,给分子、分母两列」。标成 identifier / status / attribute 的 → 调用错误。没标的照算,caveats 写「目录没标这一列的度量类型」。 + +**计算** + +- 每段 `base_i`、`target_i`(缺的一边记 0,标「新出现 / 消失」),`delta_i = target_i − base_i`,`pct_i = delta_i / base_i`(base 为 0 不给)。 +- 总数 `B = Σ base_i`、`T = Σ target_i`、`Δ = T − B`。 +- 闭合:给了 headline / parent 时,`B`、`T` 要分别等于它们(按十进制精确相等;headline 是取整过的,按它的书写精度容差,同 engine/tolerance)。不闭合 → 照算各段变化,但**不给占比、不下集中类判定**(verdict 为 `unclosed`),caveats 写差多少、可能的原因(有段没取全、两边筛选不同、关联重复计数)。没给 headline 时以各段之和为总数,criterion 写明「以各段之和为总数」。 +- 占比 `share_i = delta_i / Δ`(Δ 为 0 不给)。抵消:和 Δ 方向相反的段的变化绝对值之和 `G_opp`。 + +**判定**(阈值见 §10) + +| verdict | 条件 | subjects | +|---|---|---| +| `flat` | `|Δ| / |B| < flat_pct` | 无;有抵消时附抵消 | +| `driver` | 最大的 `share_i ≥ main_share` | 那一段 | +| `concentrated` | 不满足 driver;最小的 k 使前 k 段(按 share 降序)之和 `≥ concentration`,且 `2 ≤ k ≤ max(2, ⌈0.2·段数⌉)` | 前 k 段 | +| `dispersed` | 其余 | 无 | +| `unclosed` | 闭合核对没过 | 无 | + +- `G_opp ≥ offset_ratio · |Δ|` 时加 caveat `offset`:「下降的段合计 −120 万,上升的段合计 +40 万,互相抵消了一部分」。有抵消时 share 可以超过 100%(华东 −120、总共 −80,华东占 150%),判定照常,判定语会说清。 +- 段数少于 2 → 算不了(`too_few_segments`)。 +- 结果表:维度列 + `base`、`target`、`delta`、`pct`、`share`、`flag`(新出现 / 消失 / 为空),按 `|delta|` 降序;全部段都在工件里,写作目录只给前 `table_top_k` 段和「其余 n 段合计」。 +- values:`base_total`、`target_total`、`delta_total`、`pct_total`、`top_key`、`top_share`、`top_k`、`top_k_share`、`gross_up`、`gross_down`、`segments`。 + +### 2.2 比率(ratio)——拆成结构变化和各段自身变化 + +**输入**:维度列、期次列、分子列 `numerator`、分母列 `denominator`(如退款单数 / 订单数、销售额 / 订单数);可选 headline(整体比率的两格)。 + +**计算**(中点对称拆法,精确可加) + +- 各段权重 `w_i = D_i / ΣD`,比率 `r_i = N_i / D_i`;整体 `R = ΣN / ΣD`,`ΔR = R' − R`。 +- 结构效应 `mix_i = (w_i' − w_i) · (r̄_i − R̄)`,自身效应 `rate_i = (r_i' − r_i) · w̄_i`,其中 `r̄_i = (r_i + r_i')/2`、`w̄_i = (w_i + w_i')/2`、`R̄ = (R + R')/2`。 +- 恒等式 `Σmix_i + Σrate_i = ΔR`(因为 `Σ(w_i' − w_i) = 0`);单测钉住逐位相等。减去 `R̄` 是为了让单段的结构效应读得懂:一段占比变大,只有它的比率低于平均时才拉低整体。 +- 一边分母为 0 的段:那一边权重 0、比率取另一边的(自身效应为 0,变化全记在结构上),表里标出;两边都为 0 的段剔除并写明。 +- headline 闭合:整体比率要等于 ΣN/ΣD(按 headline 的书写精度容差)。 + +**判定** + +| verdict | 条件 | +|---|---| +| `flat` | `|ΔR| / |R| < flat_pct` | +| `mix_driven` | `Σmix / ΔR ≥ main_share` 且 `Σrate / ΔR < main_share` | +| `rate_driven` | `Σrate / ΔR ≥ main_share` 且 `Σmix / ΔR < main_share` | +| `both` | 其余 | + +- **辛普森**:分母两期都不小于 `min_denominator` 的段里,按 `w̄` 加权有 `≥ 0.8` 的段比率变化方向和 ΔR 相反 → caveat `simpson`,判定语写「各段的退款率大多在下降,整体却上升:变化来自结构,退款率高的段占比从 x 升到 y」。 +- subjects:`rate_driven` 取自身效应最大(和 ΔR 同向)的段,`mix_driven` 取结构效应最大的段。 +- 分母小于 `min_denominator` 的段在表里标「分母小」,不参与辛普森的多数判断。 +- 结果表:维度列 + `d_base`、`d_target`、`w_base`、`w_target`、`r_base`、`r_target`、`mix`、`rate`、`total`、`flag`。values:`r_base_total`、`r_target_total`、`delta`、`mix_total`、`rate_total`、`mix_share`、`rate_share`。 + +### 2.3 乘积(factor)——拆成因子 + +**输入**:期次列(或两行),乘积列 `product`(销售额),因子列 `factors`(订单数、客单价;2–4 个)。 + +- 闭合:每期 `product ≈ Π factors`,相对误差不超过 `max(1e-6, Σ 因子按书写精度的半个末位 / 因子值)`;不闭合 → 调用错误「这几列相乘不等于销售额(差 0.8%),不是乘积关系」。 +- 任一值 ≤ 0 → 算不了(`non_positive`):对数拆解要求全为正。 +- 方法 LMDI-I:`contrib_k = L(M', M) · ln(F_k' / F_k)`,`L(a, b) = (a − b) / (ln a − ln b)`,`L(a, a) = a`;`Σcontrib_k = M' − M`。 +- 判定:`flat` / `driver`(某因子 `contrib_k / ΔM ≥ main_share`,subject 是因子名)/ `both`。 +- 结果表:`factor`、`base`、`target`、`pct`、`contrib`、`share`。不按维度拆:要「哪个地区的客单价」就先 factor 再对客单价做 ratio。 + +### 2.4 下钻 + +`parent` 把几层拆解连成一条链:A5(华东按省份)是 A3(按地区)里华东那一段的拆解。闭合按 parent 那一段的值核对,链深度不超过 `max_drill_depth`(超了是调用错误)。证据面板按链显示;分析范围(细案 00 §6)按链列「拆过」。 + +## 3. 波动基线(baseline) + +回答「这个变化大不大、算不算异常」:把本期放回它自己的历史里看。 + +**输入**:一份快照,期次列 `period` + 度量列 `measure`(一条序列;要看某一段就在 SQL 里筛好);可选 `target`(缺省最后一期)、`basis`(`change` 缺省:和上一期比的变化率;`level`:值本身,给存量、比率用)、`grain`(day / week / month / quarter,缺省按期次值推断)、`lag`(缺省 day→7,即和上周同一天比,避开星期效应;week→1;month→1;要同比就写 52 / 12,历史要相应够长)、`label`(度量的叫法,§7.3 消毒)。 + +**前提**(不满足就算不了,原因照实写) + +- 期次认得出、能排序(日期、`YYYY-MM`、`YYYY-Www`、整数序号):认不出 → `period_unreadable`。 +- 本期完整:用 §6 的完整性检查;本期没过完或数据没到 → `partial_target`(「本周数据只到 10 月 16 日,还差 3 天,和完整的周比没有意义」)。 +- 期次连续:相邻两期中间缺期的,那一对不算变化;本期和它的比较期之间缺期 → `target_gap`。 +- 历史够长:有效的历史变化(或值)不少于 `min_history` → 否则 `history_short`(「只有 5 期历史,至少要 8 期」);最多用 `max_history` 期。 +- 比较期为 0 不能算变化率 → `zero_base`。 + +**计算**:历史的中位数 `m`、中位绝对偏差 `MAD`,`σ̂ = 1.4826·MAD`;稳健 z = `(x − m)/σ̂`;常规波动范围 `[m − k_warn·σ̂, m + k_warn·σ̂]`;本期在「历史 + 本期」里的名次;历史最小、最大值。`σ̂ = 0`(历史每期都一样)时:本期也一样判 `within_band`,不一样判 `far_outside_band`,判定语改说「过去 N 期每期都是 x」。 + +**判定** + +| verdict | 条件 | +|---|---| +| `within_band` | `|z| < k_warn` | +| `outside_band` | `k_warn ≤ |z| < k_alert` | +| `far_outside_band` | `|z| ≥ k_alert` | + +direction 取 z 的正负;本期是「历史 + 本期」里的最大 / 最小值时加 caveat `record_high` / `record_low`(说法表里「历史新高 / 新低」要它)。values:`x`、`median`、`band_lo`、`band_hi`、`z`、`rank`、`n_history`、`hist_min`、`hist_max`。 + +为什么用中位数和 MAD、不用均值和标准差:业务序列常有促销周这类极端值,均值和标准差会被它拉宽,正常的周都显得「不异常」,异常的周也显得不异常。 + +## 4. 趋势(trend) + +回答「是持续在变,还是一次波动」。 + +**输入**:同 baseline 的序列(期次列 + 度量列),取最后 `n` 期(`trend_min_points ≤ n ≤ max_history + 1`)。前提:期次认得出、连续(中间缺期 → `series_gap`),最后一期完整(同 §3)。 + +**计算** + +- Theil–Sen 斜率:所有两两斜率的中位数;相对斜率 = 斜率 / 序列中位数(中位数为 0 只给绝对值)。 +- Mann–Kendall:`S = Σ_{i 0 → `rising`;< 0 → `falling`;否则 `no_trend`。values:`slope`、`rel_slope`、`p`、`n`、`run_length`、`run_direction`。日粒度序列加 caveat `weekday_effect`(「日数据有星期效应,看趋势建议按周汇总」)。 + +已知局限照实写进方法说明:Mann–Kendall 假设各期独立,业务序列常有自相关,p 会偏小;所以缺省 n 至少 6、α 取 0.05,判定语只说「呈上升趋势」,不说「显著上升」。 + +## 5. 差异检验(differ) + +回答「这两组(或两期)真的不一样,还是样本波动」。 + +**输入** + +- `rate` 模式:成功数列 + 试验数列(转化单数 / 访客数),按维度列 `by` 分组;`reference` 指定对照组(缺省整体),或 `pairs` 指定要比的几对;也可以按期次比同一组的两期。 +- `mean` 模式:每组的均值、标准差、样本数三列,或者合计、平方和、样本数三列(SQLite 没有标准差函数,`SUM(x)`、`SUM(x*x)`、`COUNT(*)` 都能写)。 +- `rows` 模式:逐行的值列 + 分组列(快照不超过 1000 行且没截断),算子自己算均值、方差,另做敏感性。 + +**计算** + +- 比率:每组 Wilson 区间(和评测用同一个函数,从 eval/stats 移到 analysis/stats,评测改为引用);两组之差用 Newcombe 区间;p 值:最小期望频数 ≥ `min_expected` 用合并比例的 z 检验,否则用 Fisher 精确检验(超几何概率用 `math.lgamma` 累加)。 +- 均值:Welch t 检验,自由度按 Welch–Satterthwaite;t 分布的分布函数用正则化不完全 β 函数(连分式),分位数用二分求逆。 +- 一次调用里比了多对:按 Holm 校正,表里同时给原始 p 和校正后的 p,caveat `multiple_comparisons`(「比了 4 对,已按次数校正」)。整个运行里做了几次检验由分析范围统计(细案 00 §6),不回头改已封存的结论。 +- `rows` 模式的敏感性:去掉离均值最远的那一个值后重算,判定变了 → caveat `outlier_sensitive`(「去掉最大的一笔后,差异不再站得住」)。 + +**判定**:校正后 `p < differ_alpha` → `differs`(direction 按差的正负);否则 `no_evidence`。任一组样本数小于 `min_n`(均值)或用了 Fisher(比率)→ caveat `small_sample`。 + +判定语不出现「p 值」「显著性」这类术语:`differs` 说「差异站得住」,`no_evidence` 说「差异可能只是样本波动,样本量:A 230、B 40」;p 值、区间在表和证据面板里。 + +## 6. 可比性检查(comparable) + +回答「这两期能不能直接比」。三处用到:agent 单独调用;decompose 两份快照、baseline / trend 的本期完整性;口径卡里跨两份查询算的环比、同比(细案 00 §4.3)。 + +**检查项**(每项 pass / warn / fail / unknown,unknown 只写「核对不了」,不判不可比) + +| 项 | 怎么判 | fail / warn | +|---|---|---| +| 同口径 | 两边 SQL 用口径账本的 `explain_sql_diff` 比:完全相同 / 只差字面量 / 结构不同 | 结构不同 fail(写出差在哪);只差字面量时,非日期的字面量也不同(`'华东'` 对 `'华南'`)→ 比的是两段不是两期,fail「两边除了日期,筛选的还不一样」;同一份快照里按期次分组的天然同口径 | +| 窗口长度 | 用 data/coverage 的 `filters` 取业务日期列(目录 business_date,没有就取时间谓词列)上的区间,现算的界照 coverage 在库里算;算两边天数 | 不相等:度量是 flow(可加流量)fail,ratio / stock / 没标 warn | +| 整期 | 窗口终点和表里这一列的实际最大值、运行当天比(实际范围用 coverage 的现查 + 剖析兜底,记进 probes) | 窗口没过完或数据没到:flow fail(「本期只有 3 天数据」),其余 warn | +| 星期构成 | 日粒度、天数不是 7 的倍数时,数两边的周六、周日天数 | 不同 warn(「两段的周末天数不同:4 天对 6 天」) | +| 数据问题 | 两边查询结果上的 SQL 检查错误、「查询区间里没有数据」 | 没有数据 fail,SQL 检查错误 warn | + +**判定**:全部 pass / unknown → `comparable`;有 warn 没有 fail → `conditional`;有 fail → `incomparable`。判定语列出 fail、warn 的原因,一句一个。 + +## 7. 判定语 + +### 7.1 由系统渲染 + +`[[a:A3]]` 渲染成 A3 的判定语,`[[a:A3.top_share|pct]]` 渲染具名标量(换算后缀同 `[[m:]]`)。判定语里的数照 cell_format 渲染、点得开 A3 的出处链。写作者不写判定,只引用判定——这是「叙述层无算术」往前走的一步:分析判定和数字一样由系统写。 + +### 7.2 模板(一种说法,analysis/phrases.py 一处定义,前后端都从结论里读渲染好的字符串) + +| op / verdict | 判定语 | +|---|---| +| decompose additive `driver` | `{label}{变化},主要来自{subject}({subject 的变化},占{share})` | +| `concentrated` | `{label}{变化},集中在{subjects}(合计占{top_k_share})` | +| `dispersed` | `{label}{变化},分散在各{维度名},没有哪一段占到一半` | +| `flat` | `{label}基本持平({pct})`;有抵消时接「,内部此消彼长:{最大的上升段} {变化}、{最大的下降段} {变化}」 | +| `unclosed` | `按{维度名}拆开后各段之和和总数对不上(差 {gap}),不判断变化来自哪里` | +| ratio `mix_driven` | `{label}{变化},主要来自结构变化:{subject}占比从 {w} 到 {w'}` | +| `rate_driven` | `{label}{变化},主要来自各段自身的变化,{subject}影响最大` | +| `both` | `{label}{变化},结构变化和各段自身变化都有影响(各占 {mix_share}、{rate_share})` | +| factor `driver` | `{label}{变化},主要来自{factor}的变化(贡献 {contrib},占{share})` | +| baseline `within_band` | `{label}{本期变化},在过去 {n} {期}的常规波动范围内({lo} 至 {hi})` | +| `outside_band` | `{label}{本期变化},超出过去 {n} {期}的常规波动范围({lo} 至 {hi})` | +| `far_outside_band` | `{label}{本期变化},明显超出过去 {n} {期}的常规波动范围({lo} 至 {hi})` | +| trend `rising` / `falling` | `过去 {n} {期}{label}呈上升 / 下降趋势(每{期}约 {rel_slope})`;`run_length ≥ 3` 时接「,已连续 {run} {期}上升 / 下降」 | +| `no_trend` | `过去 {n} {期}{label}没有明显的上升或下降趋势` | +| differ `differs` | `{A}的{label}({a}){高于 / 低于}{B}({b}),差异站得住` | +| `no_evidence` | `{A}和{B}的{label}差异可能只是样本波动(样本量 {nA}、{nB})` | +| comparable | `两期可比` / `两期有条件可比:{原因}` / `两期不可比:{原因}` | +| 算不了 | `{op 的叫法}没有做成:{reason}` | + +caveats 渲染在判定语后面,用「;」接,最多两条,其余在证据面板。 + +### 7.3 系统渲染的字里不能夹带模型写的数 + +判定语里的 `label`(度量叫法)、维度值、因子名是 agent 或数据给的。数据里的维度值照原样(遮罩列不当维度,已排除)。agent 给的 `label` 消毒:去掉数字、百分号、单位符号、引用标记,长度不超过 20 字,消毒后为空就用列名。否则模型可以把「销售额(同比 +30%)」塞进 label,借系统渲染绕过裸数字检测。`hypothesis` 不进任何系统渲染的正文(只在证据面板,标「agent 写的假设」)。 + +## 8. 算不了与调用错误 + +### 8.1 调用错误(不记结论,像 SQL 写错一样把错误交回 agent 改) + +列不存在、列被遮罩、度量列不是数、角色和目录度量类型冲突(比率当可加)、同一键同一期多行、`base` / `target` 不在期次列里、乘积不闭合、`parent` 指向的结论不存在或不是 decompose、下钻超过深度、本运行分析次数用完(`max_calls`)。错误文案一种说法,告诉 agent 怎么改。 + +### 8.2 算不了(记成结论,status `refused`) + +reason_code:`truncated`、`too_few_segments`、`history_short`、`partial_target`、`target_gap`、`series_gap`、`period_unreadable`、`zero_base`、`non_positive`、`incomparable_sides`(decompose 两份快照的口径结构不同,不拆)。「算不了」本身就是结论:写作者可以照实写「历史只有 5 周,判断不了是否异常」,说法表里的「异常」类判断不能挂一个 refused 的结论。 + +## 9. 说法表与绑定规则 + +### 9.1 判断词的类 + +| 类 | 判断词(举例,analysis/claims.py 一处定义,带版本) | 要挂的分析 | 判定要对得上 | +|---|---|---|---| +| 来源 | 主要来自、主要由、主要是…(下降 / 增长)、大部分来自、绝大部分、集中在、主导、拖累、拉动、带动、贡献最大 | decompose | `driver` / `concentrated`;句子里点名的段要在 subjects 里;拖累要那一段变化为负、拉动 / 带动为正 | +| 分散 | 分散、普遍、全面(上升 / 下降)、各…都 | decompose | `dispersed` | +| 抵消 | 抵消、此消彼长、一增一减 | decompose | 有 `offset` caveat 或 `flat` 带抵消 | +| 结构 | 结构变化、结构性、占比变化带来 | decompose ratio | `mix_driven` / `both` | +| 异常 | 异常、反常、罕见、明显偏离、超出正常、大幅、骤降、暴跌、激增、飙升、剧烈 | baseline | `outside_band` / `far_outside_band`;方向对得上 | +| 新高新低 | 历史新高、历史新低、创新高、创新低、前所未有 | baseline | 对应的 `record_high` / `record_low` | +| 正常 | 正常波动、常规波动、正常范围、并不异常 | baseline | `within_band` | +| 趋势 | 趋势、持续(上升 / 下降)、连续…(周 / 月)、一路、逐步、节节、稳步 | trend | `rising` / `falling`,方向对得上;「连续」要 `run_length ≥ 3` 且方向一致 | +| 无趋势 | 没有明显趋势、基本平稳、趋势不明显 | trend | `no_trend` | +| 差异 | 显著高于 / 低于、明显高于 / 低于、确实更高 / 更低、差异明显 | differ | `differs`,方向对得上 | +| 无差异 | 差异不大、差别不大、没有明显差别、相当 | differ | `no_evidence` | +| 因果 | 因为、由于、导致、造成、引起、受…影响、归因于、原因是、源于、所致、得益于 | 知识库原话 `[[q:]]` 或运行输入 `[[i:]]` 在同一句;或用了推测说法(可能、或许、推测、待核实、不排除、疑似) | — | + +- **否定**:判断词前 4 个字以内有「不、没有、没、并非、并不、未、无」的翻到对应的反面类(「并不异常」→ 正常,「没有明显趋势」→ 无趋势;「并非主要来自华东」→ 要 decompose 且华东不在 subjects 里)。 +- **来源和因果的分界**:句子挂了 decompose,因果词前后点名的是这份结论的段或因子(「下降主要由华东造成」)→ 按来源核,这是记账意义上的「来自」;点名的不是(「由于促销结束导致下降」)→ 按因果核。拆解只能说明变化来自哪里,说明不了为什么。 +- **不扫的地方**:知识库原话(`[[q:]]` 的原文整段涂白,同裸数字)、系统渲染的判定语与分析范围块、标题行。 + +### 9.2 怎么挂 + +一句话的分析依据 = 这句里出现的 `[[a:An…]]`、`[[v:An.…]]`、`[[table:An …]]`,以及句末 `[[see:An]]`(和 `[[see:Q1]]` 同一种写法)。来源、异常这些类要的 op 必须在这句的依据里出现;有好几个时任一个对得上就算对。 + +### 9.3 违规与处置 + +| 代码 | 什么时候 | 说法(给写作者的,给人看的另有一句) | +|---|---|---| +| `analysis_claim_uncited` | 句子有判断词,依据里没有对应种类的分析结论 | 「『主要来自』要挂按维度拆解的结论([[see:A3]]);没有拆解就只写各段的变化,不下『主要』的判断」 | +| `analysis_claim_mismatch` | 挂了,但判定、点名的段、方向对不上(含挂了算不了的结论) | 「A3 判的是变化分散在各地区,没有哪一段占到一半,不能写『主要来自华东』」 | +| `causal_unsupported` | 因果词没有知识库原话或输入作依据,也没用推测说法 | 「『由于促销结束』没有依据:数据只能说明变化来自哪里;要写原因,引用知识库原话,或写成『可能与…有关,待核实』」 | + +报告节点 `analysis_claims`: + +- `check`(缺省,含问数据):违规交回写作者重写**一次**(同 inline_see、absence_claim 那一类,不按 max_repairs 反复),仍有的标在报告里(证据面板、审计、导出都看得到),不让节点失败、不影响出具档位。 +- `strict`:按 `max_repairs` 重写;仍有的记成出具缺口,带出具契约的降档;受管级别要求 `strict`(门禁 G3 加一条)。 +- `off`:不核。 + +## 10. 设置缺省值(设置 → 偏好设置 → 分析;发起时快照进 run.options["analysis"]) + +| 键 | 缺省 | 用在 | +|---|---|---| +| `main_share` | 0.5 | 「主要」:一段(一个因子、一种效应)占总变化的比重 | +| `concentration` | 0.8 | 「集中」:前 k 段合计占比 | +| `offset_ratio` | 0.25 | 反向段合计超过总变化的这个比例记抵消 | +| `flat_pct` | 0.005 | 总变化小于基期的 0.5% 算基本持平 | +| `k_warn` / `k_alert` | 2 / 3 | 波动基线的稳健 z | +| `min_history` / `max_history` | 8 / 52 | 基线、趋势用的历史期数 | +| `trend_min_points` / `trend_alpha` | 6 / 0.05 | 趋势 | +| `differ_alpha` / `min_n` / `min_expected` | 0.05 / 30 / 5 | 差异检验 | +| `min_denominator` | 30 | 比率拆解里「分母小」 | +| `max_calls` / `max_drill_depth` | 12 / 3 | 一次 agent 运行最多分析几次、下钻几层 | +| `table_top_k` | 10 | 写作目录、判定语里列几段 | + +- **agent 改不了阈值**:分析工具的参数里没有阈值,阈值只来自设置快照;分析卡(模板)可以在节点上写覆盖,写进图、随版本固定。这样模型不能把阈值调到想要的结论。 +- 每份结论记下实际用的阈值,判定标准(criterion)照这些值写出来,例如「一段占总变化一半以上算主要来源」。 diff --git a/docs/design/app-platform-and-look.md b/docs/design/app-platform-and-look.md new file mode 100644 index 00000000..ff7570b3 --- /dev/null +++ b/docs/design/app-platform-and-look.md @@ -0,0 +1,1614 @@ +# 应用平台与助手按需查看:总方案 + +> 设计文档 · 草案,待拍板 · 2026-10-14 +> +> - 本文档只定方案,不含代码改动。第六部分「实施组织」在本文签字后另行审批,再开工。 +> - 代码位置按 **main@fc002bd** 标注,全部用 `git show main:` / `git grep … main` 核对过。 +> - 和《助手按需查看与口径账本》(`docs/design/copilot-look-and-caliber-ledger.md`,下称「look 文档」)配套。本文不改 look 文档: +> - 对它的修订写在 §2.9(L1–L4); +> - 引用它的地方写作「look §x.y」。 +> - 文中出现的数据源名、表名、应用名、岗位名都是虚构的。本文不含本机开发环境的计数。 + +## 目录 + +- 摘要 +- 第一部分 定位、理念与现状 +- 第二部分 关键决定(A1–A8,L1–L4,X1–X4) +- 第三部分 规格 +- 第四部分 界面 +- 第五部分 助手按需查看(实施版) +- 第六部分 实施组织 +- 第七部分 验证与验收 +- 第八部分 风险与对策 +- 第九部分 待拍板 +- 附录 A 代码位置索引 · B 术语 · C 跨包接口契约 · D 核对方法 + +--- + +## 摘要 + +**问题。** AgentLab 现在有两个面: +- 「问」:问数据,产出一次推理; +- 「建」:画布加发布,产出一份带版本的承诺。 + +承诺签出去之后,交到谁手里、怎么用,产品里没有对应的地方: +- 只想看数的人,要去记录页翻运行; +- 要在流程里批一步的人,只能在记录页的待审批页签里处理,而那是给搭建者看的; +- 要把结果写回外部系统,只能靠 MCP 或自定义工具,而它们没有读写语义,也不能访问内网。 + +**定位。** 应用不是新引擎,也不是嵌进来的低代码平台,而是**已发布版本的使用面**。产品从两个面变成三个面,三个面共用同一套对象:工作流版本、运行、证据、口径。 + +| 面 | 现在对应 | 产出 | 给谁 | +|---|---|---|---| +| 问 | 问数据 | 推理(探索运行) | 要答案的人 | +| 建 | 画布、发布 | 承诺(不可变版本) | 定口径、定流程的人 | +| **用**(新) | 应用 | 使用承诺,产生生效 | 看数、办事的人 | + +**本方案做什么。** + +1. **应用**:看板、流程和两者混合,都是同一个对象模型——入口、视图、实例、待办、动作、询问。 +2. **流程**:正式运行加待办。指派、会签、时限与升级、待办箱、通知、入站钩子。 +3. **动作**:写外部系统。幂等、状态机、结果未知时对账,确认时看过的就是发出去的。 +4. **能力登记**:把数据源、自定义工具、MCP、内置工具的描述和治理统一起来;登记的内网端点;密钥加密;外部读取的结果也能作证。 +5. **身份**:三种模式 off / local / oidc,角色、组、应用授权、审计。默认 off,即今天的行为。 +6. **生成**:从工作流版本确定性地派生应用,助手只负责润色。 +7. **助手按需查看**:按 look 文档实施,四项全按推荐,并叠加四处修订。 +8. **修掉四个和不可回退决定冲突的现有缺陷**(F1–F4,见 §1.6)。 + +**已拍板:** +- 范围是应用平台加 look,作为一份总方案; +- 先出设计文档,签字后实施; +- look §4 的近期四项全按推荐,并采纳修订 L1–L4。 + +**待拍板**:见第九部分,共 10 项,每项附推荐。 + +--- + +## 第一部分 定位、理念与现状 + +### 1.1 对象模型 + +| 概念 | 是什么 | 现有对应 | 要新增 | +|---|---|---|---| +| 入口 | 填表,发起一件事或一次查询 | 输入节点字段 → 正式运行(`RunControl.tsx:242` FormalLauncher 已按版本生成表单) | 字段类型、受信组件、校验 | +| 视图 | 看数据、看结果 | 运行成果、报告文档、可点击证据(`EvidenceDoc.tsx`) | 绑定规则、印章(截至 / 口径 / 出具档位 / 来源) | +| 实例 | 一件正在办的事 | 一次正式运行:断点落盘,重启后还能接着等(`runner.py:60` setup、`:278` resume) | 来源、实例列表、发起人 | +| 待办 | 等某个人处理的一步 | 人工节点 + Approval 表(`runner.py:821` `_handle_updates`) | 指派、待办箱、时限、升级、会签 | +| 动作 | 写回外部系统 | 调用工具节点 + 审批档位 | 写入能力、幂等、状态机、对账 | +| 询问 | 运行中向人要信息 | 人工节点(表单写死在配置里,`human.py:31`) | agent 在受信组件范围内拟问题 | + +几种应用的组成: +- **看板**:入口(筛选条件)+ 视图; +- **审批流程**:入口 + 实例 + 待办 + 动作 + 实例视图; +- **混合**:比如在异常看板上点「发起处置」,进入一个流程。 + +**流程引擎的核心已经有了:** +- LangGraph 的检查点就是持久化的流程状态; +- runner 按「两次人工介入之间的一段」计时(`runner.py:717`,等审批的时间不算); +- 审批可以等很久,批完接着跑。 + +缺的是三件事:谁来处理、处理的人在哪里看到、超时了怎么办。 + +### 1.2 设计理念 + +前六条来自 look §2.1,后两条为本方案新增。方案里每个决定都要对得上。 + +| # | 理念 | 在本方案里怎么体现 | +|---|---|---| +| P1 | 照实说 | 印章写明截至何时、哪一版口径、哪一档出具、数据从哪来;版本错位、演练、结果未知都照实标出 | +| P2 | 留痕 | 来源记进封存范围;待办的每次回复、动作的每次发送都有记录;管理操作写审计 | +| P3 | 不挡生成 | 派生不靠模型;询问的表单不合规时退回静态表单,不中断运行 | +| P4 | 只读与遮罩 | 不可见的组件和遮罩值不出服务端;确认卡显示遮罩后的预览 | +| P5 | 一种说法 | 「已发布版本」由一个函数判定;一个表单渲染器;一份组件目录;一份能力登记 | +| P6 | 模板定骨架,agent 填关节 | 流程骨架在图里,人在应用里绑定;助手只负责润色 | +| **P7** | **界面层不放逻辑** | 和「叙述层无算术」同一个道理:绑定只取已有的值,表达式禁止算术,逻辑全在图里 | +| **P8** | **生效可查、可对账** | 写外部系统必须先留记录再发送;结果未知永不自动重发;确认时看过的就是发出去的 | + +**不可回退的决定**(见路线图),本方案逐条守住,对照表在 §2.11: +- 叙述层无算术; +- 正式运行只能从发布的不可变版本发起,按 graph_hash 钉死; +- 口径升版是必须处置的事件; +- 没发布的图只能跑探索运行; +- 不上 Postgres,不做节点级锁,单进程 + SQLite。 + +### 1.3 不做什么 + +- 不让模型或用户写前端代码,界面上没有客户端逻辑。 +- 界面不直接连业务系统,也不直接查库。所有数据都来自运行产物,所有写入都经过调用工具节点。 +- 不做通用的 BPMN 建模器,不做像素级设计器。以后真要自由排布,可以把 Puck 一类的编辑器只当外壳接进来。 + +差异化不在组件多,而在三点:每个数都能追溯,每个动作都能查,每个版本都钉死。 + +### 1.4 现有零件 + +| 应用需要的 | 现有零件(main) | +|---|---| +| 从发布版本发起 | `engine/launch.py:45` `launch_run`。正式运行只从 `WorkflowVersion` 快照取图;`notes`(`:147`)在同一事务、`run.started` 之前写入,在封存范围内 | +| 断点与恢复 | `engine/runner.py`:`resume`(`:278`)、`continue_failed`(`:449`)、`_handle_updates`(`:821`);`engine/replay.py`:`once` / `ask`、`PROTOCOL = 2`(`:32`) | +| 人工介入 | `engine/nodes/human.py`(approve / input / edit);`engine/approval.py:60` `read_decision`(只认明确的批准);`api/runs.py:525` `approvals_router` | +| 审批策略 | `tools/trust.py`:`call_policy`(`:48`)、`run_snapshot`(`:84`)、`ask_gate`(`:171`);`tools/registry.py:98` `call_is_dangerous` / `metadata["dangerous_if"]` | +| 发布门禁 | `engine/governance.py`:`lint_for_publish`(`:33`)、`publish_issues`(`:127`);`engine/autofix.py:700` `_PLANNERS` | +| 定时与通知 | `scheduler/service.py:250` `fire_once`;`scheduler/loop.py:114` `tick`(每 15 秒,`:26`);`scheduler/webhook.py`:四种格式、签名、`send`(`:506`) | +| 最近一次封存的运行 | `scheduler/auto_compare.py:97` `sealed_now`;`api/compare.py:100` `pick_base` | +| 证据与渲染 | `engine/evidence.py`:`compose_doc`(`:2737`)、`render_metric`(`:691`)、`render_cell`(`:702`);`api/evidence.py`:`_sealed`(`:239`)、`_chain`(`:843`);`report_export/build.py`(服务端渲染报告) | +| 表达式 | `engine/expressions.py`:AST 白名单(`:455` 根名字,`:543` `parse_expression`,`:692` `eval_expression`) | +| 密钥 | `core/crypto.py`:Fernet、`enc::` 前缀、`mask()` | +| 字段语义 | `data/catalog.py`:「项」的形状 `{value, source, status, note, reason}`,`TABLE_FIELDS` / `COLUMN_FIELDS`(`:79`、`:81`);审阅队列 | +| 遮罩 | `data/engine.py:376` `masked_columns`;`engine/masking.py:2806` `mask_query_result` | +| 从运行提取模板 | `api/copilot.py:2496` `/copilot/from-run`(确定性:只保留实际执行的节点) | + +### 1.5 和 look 文档、口径账本的关系 + +- **look 是应用生成的地基。** 系统一多,生成时就更不可能把所有能力预先塞进 system,只能先给概览,需要时再查看。 +- **口径账本**(look 第三部分): + - 应用是口径的又一类使用方,账本里多一条「口径 → 应用」的边; + - 动作是账本里唯一不能重算的一类记录; + - 应用里的「问」,就是把组合式问数限定在这个应用引用的口径里(账本那部分仍按 look §3.8 先做实验)。 + +### 1.6 main 上核实过的缺陷与冲突 + +| # | 事实 | 位置 | 影响与处置 | +|---|---|---|---| +| **F1** | 正式运行只认**当前**发布版:`run_version != workflow.published_version` 一律拒绝 | `engine/launch.py:92` | 应用钉不住旧的发布版。改为接受「已发布且未退役」(A1) | +| **F2** | **和不可回退决定冲突**:`continue_failed` 收到 `graph` 时只校验拓扑,不看运行类别,用 `_claim(..., graph=...)` 改写 `Run.graph`,`version_hash` 不变;同一处还把 `started_by` 覆盖成接着跑的人 | `engine/runner.py:449–540`、`:519`;`api/runs.py:298` | 正式运行在不知不觉中不再等于它钉住的版本。正式运行带图接着跑一律拒绝;接着跑不再覆盖发起人(A4) | +| **F3** | 口径升版的判定用 `func.max(WorkflowVersion.version)`,草稿版本也算进去;而工作流每保存一次就生成一个草稿版本 | `engine/governance.py:613`、`:643` | 上游改一下画布,下游的正式运行就被逼着处置。改为只看已发布且未退役的版本(A1) | +| **F4** | 删除工作流时版本级联删除 | `db/models.py:513` | 应用钉着的版本、带生效记录的版本可能被删掉。这两种情况拒绝删除(A1) | +| F5 | 发布人、发布时间只记在工作流上;各版本只有 `level` | `db/models.py:506` | 每个版本补记发布人、发布时间、退役信息 | +| F6 | 运行没有来源字段:问数据和画布试跑都是「裸图 + 探索」,分不出来 | `db/models.py:535` | 新增来源(X3) | +| **F7** | 正式运行的信任快照里,所有 MCP 和自定义工具一律要审批;带这类工具的 agent 会停在节点半路 | `tools/trust.py:84` | 流程跨系统升级会撞上 `_stale_replay`(F9)。根本办法是在能力登记里声明「只读」(A6) | +| F8 | 子工作流不带 checkpointer,里面的人工节点恢复时,整个子图从头重放 | `engine/nodes/multi.py:610` | 流程应用里禁止这种接法(发布检查) | +| **F9** | `_stale_replay`:断点是按旧重放协议写的,而节点在断点之后执行过工具或批过一次时,拒绝恢复;只豁免人工节点 | `engine/runner.py:1226` | 停在节点边界的人工审批是安全的,停在 agent 半路的不安全(A4) | +| F10 | 表达式的根名字是固定集合,允许算术 | `engine/expressions.py:455` | 加 `roots=`、`arithmetic=False` 两个参数(A2) | +| F11 | 证据防伪按名字前缀认:`name.startswith(QUERY_PREFIX)` | `engine/nodes/tools.py:186`、`engine/nodes/llm.py:511`、`:874` | 外部读取要作证,得按「平台执行」这个身份认,不能按结果长得像不像(A6) | +| F12 | 没有任何鉴权:`/api/artifacts/{id}` 直接返回原文,证据路由都在 `/api/runs/{run_id}/…` 下 | `backend/app/main.py:151` 只有 CORS 和 `include_router` | 鉴权挂在 `include_router` 的路由级依赖上(A7) | +| F13 | 自定义工具的 headers、MCP 的 env 明文存库、明文返回;MCP 的 http 地址不经 SSRF 检查,也不能配认证头 | `tools/custom.py:127`、`tools/mcp_manager.py:18`、`db/models.py:447` | 加密、掩码、收口(A6) | +| F14 | `WebhookDelivery.schedule_id` 是非空外键,SQLite 改不了可空性 | `db/models.py:914` | 通用投递记录另建表(A4) | +| F15 | `_INDEX_MIGRATIONS` 只支持单列索引 | `db/base.py:225` | 扩成支持多列 | +| F16 | 导航有 8 项(`components/CommandPalette.tsx:53`),check-shell 要在 480px 高度下量 | `scripts/check-shell.mjs:187` | 只能加 1 项(§4.1) | +| F17 | 前端依赖很少:没有 JSON Schema 校验库,也没有图表库;后端锁文件里有 `jsonschema 4.26.0` | `frontend/package.json`、`backend/requirements.lock:109` | 前端只校验目录规定的子集,图表自绘 SVG;后端以 jsonschema 复核为准 | +| F18 | look 文档引用的 `copilot*.py` 位置在 main 上没变;`catalog.py`、`decode.ts`、`terms.ts` 的位置变了 | 见 §5.5 | 附录 A 按 main 更新 | + +--- + +## 第二部分 关键决定 + +每条都写四样:决定、理由(对应 §1.2 的理念)、否决的做法、代价。 + +### 2.1 A1 应用和应用版本是独立对象,钉住工作流版本 + +**决定** + +- **App 可变**:名称、草稿、授权。**AppVersion 不可变**: + - spec(`agentlab.app/1`)、钉住的版本(pins)、岗位与钩子的绑定、级别、`spec_hash`; + - 只在发布时生成,草稿不进版本表。 +- **一个钉住项**写作 `{alias, workflow_id, version, graph_hash, level, use: view|entry|process}`。 +- **应用的级别**取各钉住项里最低的那一级:全部是受管级,应用才算受管级。 +- **哪些改动要发新版本**: + - 改钉住项、改 spec、改岗位绑定:要发新版本; + - 改授权、改组成员:不发新版本,写审计。 +- **钉住项升版是必须处置的事件**:新应用版本把某个钉住项从 vN 升到 vM 时,必须为它选一种处置——recompute、dual 或 incomparable,记进 `app_versions.dispositions`。趋势类绑定按这个选择来画(§3.4)。 +- **工作流版本可以退役**(`POST /api/workflows/{id}/versions/{v}/retire`): + - 当前发布版不能退役; + - 退役时列出受影响的应用; + - 钉着被退役版本的应用标「需升级」:入口拒绝发起新实例,视图保留最后一次的数据并注明「钉住的版本已退役」,进行中的实例照常走完。 +- **`launch_run` 的改动**: + - 接受 `level 不为空 且 retired_at 为空` 的版本; + - 老数据 `level 为空` 时,只认它等于 `published_version` 的情形; + - 新增参数 `expect_hash`,应用发起时必须带上,和快照的 `graph_hash` 不一致就拒绝; + - 新的拒绝码:`run_version_unpublished`、`run_version_retired`、`run_version_hash_mismatch`。 +- **新增 `published_versions(session, workflow_id)`**:发起运行、口径升版的判定、子图钉版本、应用钉版本,都用这一个函数(修 F3)。 +- **删除约束**:工作流有应用钉着、或某个已发布版本有生效记录时,拒绝删除(409,列出原因)(修 F4)。 + +**理由** +- P2:承诺不可变; +- P5:「已发布版本」只有一种说法; +- 守住「正式运行只从发布的不可变版本、按 graph_hash 发起」,同时补上 F2 那个口子(见 A4)。 + +**否决的做法** +- 把界面写进图里:每改一次布局就成了新口径,graph_hash 跟着变; +- 内嵌低代码平台:有客户端逻辑,数字核对不了; +- 应用每保存一次就生成一个草稿版本:F3 已经说明这么做有害。 + +**代价**:使用者要理解两套版本(应用版本、工作流版本);升级时多一步处置。 + +### 2.2 A2 界面层不放逻辑:受信组件目录 + 服务端解析 + +**决定** + +- **组件目录登记在后端**(`app/apps/catalog.py`)。每个组件登记:名称、版本、类别、props 的 JSON Schema、允许的绑定类型、服务端校验器、解析器。 + - 前端只有一张「名称 → 渲染器」的表; + - 前端不认识的组件,显示「此组件需要更新前端」(P1),不报错、不空白。 +- **绑定是带类型的对象,不是模板。** 值里不做插值;实例标题这类展示文字,只能按名字引用入口字段,由服务端拼。 +- **可见性表达式**用 `parse_expression(expr, roots=APP_ROOTS, arithmetic=False)`,和画布校验、运行时是同一个解析器: + - 根名字:`run`、`input`、`metric`、`instance`、`task`、`user`、`view`; + - 只允许比较、布尔运算、取值和 `in`; + - 不可见的组件**服务端不下发**(P4)。 +- **表单统一用 `agentlab.form/1`**(§3.3):JSON Schema 的一个子集,加 `x-widget`、`x-order`。 + - 前端只校验这个子集;后端用 jsonschema 复核,以后端为准; + - 两边的错误格式一样:`{path, code, message}`。 +- **一份目录,四个使用方**:应用页、人工节点表单、agent 拟的询问、助手生成。 + - 开发者可以扩展:组件 + props schema + 校验器 + 前端渲染器; + - 模型只能从已登记的组件里挑。 +- **格式**参考 A2UI v1.0 的「目录」和「数据模型」概念,不引它的库,也不承诺兼容。 + +**理由** +- P4:隐藏的数据不出服务端; +- P5:只有一个求值器; +- P7、叙述层无算术:表达式里禁止算术。 + +**否决的做法** +- 客户端求值(json-render、amis 那类):两个求值器早晚会走样,还会把隐藏数据带到浏览器; +- 让模型输出 JSX 或 HTML。 + +**代价**:每加一个组件,前后端都要各登记一次;联动要往返服务端,交互没那么灵活。 + +### 2.3 A3 视图只读运行产物 + +**决定** + +视图的数据有三种来源: + +| 来源 | 取什么 | 规则 | +|---|---|---| +| `latest_sealed {pin, from}` | 钉住版本、同一 graph_hash 最近一次成功且已封存的正式运行 | `from` 可以是 `schedule:` 或 `formal`;用 `sealed_now` 判断是否封存完整;派生时优先绑定已有的定时任务 | +| `on_demand {pin, params, ttl_s, auto}` | 按参数发起一次运行,结果缓存 | 缓存键见下;查活库的源必须设 TTL;默认点「刷新」才跑;每个应用有每日上限 | +| `instance` | 实例自己的那次运行 | — | + +`on_demand` 的细则: +- 缓存键 = sha256(graph_hash, 规范化后的输入, 上传源当前的 data_versions, 用到的能力版本); +- 查活库的源必须设 TTL:默认 600 秒,最多 86,400 秒; +- 同一个键在进程内只跑一份; +- 默认要点「刷新」才跑;只有声明了 `auto` 而且没有缓存时,打开页面才自动发起。 + +**印章**由服务端算,每个视图都带: + +| 项 | 内容 | +|---|---| +| 截至 | 运行结束时间、输入里的统计期、数据版本 | +| 口径 | 「工作流名 vN · 哈希前 8 位」,加上口径卡里的口径名和版本 | +| 出具档位 | 取 `_issuance.tier` | +| 来源 | 「定时任务『…』第 n 次」、「按需运行」或「本实例」 | + +定时任务已经改跑新版本、应用还钉在旧版本时,照实标出来。 + +**理由** +- P1; +- 数字只来自封存的证据链(P2); +- 叙述层无算术。 + +**否决的做法**:视图直接查库(绕过口径和证据);在浏览器端缓存。 + +**代价**:按需运行要花 token 和时间。靠三样东西控制:点「刷新」才跑、同一个键只跑一份、每日上限。 + +### 2.4 A4 流程 = 正式运行 + 待办 + +**决定** + +1. **一次中断对应一条 Approval(给引擎)和一条 Task(给人)。** + - Task 下挂若干条派给具体的人或组的记录(assignment)。 + - 所有人的决定都经 `task_service.respond` 处理;`/api/approvals/{id}/decide` 和 `/api/runs/{id}/resume` 改成兼容的垫片。 + - 升级时为存量的待审批补建 Task(派给任何人)。 + +2. **人工节点新增配置**(写在图里,属于流程语义;字段规格见 §3.7): + - `assignee`:岗位、发起人、表达式或钩子; + - `policy`:any、all、quorum(m); + - `due`:ISO 8601 时长; + - `on_timeout`:提醒、升级或驳回; + - `form` / `form_from`; + - `correlation`。 + +3. **图里只写岗位名,不写具体的人。** + - 岗位到组或人的绑定放在 AppVersion 里; + - 人员变动只改组成员,不发新版本(写审计); + - 应用可以覆盖表单的外观(标题、顺序、控件),不能改数据约束。 + +4. **会签**(只用于审批模式): + - `all`:全体同意才通过,任何一人驳回立即否决; + - `quorum(m)`:m 人同意就通过;驳回的人数超过 n − m 即否决; + - 派给组时,在待办打开那一刻展开成当时的成员(快照,写审计)。 + - 汇总在待办服务这一层做,判定之后才调一次 `run_manager.resume`,**引擎和重放都不改**。 + - 并发靠条件 UPDATE:assignment 从 pending 到 responded,task 从 pending 到 resolving,只有一个请求能抢到恢复。 + +5. **超时**:调度循环每一拍跑 `tasks.tick`,处理提醒、升级、驳回。**永远不会自动批准。** + +6. **待办箱**:派给我的待办。关闭登录时,所有待办都算「我的」。 + +7. **通知**: + - 把 `scheduler/webhook.py` 里的格式、签名、发送、脱敏抽到 `app/notify/`;`webhook.py` 照原名再导出,现有测试不动。 + - 新增渠道表和通用投递表;定时任务的通知存储不迁移(F14,Q7)。 + - 通知里只放标题、状态、链接,不放报告里的具体数字(沿用 `webhook.py` 的规矩)。 + +8. **入站签名钩子**: + - 签名和出站的通用格式是同一套:`HMAC-SHA256(ts.body)`,时间窗 5 分钟,用 `Idempotency-Key` 去重; + - `start`:按入口发起实例,请求体按入口表单校验; + - `signal`:按 `correlation` 解决一条「等外部系统」的待办。 + +9. **升级安全**(F7、F8、F9): + - 发布检查拦三种接法:agent 半路需要审批(`process.wait_mid_agent`)、子图里放人工节点(`process.human_in_subgraph`)、需要审批的工具放进 agent 的工具清单; + - 动作确认放在调用工具节点的开头、`tool.start` 之前——按 F9 这是安全的; + - 「结果未知」不在节点里等(A5); + - `_stale_replay` 只多豁免一种情况:调用工具节点在断点之后的每条 `tool.start` 都带 `action_key`。这类节点发送前一定先查 ActionRecord,重复执行由数据库挡住; + - 在 `engine/replay.py` 写明规矩:以后改节点里 task 的调用顺序,就把 PROTOCOL 加一,并跑升级安全测试(§7.5)。 + +10. **agent 拟的询问**: + - 上游 agent 输出一份 `agentlab.form/1`,下游人工节点用 `form_from` 接住; + - 等待发生在节点边界,不新增 agent 半路询问的工具; + - 运行时按目录校验,不合规就退回节点上的静态表单,并记一条警告(P3); + - 询问的正文用报告那一套 `[[…]]` 标记渲染,裸数字照实标「无证据」,不拦。 + +11. **修 F2**: + - 正式运行带图接着跑,一律拒绝(409,`formal_graph_locked`); + - 接着跑不再改 `started_by`,接着跑的人只记在 `run.resumed` 事件里。 + +**理由** +- P2; +- P6:流程骨架在图里,人在应用里; +- 守住「不做节点级锁、单进程」。 + +**否决的做法** +- 引入 BPMN 引擎; +- 在引擎层做会签(一次挂起多个中断); +- 把具体的人写进图里; +- 给 agent 一个半路询问的工具(会撞上 F9)。 + +**代价**:人工节点的配置变多;组成员按快照展开,之后加入组的人不会补派。 + +### 2.5 A5 动作:写外部系统 + +**决定** + +1. **写入能力只能放在调用工具节点里。** + - `build_tools(..., allow_write=False)` 默认拒绝写入能力,agent、协作节点的代码都不用动; + - 只有调用工具节点传 `allow_write=True`; + - 发布检查再拦一道(`action.write_in_agent`)。 + +2. **ActionRecord 是权威记录。** + - `action_key = sha256(run_id, node_id, exec)`,`exec` 取 `engine/evidence.py:224` 的 `next_exec`; + - 第 n 次发送的幂等键是 `{action_key}:{attempt}`; + - 状态:pending → sent → confirmed | failed | unknown,另有 simulated(演练); + - **先写记录再发送**,节点每次执行都先按 `action_key` 查(流程图见 §3.8): + + | 查到的状态 | 怎么做 | + |---|---| + | confirmed | 直接交回存下的回执,不再发 | + | sent / unknown | 不发 | + | failed,且有人确认过要重发 | attempt 加一后再发 | + +3. **按引用确认:看过的就是发出去的。** + - 确认卡显示遮罩后的预览和 `payload_hash`; + - 恢复时,节点重新渲染参数、重算哈希,和确认时的哈希对不上就拒绝发送; + - 动作确认不接受改参数(`args` 不理),要改就在上游的表单里改。 + +4. **结果未知。** + - 节点抛 `ActionPending`,运行收成 interrupted(挂起,不封存),发 `action.unknown`; + - 登记了查询能力的:调度循环每拍跑 `actions.tick`,带退避去查; + - 没登记的:开一条核实待办,派给应用的 reconciler 岗位。选项是「已生效(填外部单号)」、「未生效,重新发送」、「未生效,放弃」; + - 核实之后,系统自动接着跑; + - **结果未知永远不会自动重发。** + +5. **环境。** + - 正式运行走 prod; + - 探索运行走 test;端点没有 test 环境时走演练:不发请求,记 simulated,回执标 `simulated: true`(Q4)。 + +6. **不新增运行类别。** 运行类别说的是图从哪里来;写没写外部系统,是运行里发生的事,记成这次运行的生效记录。 + +**理由** +- P1; +- P8; +- 守住「不加锁」:幂等靠数据库记录,不靠锁。 + +**否决的做法** +- 在节点里等核实:会撞上 F9; +- 让引擎重试:会重复生效; +- 让前端直接调外部系统; +- 新增一类 `action` 运行。 + +**代价**:多了一种挂起状态,定时任务通知的 `_state_of`(`webhook.py:720`)要能认出它。 + +### 2.6 A6 能力登记 + +**决定** + +1. **`capabilities` 表 = 系统同步的部分 + 人工治理的叠加层。** + - 主键沿用图里工具的名字(`tools/registry.py:142` `build_tools` 的命名规则)。 + - 系统同步:内置工具、数据源、自定义(http / python)、MCP。启动时同步一次,各对象增删改时同步;MCP 的 `readOnlyHint` 只当「建议只读」,要人确认。 + - 治理字段: + - `effect`:read / write / dynamic; + - `approval`:inherit / confirm / none; + - `unattended_write`:是否允许无人值守写入; + - `returns_schema`;`tabular`:行的 JSON 指针和列映射; + - `fields`:按 JSON 指针写字段语义,**沿用数据目录「项」的形状** `{value, source, status, note, reason}`,审阅队列照样可用; + - `masked`、`credential_mode`、`lookup`(查询能力)、`endpoint_id`。 + +2. **登记的端点(endpoints)。** + - 按 test / prod 两个环境分别存 `base_url` 和认证头(加密); + - `allow_intranet` 只能由管理员开,**而且只有登记的端点能访问内网**;开了也照样拦元数据地址和高危端口(`net.py:17`); + - 模型给出的地址照旧走完整的 SSRF 检查(`net.py:98`); + - 绑定端点的 HTTP 能力只填路径模板,参数按路径段编码代入,模型拿不到任意 URL。 + +3. **凭据模式:`service` 和 `service_on_behalf`。** + - `service_on_behalf` 用服务凭据,另带一个 AgentLab 签发的身份断言头:EdDSA JWT,5 分钟有效,`aud` 写端点名,公钥在 `/api/auth/jwks`; + - **不存用户自己的 token**(Q3):流程一等就是几天,会超过 token 的寿命;存刷新令牌会扩大泄露面。 + +4. **审批跟着登记的效果走。** + - 正式运行里:管理员声明为只读、审批策略为 none 的能力直接放行,其余照旧要审批——从根上修 F7; + - 探索运行沿用三档信任; + - 运行发起时,把用到的能力的治理快照放进 `options["capabilities"]`,写进 `run.started`,重放时用同一份。 + +5. **发布时记下能力版本**(`workflow_versions.capability_versions`)。正式运行发起时如果有漂移只提醒、不拦,做法同数据目录漂移(`launch.py:155` `CATALOG_DRIFT`)。 + +6. **密钥**(修 F13): + - 自定义工具的 headers、MCP 的 env,值都加密,接口只回掩码;提交掩码值表示保持原值; + - 启动时把存量的明文加密一遍(`encrypt` 本身是幂等的); + - MCP 的 http 地址要么绑定端点,要么过 `assert_safe_url`,并支持加密的认证头; + - MCP 的 stdio(在服务器上执行命令)只有管理员能建。 + +7. **端点读取的结果可以作证。** + - 平台执行器按 `tabular` 把响应落成**和查询快照同一个形状**的工件:`{artifact, source, columns, rows, truncated, request}`;台账类型仍是 `query`,编号进 Q1…。这样报告的单元格标记、整表标记、口径卡的取值、运行对比、可验证报告都不用改。 + - **防伪**: + - 只认登记表里标为「平台执行的端点读取」的名字(新函数 `is_platform_query(name)`,替换 F11 那三处判断),不看结果长什么样; + - 工件必须是本次运行落下的 `endpoint_snapshot`; + - 外部响应的原文嵌在快照里面,伪造不了顶层字段; + - Python 自定义工具(用户代码在算)和 MCP(没有结构化回执)不算。 + +**理由** +- P5:能力只有一种说法; +- P4; +- P1:外部系统来的数字也有出处。 + +**否决的做法** +- 新增一个「连接器」概念(变成第四种说法); +- 新增一种证据类型:会波及运行对比、可验证报告、质询、复算、反方审稿好几处。 + +**代价**:端点的结果要先声明 `tabular` 才能作证;能力登记要人维护。 + +### 2.7 A7 身份 + +**决定** + +1. **三种模式**(环境变量 `AGENTLAB_AUTH_MODE` 优先于设置页): + - **off**(默认):就是今天的行为。`X-Actor` 自报署名,所有人等同管理员,界面上挂横幅说明; + - **local**:用户名 + 密码,用 `hashlib.scrypt`,不加依赖; + - **oidc**:授权码 + PKCE,用 `httpx` 和 `pyjwt` 的 `PyJWKClient`;state、nonce、verifier 用 Fernet 加密后放进短时 cookie。 + +2. **会话**: + - HttpOnly、SameSite=Lax 的会话 cookie,库里只存令牌的哈希; + - 闲置 12 小时、绝对 7 天过期; + - 非 GET 请求必须带 `X-AgentLab-Request` 头,并校验 Origin; + - 登录失败在进程内退避。 + +3. **角色和授权**: + - 角色 admin ⊇ builder ⊇ user; + - 组:本地建,或从 OIDC 的 groups 声明同步(同步来的只读); + - 应用授权:use、observe、manage。 + +4. **接入方式**: + - `actor_of`(`api/runs.py:28`)搬到 `api/deps.py`,改由中间件写 contextvar。开了登录,返回认证过的用户名,忽略请求头;关了登录,照旧解码 `X-Actor`。**各接口的签名都不用改**; + - 权限挂在 `main.py` 的 `include_router(..., dependencies=[...])` 上;证据路由按 `run_id` 判断能不能读这次运行; + - WebSocket 只给 builder 及以上用;user 角色的实例页改为轮询。 + +5. **留痕**:发起时的 `run.origin` 记 `actor_verified`;派给人的记录上记 `verified`;管理类操作写审计表。 + +6. **开了登录后,user 角色看到的数据一律遮罩**: + - 查询快照按派生遮罩处理;`tool_snapshot` 的原文不给;视图一律遮罩; + - 从这一刻起,遮罩对 user 角色就是边界。`data/engine.py` 里那句「在有身份体系之前,遮罩只减少暴露,不是安全边界」要改写;README 照实写清哪些角色能看到原值。 + +7. **引导与找回**: + - 从 off 切到 local 时,必须在同一个请求里建出第一个管理员; + - 切到 oidc 时,至少配好一个管理员映射; + - 提供 `python -m app.auth reset-admin`。 + +**理由** +- P2;P4; +- 改动面最小:只动一个函数,再加路由级依赖。 + +**否决的做法** +- JWT 放 localStorage:WebSocket 要另想办法,还有 XSS 风险; +- 逐个接口加鉴权:改动大,容易漏; +- 只靠外部认证代理:拿不到 user 角色这一层的粒度。 + +**代价**:README「AgentLab 没有登录」那两段(`README.md:43` 起)要改写;多了一套设置和恢复流程。 + +### 2.8 A8 应用生成:先推导,再润色 + +**决定** + +1. **确定性派生** `derive_app(workflow_version)`: + + | 图里的东西 | 派生成 | + |---|---| + | 入口字段 | 入口表单(`input_schema(fields)`) | + | 人工节点 | 待办表单,岗位留空等人绑定 | + | 口径卡 | 指标块 | + | 报告节点 | 报告块 | + | 取数节点 | 表格 | + | 写入的调用工具节点 | 动作确认待办 + 动作状态 | + +2. **助手只润色**:布局、文案、控件选择。走 L1 的可插拔生成过程,接上应用操作和应用检查。岗位绑定只能由人来做,助手只留空并给出提示。 + +3. **「运行后做成应用」**: + - 问数据回答上点「做成应用」→ `/copilot/from-run`(`copilot.py:2496`,新工作流记下来源); + - → 在画布上发布; + - → 发布成功的弹窗里点「做成应用」→ 进入应用编辑。 + - 运行详情页的「提取模板」(`RunDetailView.tsx:403`)也接上同一条路。 + +**理由** +- P6; +- P3:派生不靠模型,助手失败了也有可用的应用。 + +**否决的做法**:让助手从零生成整份 spec。 + +**代价**:派生规则要随节点类型维护。 + +### 2.9 L1–L4:对 look 文档的修订 + +**L1 生成过程可插拔**(修订 look §2.4 D9、§2.5.5) + +`api/copilot_gen.py` 的循环只管四件事:读到 look 就停下、执行查看、接着写、计次数和上限。下面两样由调用方注入: + +| 注入项 | 接口 | 说明 | +|---|---|---| +| Applier | `apply(op) -> Applied{changed, forward}`、`landed()`、`continuing()` | look §2.5.5 的去重规则(`add_edge` 查重、同 id 的 `add_node`、`plan` 只留第一次)放在 `GraphApplier` 里;应用生成用 `AppApplier` | +| Checker | `issues()`、`repair_request(issues, looks_text)` | 图用 `GraphChecker`(现在的 `_blocking_issues` + `_repair_request`),应用用 `AppChecker`(§3.14) | + +模型输出 `{"op":"ask","question":…}` 时,**这一轮就结束**,循环里不等用户回答。已经落地的改动照常交付,附上自查问题;用户的回答就是下一轮。 + +**L2 从第一天起按类型标记**(修订 look §2.5.2、§2.5.7、§2.11) + +- look 行: + + ```json + {"op":"look","need":"…","targets":[{"kind":"table","source":"库 B","name":"refunds"}],"sources":["库 B"]} + ``` + + 旧写法 `tables:[{source, table}]` 也认,读进来时换成 `targets`。 +- 记录:`objects:[{kind:"table", …}, {kind:"capability", …}, {kind:"workflow_version", …}]`。表类型的字段同 look §2.5.7 的 `sources[]`。 +- 这次实现三种对象:表、能力、工作流版本。口径以后再加(账本先做实验,look §3.8)。 +- **否决**:每加一种对象就加一个字段(`tables`、`calibers`、`ops`…)。每加一种都要同时改协议、提示词、解码和存储格式,而 `meta.looks` 存下来之后再改就要做数据迁移。 +- **代价**:只查表的时候,模型每次多写一个 `kind`。 + +**L3 「三样东西」做成接口**(修订 look §2.4 D6) + +每种可查看的对象都实现同一个 `LookKind`: + +| 方法 | 作用 | +|---|---| +| `overview()` | 概览,进 system,每个来源一行 | +| `index()` | 目录,挑选失败时交回,每个对象一行 | +| `resolve(targets)` | 按点名解析,认不出的记为「没找到」 | +| `pick(need)` | 按需求挑选 | +| `detail(objects, budget)` | 详情,受字数上限约束 | + +- 挑选的提示词改成和对象类型无关,几种类型共用; +- 遮罩按类型各自处理: + +| 类型 | 遮罩规矩 | +|---|---| +| 表 | 按 `masked_columns`,同 look D6 | +| 能力 | 遮罩字段不给示例值,凭据一律不给 | +| 工作流版本 | 不含任何密钥 | + +**L4 账本加上「生效」和「口径 → 应用」**(修订 look §3.4.8) + +- 不另建存储,只提供只读查询: + - ActionRecord 本身就带着工作流 id、版本、graph_hash、能力版本、`payload_hash`、回执; + - 「口径 → 应用」= 钉住这个版本的应用版本。 +- 用在三处:发布弹窗、退役确认、版本列表。 +- 召回(look §3.4.5)碰到生效记录时,只能标记并转人工,不能重算。 + +### 2.10 交叉决定 X1–X4 + +- **X1 「人」这一列写什么。** 开了登录,各「人」列(`started_by`、`resolved_by`、`published_by` 等)一律写**用户名**(不可改);关了登录,照旧写自报的署名。认证与否另外记下(`run.origin`、`task_assignments.verified`、审计表),不靠给字符串加前缀来区分。 +- **X2 只有一个表单渲染器。** `components/SchemaForm.tsx` 用在四处: + - 画布的发起表单:服务端把 `input_fields` 换成 `input_schema`,放进 `VersionDetailOut`(`api/workflows.py:228`); + - 人工节点; + - 待办; + - 应用入口。 + + 它替换掉 `RunControl.tsx:82` 的 FieldsForm(目前每个字段都是一个文本框)。输入节点的字段扩展出 `type`、`enum`、`format`、`description`,没写类型的仍按字符串处理。 +- **X3 运行来源。** `Run.origin_kind` 取值: + + | 值 | 含义 | + |---|---| + | `chat` | 问数据 | + | `canvas` | 画布试跑 | + | `launcher` | 正式运行(手动) | + | `schedule` | 定时任务 | + | `app_entry` | 应用入口 | + | `app_view` | 应用视图 | + | `app_backfill` | 按新口径补算 | + | `hook` | 外部系统触发 | + | `api` | 接口 | + + - 由 `launch_run(origin=…)` 写成 `run.origin` 事件,放在 `notes` 里,在封存范围内; + - `app_*` 和 `hook` 只能由服务端写,`/api/runs` 收到客户端声明的这类来源一律拒绝; + - 老运行这一列为空,界面写「来源未记录」。 +- **X4 审批卡和画布解耦。** 从 `RunPanel.tsx:38` 的 ApprovalCard 抽出 `run/TaskCard.tsx`: + - `onResolved` 必填,内部调 `/api/tasks/{id}/respond`; + - 报告核对概况、反方审稿、双盲复算、质询都保留; + - ApprovalCard 只剩一层包装。 + +### 2.11 和不可回退决定的对照 + +| 不可回退的决定 | 冲突点 | 怎么化解 | +|---|---|---| +| 正式运行只从发布的不可变版本发起,按 graph_hash 钉死 | F2:带图接着跑改写正式运行;F1:只认当前版 | 正式运行禁止带图接着跑;放开到「已发布且未退役」,并核对 `expect_hash` | +| 口径升版是必须处置的事件 | F3:草稿被当成升版 | 只看已发布且未退役的版本;应用钉住项升版必须处置;退役也会逼出处置 | +| 叙述层无算术 | 视图里可能冒出算式和拼出来的数字 | 绑定只取已有的值;表达式禁止算术;趋势只把封存的指标排成一列、不算差值(环比放进口径卡);表格只排序、不出合计行;静态文字里有数字时警告,受管级应用报错 | +| 不上 Postgres、不做节点级锁、单进程 + SQLite | 会签并发、超时、钩子 | 全部用条件 UPDATE;调度、核实、钩子都在进程内循环;按需运行在进程内同一个键只跑一份 | +| 没发布的图只能跑探索运行 | 应用草稿预览、问数据 | 应用里的运行永远是钉住版本的正式运行(来源标 `app_*`);应用草稿只预览布局和派生结果,不发起运行;问数据时(意图为回答)查看结果里不列写入能力,运行时也只能走 test 或演练 | + +--- + +## 第三部分 规格 + +### 3.1 数据模型 + +新表按领域分文件:`db/models_identity.py`、`db/models_capabilities.py`、`db/models_apps.py`、`db/models_process.py`,由 `db/models.py` 统一导入。这些文件在第 0 步「契约骨架」里一次写好,之后冻结(§6.4)。 + +#### 3.1.1 新表 + +| 表 | 主要列 | 用途 | +|---|---|---| +| `users` | id、username(唯一、不可改)、display_name、email、role(admin / builder / user)、password_hash、oidc_issuer、oidc_subject(唯一)、disabled、last_login_at、created_by | 本地用户和 OIDC 用户 | +| `groups` | id、name(唯一)、label、source(local / oidc) | 组,用于岗位绑定和应用授权 | +| `group_members` | group_id、user_id、added_by | 组成员 | +| `auth_sessions` | token_hash(唯一)、user_id、expires_at、last_seen_at、revoked_at、ip、user_agent | 会话 | +| `audit_events` | at、actor、verified、action、subject_kind、subject_id、data | 管理类操作的留痕:登录方式、角色、组、授权、应用发布、端点、能力治理、退役 | +| `capabilities` | name(主键)、system、title、description、params_schema、returns_schema、tabular、effect_system(同步得出)、effect(治理后)、approval、unattended_write、fields、masked、credential_mode、endpoint_id、lookup、version、source_hash、synced_at、missing、updated_by | 能力登记(A6) | +| `endpoints` | id、name(唯一)、environments(`{test, prod}`:base_url、headers 加密、allow_intranet、verify_tls)、idempotency_header、timeout_s、version | 登记的端点 | +| `apps` | id、name、description、icon、draft(spec 草稿)、published_version、status(active / archived)、created_by、updated_by | 应用 | +| `app_versions` | (app_id, version) 唯一、spec、spec_hash、pins、bindings(岗位、钩子、渠道)、level、dispositions、note、published_by、published_at | 不可变的应用版本 | +| `app_grants` | app_id、principal_kind(user / group / everyone)、principal、role(use / observe / manage)、granted_by | 应用授权 | +| `app_view_cache` | key(主键)、app_id、view_id、run_id、expires_at | 按需视图的缓存 | +| `app_hooks` | id、app_id、name、kind(start / signal)、entry_id、secret(加密)、enabled | 入站钩子 | +| `hook_receipts` | (hook_id, idem_key) 唯一、run_id、task_id、status、received_at | 钩子去重 | +| `tasks` | id、approval_id(唯一,可空:核实待办没有 Approval)、run_id、node_id、app_id、app_version、kind、title、form、policy、status、due_at、remind_at、on_timeout、escalation_level、correlation_key、outcome、resolved_by、resolved_at | 待办(§3.7) | +| `task_assignments` | id、task_id、principal_kind(user / group / anyone / hook)、principal、source(slot:x / initiator / expression / escalation / reassign)、status、response、responded_by、responded_at、verified | 派给谁、谁回复了什么 | +| `action_records` | id、action_key、attempt、idem_key(唯一)、run_id、node_id、exec_no、app_id、workflow_id、workflow_version、graph_hash、capability、capability_version、endpoint_id、environment、payload_hash、preview(遮罩后)、state、confirm_task_id、request_at、response_status、receipt、error、lookup_attempts、next_lookup_at、resolution | 生效记录,权威(§3.8) | +| `notify_channels` | id、name、scope(global / app / user)、owner_id、format、url(加密)、secret(加密)、events、enabled | 通知渠道 | +| `notify_deliveries` | id、channel_id、subject_kind、subject_id、event、run_id、task_id、status、http_status、attempts、error | 通用投递记录。定时任务的投递仍写 `webhook_deliveries` | + +#### 3.1.2 已有表的新列 + +按 `_COLUMN_MIGRATIONS`(`db/base.py:134`)补列,存量行一律为空: + +| 表 | 新列 | 说明 | +|---|---|---| +| `runs` | `origin_kind VARCHAR(20)`、`origin JSON`、`app_id VARCHAR(32)`、`app_version INTEGER` | 存量为空,界面写「来源未记录」 | +| `workflow_versions` | `published_at DATETIME`、`published_by VARCHAR(100)`、`retired_at DATETIME`、`retired_by VARCHAR(100)`、`retire_note TEXT`、`capability_versions JSON` | 每个版本各自记发布和退役 | +| `workflows` | `origin JSON` | 从运行提取时的来源:run_id、对话、轮次 | +| `mcp_servers` | `headers JSON`(值加密)、`endpoint_id VARCHAR(32)` | MCP http 的认证头和端点绑定 | +| `conversations` | `app_id VARCHAR(32)` | 应用编辑时助手对话归属哪个应用 | + +#### 3.1.3 索引与一次性迁移 + +**索引**:`_INDEX_MIGRATIONS`(`db/base.py:225`)扩成 `(名, 表, (列…))`,支持多列。新库在模型的 `__table_args__` 里写同名的 Index。新增: +- `ix_runs_origin_kind` +- `ix_runs_app_id` +- `ix_runs_wf_ver_status (workflow_id, version, status)`:找钉住版本最近一次封存的运行 +- `ix_tasks_status_due (status, due_at)` +- `ix_assign_principal (principal_kind, principal, status)` + +**启动时的一次性迁移**(都幂等): +1. 把自定义工具 headers、MCP env 里的明文加密; +2. 同步能力登记; +3. 为存量的待审批补建 Task(派给任何人)。 + +### 3.2 应用描述 `agentlab.app/1` + +示例(虚构): + +```json +{ + "format": "agentlab.app/1", + "name": "报销审批", + "description": "员工发起报销,财务会签后付款", + "icon": "receipt", + "pins": [ + {"alias": "proc", "workflow_id": "wf_x", "version": 4, "graph_hash": "…", "use": "process"}, + {"alias": "weekly", "workflow_id": "wf_y", "version": 7, "graph_hash": "…", "use": "view"} + ], + "slots": { + "finance": {"label": "财务审批"}, + "director": {"label": "总监"}, + "reconciler": {"label": "动作核实"} + }, + "entries": [{ + "id": "apply", "label": "发起报销", "pin": "proc", + "form": {"$ref": "#/forms/apply"}, + "map": {"amount": "form.amount", "applicant": "user.display_name"}, + "instance_title": {"pattern": "{applicant} 的报销", "fields": ["applicant"]} + }], + "forms": { + "apply": { + "format": "agentlab.form/1", "type": "object", "required": ["amount"], + "properties": {"amount": {"type": "number", "title": "金额(元)", "minimum": 0}} + } + }, + "task_ui": {"approve": {"title": "财务审批", "x-order": ["note"]}}, + "views": { + "wk": {"source": {"kind": "latest_sealed", "pin": "weekly", "from": "schedule:sch_1"}}, + "inst": {"source": {"kind": "instance"}} + }, + "pages": [{ + "id": "home", "title": "本周概览", + "body": [ + {"id": "stamp", "type": "stamp", "bind": {"view": "wk"}}, + {"id": "kpis", "type": "columns", "children": [ + {"id": "gmv", "type": "metric", "props": {"label": "销售额"}, + "bind": {"view": "wk", "metric": "kpi.gmv"}} + ]}, + {"id": "warn", "type": "notice", "props": {"text": "本期为降级出具"}, + "visible": "run.tier != 'formal'", "scope": "wk"}, + {"id": "new", "type": "entry_button", "props": {"entry": "apply"}}, + {"id": "mine", "type": "instance_list", "props": {"entry": "apply", "who": "mine"}} + ] + }], + "hooks": [{"name": "paid", "kind": "signal"}], + "notify": [{"channel": "ch_1", "events": ["task.opened", "action.unknown"]}] +} +``` + +| 顶层字段 | 说明 | +|---|---| +| `pins` | 钉住的工作流版本。`use: process` 的版本,要按流程规则检查(§3.14) | +| `slots` | 岗位,名字和图里人工节点的 `assignee.slot` 对得上。岗位绑定到人或组的那一份放在 `app_versions.bindings`,不进 spec:spec 可以导出,绑定是这一个部署自己的 | +| `entries` | 入口:表单 + 字段映射(`form.x`、`user.display_name`、常量)。实例标题只能按名字引用入口字段,服务端拼 | +| `forms` | `agentlab.form/1` 表单 | +| `task_ui` | 按人工节点覆盖待办的外观(标题、顺序、控件),不能改数据约束 | +| `views` | 视图来源(§2.3) | +| `pages` | 页面树:组件 + props + 绑定 + 可见性 | +| `hooks` | 入站钩子声明;密钥放在 `app_hooks` | +| `notify` | 通知订阅 | + +### 3.3 表单 `agentlab.form/1` + +JSON Schema 的一个子集。前后端按同一份子集校验,后端用 jsonschema 复核、以后端为准。 + +| 允许的 | 说明 | +|---|---| +| 顶层 `type: object`,`properties`、`required` | 只有一层;数组只能装枚举字符串(多选) | +| 字段 `type`:string / number / integer / boolean / array | — | +| `title`、`description`、`default`、`readOnly` | — | +| `enum` + `x-enum-labels` | 下拉、单选、多选 | +| `minimum`、`maximum`、`minLength`、`maxLength`、`maxItems` | — | +| `format`:date / date-time / email / uri | **不支持 `pattern`**:JS 和 Python 的正则语义不同,会出现前端放行、后端拒收(P5) | +| `x-widget` | text、textarea、number、integer、date、datetime、select、radio、multiselect、checkbox、principal(选人或组)、note(只读说明)、json(只给 builder) | +| `x-order` | 字段顺序 | + +- 错误格式统一为 `{path, code, message}`,code 取 `required`、`type`、`enum`、`minimum`、`maximum`、`min_length`、`max_length`、`max_items`、`format`; +- 入口提交校验失败回 422,带 `{errors:[…]}`; +- 输入节点的字段扩展出 `type`、`enum`、`format`、`description`,由 `input_schema(fields)` 转成这个格式;没写类型的按 string 处理,老图照常运行。 + +### 3.4 绑定类型 + +| 类型 | 写法 | 服务端怎么解析 | 证据 | +|---|---|---|---| +| 指标 | `{view, metric:"节点.指标"}` | 读口径卡(metric_set)工件,按 `render_metric` 渲染 | `{run_id, kind:metric, node, metric, eid}`,点开是指标链 | +| 趋势 | `{view, series:"节点.指标", last:12}` | 取同一来源最近 N 次封存运行的指标值。跨钉住版本时按处置来画:dual 画两条线;incomparable 在升版处断开并标注;recompute 只取新版本,可以按历次触发的输入补算(来源记 `app_backfill`) | 每个点各自有证据 | +| 单元格 | `{view, cell:{node, row, col}}` | 查询快照(含端点快照),按 `render_cell` 渲染,遮罩 | 单元格的 eid | +| 表格 | `{view, query:{node, exec}}` | 快照的列和行,遮罩后给出;只能排序 | 快照 | +| 报告 | `{view, report:"节点"}` | 报告的 doc_artifact | 逐段证据(`EvidenceDoc`) | +| 输入 | `{view, input:"字段"}` | 运行输入 | `i:` | +| 运行信息 | `{view, run:"status"\|"tier"\|"finished_at"}` | 运行记录 | — | +| 成果字段 | `{view, output:"字段"}` | **只允许**两种:逐字取自某份报告(`_evidence`)的,或者不含数字的文本。其余被 `app.binding_not_evidenced` 拦下 | 同报告 | +| 待办 / 动作 | `{task:"节点"}` / `{action:"节点"}` | 本实例的待办、ActionRecord | 动作的回执和哈希 | + +### 3.5 组件目录 v1 + +| 类别 | 组件 | +|---|---| +| 布局 | `page`、`section`、`columns`、`tabs`、`divider` | +| 展示 | `stamp`(印章)、`metric`(指标块)、`series`(趋势:折线或柱状,自绘 SVG)、`table`、`chart`(按表格的列画柱状或折线,不做聚合)、`report`、`text`(静态 Markdown;里面有数字时警告)、`notice`、`field`、`status` | +| 输入 | `form`(`agentlab.form/1`) | +| 流程 | `entry_button`、`instance_list`(我发起的 / 全部,按权限)、`instance_header`(状态、印章、发起人)、`instance_timeline`(服务端摘要,不下发原始事件)、`task_list`、`task_form`、`signoffs`(会签进度)、`action_status`(状态、环境、哈希前 8 位、回执) | + +每个组件在目录里登记:`props` 的 JSON Schema、允许的绑定类型、服务端校验器、解析器、前端渲染器的名字。开发者扩展时四样都要给,缺一样启动就报错。 + +### 3.6 页面解析与视图模型 `agentlab.view/1` + +请求:`GET /api/apps/{id}/pages/{page}?instance=¶ms=` + +返回: + +```json +{ + "format": "agentlab.view/1", + "app": {"id": "…", "name": "…", "version": 3, "level": "governed", "needs_upgrade": false}, + "page": {"id": "home", "title": "本周概览"}, + "sources": { + "wk": {"state": "ok", "run_id": "…", "stamp": { + "as_of": "…", "as_of_text": "截至 …", + "pin": "周销售 v7", "graph_hash": "3fa2c1d0", + "calibers": [{"name": "周销售口径", "version": "v3"}], + "tier": "formal", "tier_text": "正式出具", + "from": "定时任务「周一 10 点」第 14 次", "sealed": true}} + }, + "tree": [ + {"id": "gmv", "type": "metric", "props": {"label": "销售额"}, + "data": {"text": "45.68 万元", "state": "ok", + "evidence": {"run_id": "…", "kind": "metric", "node": "kpi", "metric": "gmv", "eid": "ev:metric:…"}}} + ], + "forms": {}, + "problems": [{"component": "trend", "code": "view.source_empty", "message": "还没有可读的封存运行"}] +} +``` + +- 不可见的组件不出现在 `tree` 里;遮罩值不出服务端。 +- 解析失败的项照实放进 `problems`,不吞掉(P1)。 +- 在进程内做 LRU 缓存,键是 (应用版本, 页面, 来源运行的 id 列表, 角色)。 +- 点开指标的证据链,要新增 `GET /api/runs/{run_id}/evidence/metrics/{node}/{metric}`,复用 `api/evidence.py` 里拼指标链的那部分。单元格和报告沿用现有的证据接口。 + +### 3.7 人工节点的新配置与待办状态机 + +#### 人工节点新配置(写在图里) + +| 字段 | 取值 | 说明 | +|---|---|---| +| `assignee` | `{kind:"slot", slot:"finance"}` / `{kind:"initiator"}` / `{kind:"expression", expr:"input.manager"}` / `{kind:"hook", correlation:"…"}` | 表达式只能取输入和变量,结果必须是用户名或组名 | +| `policy` | `any`(默认)/ `all` / `{quorum: m}` | `all` 和 `quorum` 只用于审批模式 | +| `due` | ISO 8601 时长,如 `PT4H`、`P2D` | 从待办打开时算起 | +| `on_timeout` | `{action:"remind", every:"PT1H"}` / `{action:"escalate", to_slot:"director"}` / `{action:"reject"}` | 没有「自动批准」这个选项 | +| `form` / `form_from` | 静态表单,或 `"nodes.agent1.form"` | `form_from` 不合目录时退回 `form`,并记一条警告 | +| `correlation` | 模板,如 `"{{ input.order_id }}"` | 只用于 `kind: hook`;入站 signal 按它找到待办 | + +没有应用绑定的运行(画布、定时任务):岗位派给「任何人」,行为和今天一样。 + +#### 待办的种类 + +`approve`、`input`、`edit`(人工节点);`tool`、`code`、`dual_check`(现有的几种中断);`action_confirm`(动作确认);`reconcile`(核实结果未知的动作);`signal`(等外部系统)。 + +种类看中断内容里的 `kind` 来定,不看 `mode`:工具审批的中断里没有 `mode`,所以 `Approval.mode` 一律是 approve。 + +#### 状态机 + +``` +Task: pending ──respond(凑齐)──► resolving ──resume 成功──► resolved + │ └──resume 失败──► pending(再试,写日志) + ├──到期,驳回──► resolving ──► resolved(outcome=expired_reject) + ├──运行被取消──► cancelled + └──到期,升级──► pending(加派一条,escalation_level+1) + +Assignment: pending ──respond──► responded + └──改派 / 升级 / 汇总已判定──► withdrawn +``` + +**汇总规则**: +- `any`:第一条回复就判定; +- `all`:全体同意才通过,任何一人驳回立即否决; +- `quorum(m)`:同意数达到 m 即通过;驳回数超过 n − m 即否决。 + +**判定后**:在同一个事务里,用条件 UPDATE 把 Task 从 pending 改成 resolving,抢到的那个请求负责调 `run_manager.resume`,再把会签明细写进 `human.resolved` 事件。 + +### 3.8 动作:调用工具节点的写入流程 + +``` +调用工具节点(能力 effect=write,allow_write=True) + 1. 渲染参数(真值)→ payload_hash + 2. 查 ActionRecord(action_key) + confirmed → 交回存下的回执,结束(不发) + sent / unknown → 抛 ActionPending(不发) + failed 且已确认重发 → attempt+1,继续 + 没有记录 → 继续 + 3. 需要确认时:中断,kind=action_confirm + (在 tool.start 之前,节点边界上,升级安全) + 确认卡 = 遮罩后的预览 + payload_hash + 环境 + 恢复后:重算 payload_hash,与确认时的不一致 → 拒绝发送(节点失败,写清原因) + 4. 写 ActionRecord(state=pending) 并提交 + 5. 环境:正式运行 → prod;探索运行 → test;没有 test → simulated(不发) + 6. 发送(带幂等头 idem_key)→ state=sent 并提交 + 7. 2xx → confirmed(落回执工件) + 4xx(429 除外)→ failed + 超时、连接中断、5xx → unknown → 抛 ActionPending +``` + +- `ActionPending` 让运行收成 interrupted(挂起,不封存),发 `action.unknown`。 +- 登记了 `lookup` 的:`actions.tick` 带退避去查(1、2、4…分钟,最多 12 次);查到就核实并自动接着跑,查满了还没结果就转核实待办。 +- 没登记 `lookup` 的:直接开核实待办,派给 reconciler 岗位;核实完由系统自动接着跑。 +- **结果未知永远不自动重发**;重发只能来自核实待办里的「未生效,重新发送」,幂等键带上 `:attempt`。 + +### 3.9 能力登记与端点 + +| 字段 | 说明 | +|---|---| +| `name` | 图里工具的名字,沿用现有规则:内置名 / `db_query__<源>` / `db_schema__<源>` / 自定义工具名 / `mcp:<服务>/<工具>` | +| `system` | 所属系统:数据源名、端点名、MCP 服务名、`builtin` | +| `effect_system` / `effect` | 前者是同步时得出的(数据源可写时为 dynamic,内置工具按 `ToolSpec.dangerous`),后者是治理后的值;两者不一致时界面上标出来 | +| `approval` | inherit(跟三档信任)/ confirm(总要确认)/ none(只读能力在正式运行里免审批) | +| `returns_schema`、`tabular` | `tabular = {rows:"/data/items", columns:{"单号":"/id", "金额":"/amount"}}`,声明了才能作证 | +| `fields` | `{"/amount": {"label": {...项}, "unit": {...项}}}`,形状同数据目录的「项」 | +| `masked` | JSON 指针列表;查看、快照、视图都按它遮 | +| `credential_mode` | service / service_on_behalf | +| `lookup` | 用来按幂等键查结果的能力名 | +| `version` | 治理字段每改一次加一;运行固定的就是它 | + +**端点**: +- `{name, environments:{test?, prod}, idempotency_header, timeout_s}`,每个环境有 `{base_url, headers(加密), allow_intranet, verify_tls}`; +- 绑定端点的 HTTP 能力,config 改成 `{endpoint, method, path, query, body}`,`path` 里的参数逐段 URL 编码代入; +- 没绑定端点的老 HTTP 工具照旧,地址整串过 SSRF 检查。 + +### 3.10 接口 + +| 前缀 | 接口 | 说明 | +|---|---|---| +| `/api/auth` | `GET /me`;`POST /login`、`/logout`;`GET /oidc/start`、`/oidc/callback`;`GET,PUT /settings`;`POST /bootstrap`;`GET /jwks` | 身份 | +| `/api/users`、`/api/groups`、`/api/audit` | 增删改查、成员;审计只读 | 只给管理员 | +| `/api/apps` | 列表、新建、详情;`PATCH`(改草稿);归档;`POST /derive`;`POST /{id}/lint`;`POST /{id}/publish`;`GET /{id}/versions[/{v}]`;`GET,PUT /{id}/grants`;`GET /{id}/pages/{page}`;`POST /{id}/entries/{entry}`;`GET /{id}/instances[/{run_id}]`;`POST /{id}/views/{view}/refresh`;`GET /catalog` | 应用 | +| `/api/tasks` | `GET ?assigned=me\|all&status=&app_id=`;`GET /count?mine=1`;`GET /{id}`;`POST /{id}/respond {decision, values?, note?, payload_hash?}`;`POST /{id}/reassign`;`POST /{id}/remind` | 待办 | +| `/api/actions` | `GET ?run_id&app_id&state`;`GET /{id}` | 生效记录(核实走待办) | +| `/api/capabilities`、`/api/endpoints` | 列表、详情;`PUT /{name}/governance`;端点增删改查;`POST /{id}/test?env=` | 能力治理 | +| `/api/hooks/{hook_id}` | `POST`:只认签名,不走会话 | 入站钩子;管理放在 `/api/apps/{id}/hooks` | +| `/api/notify` | 渠道增删改查、测试;投递记录 | 通知 | +| `/api/ledger` | `GET /workflow-versions/{wf}/{v}`(钉住它的应用、生效计数、正式运行数);`GET /effects?…` | 账本(只读) | +| `/api/copilot` | 新增 `POST /app-stream`(应用润色,SSE);`/generate-stream`、`/generate`、发布前修复、升级证据都改走 `copilot_gen` | 助手 | +| `/api/access-keys`(**已修订,2026-10 next-round 04 A1**) | 服务账号与个人密钥的增删、轮换、撤销;请求记录;接口说明 | 访问密钥;`Authorization: Bearer agl_…`,按路由白名单放行 | +| `/api/apps/{id}/shares`、`/api/shares/*`(**已修订,2026-10 next-round 04 C2**) | 建、列、撤销;公开的 `GET /api/shares/page`、`/report.html`(只认请求头 `X-AgentLab-Share`) | 分享链接 | +| `/api/apps/{id}/views/{view}/data`(**已修订,2026-10 next-round 04 A5**) | 印章、清单哈希、口径卡指标的原值(遮罩的为 null) | 视图数据,给访问密钥用 | + +**改动的已有接口**: +- `POST /api/runs`:接收 `origin`,客户端只能填 chat、canvas、launcher、api; +- `POST /api/runs/{id}/continue`:正式运行带 `graph` 回 409; +- `/api/approvals/{id}/decide`:改成垫片; +- 新增 `POST /api/workflows/{id}/versions/{v}/retire`; +- `VersionDetailOut` 加 `input_schema`、`retired_at`、`usage`; +- `DELETE /api/workflows/{id}` 加删除约束。 + +### 3.11 事件 + +新增到 `core/events.py:11` 的 EventType: + +| 类别 | 事件 | +|---|---| +| 来源 | `run.origin`:发起时写进 notes,在封存范围内。数据包括 kind、app_id、app_version、spec_hash、entry 或 view、hook、actor、actor_verified、auth_mode | +| 待办 | `task.opened`、`task.responded`(谁、怎么决定的、第几人 / 共几人)、`task.reassigned`、`task.reminded`、`task.escalated`、`task.expired`、`task.resolved`、`task.cancelled` | +| 动作 | `action.prepared`(payload_hash、环境、遮罩后的预览)、`action.sent`、`action.confirmed`(回执)、`action.failed`、`action.unknown`、`action.reconciled`(谁、怎么核实的)、`action.simulated` | +| 助手流(SSE,不落运行事件) | `look`(L2 的形状)、`ask`、应用操作(`app_add`、`app_update`、`app_remove`、`app_move`)、应用的 `final` | + +### 3.12 权限矩阵 + +关闭登录时下表全部放行,签批人是自报的署名,界面上挂横幅说明。 + +| 能做的事 | admin | builder | user | +|---|---|---|---| +| 问数据、助手 | ✓ | ✓ | 默认不能(Q2) | +| 编排、探索运行、发布(已发布级) | ✓ | ✓ | ✗ | +| 发布受管级、退役版本 | ✓ | Q8 | ✗ | +| 记录页(全部运行、审批)、运行的 WebSocket | ✓ | ✓ | ✗ | +| 数据源、数据目录、定时任务、知识库 | ✓ | ✓ | ✗ | +| 自定义工具(python;http 绑定端点) | ✓ | ✓ | ✗ | +| MCP stdio、端点和凭据、能力治理、模型接入 | ✓ | 只读 | ✗ | +| 应用:搭建、发布、绑定岗位、授权、钩子 | ✓ | 创建者或有 manage 授权 | ✗ | +| 应用:使用(入口、视图、自己的实例) | ✓ | ✓ | 有 use 授权 | +| 应用:看全部实例 | ✓ | ✓ | 有 observe 授权 | +| 运行、证据、工件(只读) | ✓ | ✓ | 只限有授权的应用视图运行,以及自己参与的实例(遮罩后) | +| 处理派给自己的待办、核实动作 | ✓ | ✓ | ✓ | +| 改派、催办 | ✓ | 有 manage 授权 | ✗ | +| 用户、组、登录方式、审计 | ✓ | ✗ | ✗ | +| 个人的通知渠道和偏好 | ✓ | ✓ | ✓ | +| 入站钩子 | 只认签名 | 只认签名 | 只认签名 | +| 访问密钥(**已修订,2026-10 next-round 04 A1**):服务账号、个人访问密钥 | 建服务账号、开关个人密钥、撤销任何人的 | 个人密钥(管理员开了才有) | 个人密钥(管理员开了才有);一律按使用者的权限、遮罩、接口白名单 | +| 分享链接(**已修订,2026-10 next-round 04 C2**):只读、不用登录的视图页 | 开关(缺省关)、建、撤销 | 有应用 manage 授权的建、撤销 | ✗ | + +### 3.13 发布检查 + +**挂接方式**: +- 契约骨架在 `lint_for_publish`(`governance.py:33`)末尾加一个钩子列表 `EXTRA_LINTS`; +- 流程包、治理底座包各写自己的检查模块(`engine/lint_process.py`、`engine/lint_capabilities.py`),**不改 governance.py 的主循环**; +- 问题经现有的 `flag(message, code, node_id, hard, field)` 记录,和其他检查同一种格式; +- 应用检查放在 `app/apps/lint.py`。 + +#### 工作流(新增) + +| 编号 | 条件 | 级别 | +|---|---|---| +| `action.write_in_agent` | agent 或协作节点的工具清单里有写入能力,或视参数而定(dynamic)的写入能力;运行时也会拒 | 各级都报错 | +| `action.unattended_write` | 写入节点设成不需确认,而这个能力不允许无人值守写入 | 已发布级警告,受管级报错 | +| `action.prod_missing` | 写入能力绑定的端点没有 prod 环境 | 报错 | +| `action.reconcile_unplanned` | 写入能力没有登记查询能力 | 警告(应用那边由 `app.reconciler_unbound` 拦) | +| `human.form_invalid` | 人工节点的 `form` 不合目录 | 报错 | +| `human.due_invalid` | `due` 不是 ISO 8601 时长 | 报错 | +| `human.policy_mode` | `all` 或 `quorum` 用在补填、改稿模式 | 报错 | +| `human.escalate_slot` | `on_timeout.escalate` 没写升级给哪个岗位 | 报错 | +| `process.wait_mid_agent` | agent 的工具在正式运行快照下可能要审批 | 发布工作流时警告;被应用作为流程钉住时报错 | +| `process.human_in_subgraph` | 子工作流里有人工节点 | 同上 | +| `process.code_network` | 代码节点开了联网 | 警告 | + +#### 应用(全部新增) + +| 编号 | 条件 | 级别 | +|---|---|---| +| `app.format`、`app.component_unknown`、`app.props_invalid` | 格式、组件、props 不对 | 报错 | +| `app.pin_unpublished`、`app.pin_retired`、`app.pin_hash` | 钉住的版本没发布、已退役、哈希对不上 | 报错 | +| `app.upgrade_policy` | 钉住项相对上一个应用版本升了版,却没选处置 | 报错 | +| `app.binding_target_missing` | 绑定的节点、指标、报告在钉住的版本里不存在 | 报错 | +| `app.binding_not_evidenced` | 数字类组件绑定了没有证据的值 | 报错 | +| `app.expr_invalid`、`app.expr_arith`、`app.expr_root` | 表达式不合法、用了算术、用了不认识的根名字 | 报错 | +| `app.text_number` | 静态文字里有数字 | 已发布级警告,受管级报错 | +| `app.entry_form_mismatch`、`app.entry_unmapped_required` | 入口表单和钉住版本的入口字段对不上;必填字段没映射 | 报错 | +| `app.slot_unbound` | 流程应用的岗位没绑定 | 报错 | +| `app.reconciler_unbound` | 有写入动作,既没有查询能力,也没绑定核实岗位 | 报错 | +| `app.process_unsafe` | 流程钉住项有 `process.*` 问题 | 报错 | +| `app.hook_entry_missing` | 钩子指向的入口不存在 | 报错 | +| `app.view_source_empty` | 视图暂时没有可读的运行 | 警告 | +| `app.on_demand_ttl` | 按需视图查活库却没设 TTL(派生时会给默认值) | 报错 | +| `app.acl_empty` | 开了登录,却没有任何 use 授权 | 警告 | +| `app.notify_channel_missing` | 引用的通知渠道不存在 | 警告 | + +--- + +## 第四部分 界面 + +### 4.1 导航 + +- **只加一项「应用」**(⌥9)。放在「编排」后面,其他项的快捷键都不变。导航定义在 `components/CommandPalette.tsx:53`,九项要通过 check-shell 在 480px 高度下的测量(`check-shell.mjs:187`);放不下,就在 `max-height: 520px` 时进一步收紧。 +- **「应用」页内三个页签**:待办、应用、我发起的。 +- **徽标按来源分开、不重复计数**: + - 「应用」上数应用里的待办; + - 「记录」上数非应用运行的审批(现有的 ApprovalBadge,`App.tsx:322`)。 +- **开了登录时,user 角色**的导航只有「应用」和「设置(个人)」。 + +### 4.2 路由与页面 + +| 路由 | 页面 | 面 | +|---|---|---| +| `/apps` | 应用首页:待办 / 应用 / 我发起的 | 使用面 | +| `/apps/inbox/:taskId` | 待办详情(`TaskCard`) | 使用面 | +| `/apps/:appId`、`/apps/:appId/p/:pageId` | 应用页 | 使用面 | +| `/apps/:appId/new/:entryId` | 发起(入口表单) | 使用面 | +| `/apps/:appId/i/:runId` | 实例页:状态、印章、时间线、待办、动作 | 使用面 | +| `/login` | 登录 | 使用面 | +| `/apps/:appId/edit`、`/apps/:appId/edit/:section` | 应用编辑:钉住项、派生、页面树、属性面板、预览、检查、发布(选处置)、岗位和授权、钩子、助手面板 | 搭建面 | +| `/tools/capabilities`、`/tools/endpoints` | 能力登记、端点 | 搭建面 | +| `/settings/identity`、`/settings/notify`、`/settings/audit` | 身份、通知渠道、审计 | 搭建面 | + +路由照现有约定,带 id 和不带 id 各写一条显式路由(`App.tsx:102` 起)。 + +### 4.3 共享组件 + +| 组件 | 用在哪里 | +|---|---| +| `components/SchemaForm.tsx` + `lib/schemaForm.ts`(子集校验) | 发起表单、待办、应用入口、询问 | +| `run/TaskCard.tsx`(从 ApprovalCard 抽出) | 待办箱、实例页、记录页、画布、问数据 | +| `apps/ViewRenderer.tsx`、`apps/components/*` | 应用页面,按目录名字渲染 | +| `apps/Stamp.tsx` | 印章 | +| `apps/Series.tsx`、`apps/Chart.tsx`(自绘 SVG) | 趋势、图表。动手前先按 dataviz 的规矩定配色和标记 | +| `components/PrincipalPicker.tsx` | 岗位绑定、授权、改派 | + +证据面板直接复用 `EvidencePanel`、`EvidenceDoc`、`ResultTable`。 + +### 4.4 文案与事件翻译 + +`decode.ts` 仍然是事件到文字唯一的翻译层,新增: +- `run.origin`:来源标签; +- `task.*`、`action.*`; +- 助手流里的 `look`、`ask`; +- 旧的 `context` 解码保留,新前端连老后端时用。 + +`terms.ts` 新增: +- `ORIGIN_LABEL`:问数据、画布试跑、正式运行(手动)、定时任务、应用入口、应用视图、按新口径补算、外部系统触发、接口; +- `TASK_TEXT`; +- `ACTION_STATE_LABEL`:待发送、已发送、已生效、失败、结果未知、演练(未发送); +- `CAPABILITY_TEXT`:只读、写入、视参数而定; +- `STAMP_TEXT`:截至、口径、出具档位、来源; +- `AUTH_TEXT`、`LOOK_TEXT`。 + +按 look §2.6 删掉三条旧文案:「参考了 N 张表的字段」(`terms.ts:3420`)、「另有 N 张表只提供了表名」、「未能按需求挑选,已提供全部表名」(`decode.ts:3052`)。 + +使用面的文案写在 `lib/termsApp.ts`,契约骨架在 `terms.ts` 里加一行 `export *`,两个前端包不抢同一个文件。 + +`check-copy` 的键名白名单加上新配置键:`on_timeout`、`form_from`、`assignee`、`unattended_write`。 + +### 4.5 检查脚本 + +| 脚本 | 新增 / 更新 | 内容 | +|---|---|---| +| `check-apps.mjs` | 新增 | 应用页、印章、点数字看证据、入口校验、实例页 | +| `check-inbox.mjs` | 新增 | 会签进度、超时横幅、动作确认的哈希卡、核实待办 | +| `check-auth.mjs` | 新增 | 登录、按角色收起的导航、401 跳登录、开登录时不出署名框 | +| `check-app-editor.mjs` | 新增 | 钉住项、派生、检查、发布时选处置、助手流 | +| `check-capabilities.mjs` | 新增 | 能力登记、端点、密钥掩码 | +| `check-shell` | 更新 | 九项导航 | +| `check-decode`、`check-chat`、`check-stream` | 更新 | 桩数据里的 `context` 换成 `look` | +| `check-runs`、`check-studio`、`check-publish` | 更新 | 来源、人工节点新字段、退役和使用情况 | +| `check-all` | 更新 | `ORDER`、`ESTIMATE` 加上新脚本 | + +--- + +## 第五部分 助手按需查看(实施版) + +### 5.1 采纳的决定 + +按 look §2.3–§2.10 实施,look §4 的近期四项全按推荐: + +| 项 | 采纳 | +|---|---| +| D1 | 用逐行输出里的 `look` 操作,不用原生工具调用 | +| D6 | 遮罩规矩一并用到运行时的 `db_schema__` 和 `/schema` 接口;运行时 agent 看到的目录因此少了遮罩列那部分,README 写明 | +| 前提 | feat/catalog-loop 已合进 main(6dbe4ad),已满足 | +| 上限 | `LOOK_LIMIT = 3`、`REPAIR_LOOK_LIMIT = 1`、`LOOK_RESULT_CHARS = 12000`;沿用 `PICK_LIMIT = 12`、`CONTEXT_TABLE_LIMIT = 16`、`PICK_TIMEOUT_S = 20` | + +在此之上叠加 L1–L4(§2.9)。 + +### 5.2 文件改动 + +| 文件 | 改动 | +|---|---| +| `data/schema_view.py`(新) | 库概览、表目录、表详情(含遮罩),供 `db_schema__`、`/schema` 和 look 共用。从 `copilot_context`、`introspect.describe_table`(`introspect.py:425`)、`catalog.render_table_notes` 里抽出来 | +| `app/look/base.py`(新) | `LookKind` 协议、`LookTarget`、`LookRecord`、各项上限常量 | +| `app/look/tables.py`、`capabilities.py`、`versions.py`(新) | 三种对象:表、能力、工作流版本 | +| `app/look/service.py`(新) | 解析 targets;按类型挑选,挑不出就交回目录;字数上限;重复查看短路;产出记录。系统代为查看(look D5)也走这里 | +| `api/copilot_gen.py`(新) | L1 的循环,以及 `GraphApplier`、`GraphChecker` | +| `api/copilot_context.py` | 改写成「概览 + 调用查看服务」;删掉 `_modes`(`:223`)、按 all / fallback 组装的部分和 `listed` | +| `api/copilot.py` | 四个入口加 `/app-stream`,都改走生成过程;`system_for` 改成给概览;`_tool_catalog`(`:75`)不再列表名;删掉 `_pick_need`;`_history_section`(`:459`)读 `meta.looks`;`_STREAM_PROTOCOL`(`:838`)加 `look` 和 `ask`;节点手册补上人工节点和写入动作的新字段 | +| `tools/datasource.py`、`api/datasources.py` | `db_schema__`(`datasource.py:681`)和 `/schema?table=` 改用共用的表详情 | +| `providers/mock_model.py` | 演示模型按脚本走一次 `look` | + +### 5.3 每个入口给哪些对象类型 + +| 入口 | 可查看的对象 | 说明 | +|---|---|---| +| 画布(`/generate-stream`) | 表、能力 | — | +| 问数据(意图为回答) | 表、能力 | **只列只读能力**,写入能力不出现在概览和查看结果里 | +| 发布前修复、升级证据 | 表 | 系统按要修的 SQL 点名查看(look D5) | +| 应用润色(`/app-stream`) | 工作流版本、能力 | 系统先代为查看钉住的版本,模型可以再查看 | + +### 5.4 测试 + +- look §2.7 列的全部测试:重写 `test_copilot_table_pick.py`;更新目录影响、表结构截断、SQL 别名几项;新增 look 行解析、查看服务、生成过程、四个入口、历史、前端解码与文案。 +- 另加: + - 三种对象类型对同一接口的一致性测试; + - `ask` 让这一轮结束; + - `AppApplier` 的去重; + - 旧写法 `tables` 能被正确转换。 + +### 5.5 look 文档引用位置的更新 + +look 文档写的是当时的位置,下面这些在 main@fc002bd 上变了(其余 `copilot.py`、`copilot_context.py` 的位置没变): + +| look 文档写的 | main 上 | +|---|---| +| `catalog.py:1152` `resolve_table_name` | `catalog.py:1234` | +| `catalog.py:2010` `render_table_index` | `catalog.py:2239`(`render_table_notes` 在 `:2225`) | +| `engine.py:370` `masked_columns` | `data/engine.py:376` | +| `datasource.py:647` `_make_schema_tool` | `tools/datasource.py:681` | +| `decode.ts:3041` `CONTEXT_FALLBACK_TEXT` | `decode.ts:3052` | +| `decode.ts:3086` `case 'model'` | `decode.ts:3097`(0 张表的说法在 `:3102`) | +| `terms.ts:2863`「参考了 N 张表的字段」 | `terms.ts:3420`(`namesOnly` 在 `:3421`) | +| `client.ts:638` 聚类接口 | `client.ts:714` | +| `ChatPage.tsx:298`「在画布里打开」 | `ChatPage.tsx:307`(`api.workflows.create`) | + +--- + +## 第六部分 实施组织 + +本部分在本文签字后另行审批,再执行。 + +### 6.1 流程 + +1. **开分支。** 从最新 main 开集成分支 `feat/app-platform`,各包用自己的 worktree 和分支。本文仍放在 `docs/design/` 下,不跟踪;要提交时先按附录 D 做匿名核对。 +2. **第 0 步:契约骨架。** 由编排者亲自做,一次提交: + - **后端**: + - §3.1 的全部模型和迁移(含多列索引); + - EventType 新增项; + - `engine/origin.py`; + - `api/deps.py`(搬入 `actor_of`,`Principal`、`require_role` 先做成关闭登录时的实现); + - `app/apps/spec.py`(`agentlab.app/1`、`agentlab.form/1` 的 pydantic 模型); + - 组件目录的名字和 props schema; + - 新路由的桩(一律回 501,在 `main.py` 登记); + - `governance.EXTRA_LINTS`; + - 附录 C 里每个跨包函数的签名,桩里抛 NotImplementedError。 + - **前端**: + - `typesApp.ts`; + - `api/apps.ts`、`api/tasks.ts`、`api/auth.ts`、`api/capabilities.ts`; + - `SchemaForm`、`TaskCard` 的 props 接口和最简实现; + - `App.tsx` 里的全部新路由(先挂占位页); + - `terms.ts` 里 `export * from './termsApp'` 那一行。 +3. **构建。** 一个 workflow,6 个包,每包一个完整的代理,并行。每个包交付:代码、测试、自己的检查脚本、README 片段、「契约偏差」清单。 +4. **统一复核。** 一个复核代理读全部差异,对照这些项检查: + - P1–P8; + - 五条不可回退的决定; + - 附录 C 的契约; + - 文件归属; + - 文案规范; + - 遮罩与权限。 + + 出各包的修正单,由各包自己改。 +5. **合并与集成修正。** 编排者按 BE-1 → BE-3 → BE-2 → BE-4 → FE-5 → FE-6 的顺序合并,把桩换成真实现,接通 `TaskCard`、`SchemaForm`,按各包的片段汇总 README。 +6. **完整关口。** 跑一次(§6.3)。 +7. **验收(第七部分),问用户之后再合 main。** + +### 6.2 六个包 + +| 包 | 范围 | 独占的文件(契约骨架之后) | +|---|---|---| +| **BE-1 治理底座:身份 + 能力** | 三种登录模式、会话、CSRF、OIDC、角色、组、授权判定、审计、user 角色的遮罩;能力登记与同步、端点、密钥加密与掩码、SSRF、MCP 的认证、`service_on_behalf`、端点快照作证、`build_tools(allow_write)` | `api/deps.py`、`api/auth.py`、`api/identity.py`、`api/capabilities.py`、`api/tools.py`、`app/auth/*`、`tools/registry.py`、`tools/custom.py`、`tools/mcp_manager.py`、`tools/net.py`、`tools/trust.py`、`engine/toolcalls.py`、`engine/nodes/llm.py`(只改作证那一处)、`engine/compare.py`(兼容端点快照)、`engine/lint_capabilities.py`、`core/config.py`、`main.py` 里中间件那几行 | +| **BE-2 流程:待办、动作、钩子、通知** | 待办服务(打开、回复、会签汇总、tick、改派)、人工节点扩充、动作服务和调用工具节点的写入路径、`ActionPending` 挂起、核实、`_stale_replay` 的豁免、修 F2、`app/notify/*`、入站钩子、`_state_of` 认得挂起、流程检查;种子模板「报销审批(会签 + 写入)」 | `engine/runner.py`、`engine/replay.py`(只改注释)、`engine/nodes/human.py`、`engine/nodes/tools.py`、`engine/schema.py`(人工节点和调用工具节点的配置校验)、`engine/lint_process.py`、`app/process/*`、`app/notify/*`、`scheduler/*`、`api/runs.py`、`api/tasks.py`、`api/actions.py`、`api/hooks.py`、`api/notify.py`、`seed.py` | +| **BE-3 应用:对象、视图、派生、账本** | App / AppVersion、钉住项与升级处置、退役、`published_versions`、修 F1 / F3 / F4、`launch_run(origin, expect_hash)`、组件目录(后端)、表单与 `input_schema`、表达式加 `roots` / `arithmetic`、页面解析与视图模型、三种来源与缓存、印章、入口提交、实例、授权、应用检查、派生、应用操作、账本只读接口、指标证据链接口 | `engine/launch.py`、`engine/governance.py`(只改口径升版的判定)、`engine/expressions.py`、`api/workflows.py`、`api/evidence.py`(只加一条路由)、`api/apps.py`、`api/ledger.py`、`app/apps/*` | +| **BE-4 助手:按需查看 + 应用生成** | 第五部分全部;`/app-stream`;`workflows.origin` 的写入 | `api/copilot.py`、`api/copilot_context.py`、`api/copilot_gen.py`、`app/look/*`、`data/schema_view.py`、`tools/datasource.py`、`api/datasources.py`、`providers/mock_model.py` | +| **FE-5 使用面** | 应用首页、待办箱、应用页、发起页、实例页、登录页、按角色收起的导航、账户菜单、`SchemaForm`、`TaskCard`、视图渲染、印章、趋势与图表、待办心跳 | `pages/apps/*`(除 `edit/*`)、`pages/LoginPage.tsx`、`apps/*`、`components/SchemaForm.tsx`、`components/PrincipalPicker.tsx`、`run/TaskCard.tsx`、`App.tsx`、`components/CommandPalette.tsx`、`store/catalog.ts`、`components/ui.tsx`、`index.css`、`lib/termsApp.ts`、`api/apps.ts`、`api/tasks.ts`、`api/auth.ts`、`check-apps`、`check-inbox`、`check-auth`、`check-shell` | +| **FE-6 搭建面** | 应用编辑器;画布(人工节点、调用工具节点的新字段,发布弹窗里的退役和使用情况);记录页(来源、待办和动作事件、生效记录面板);工具页(能力、端点);设置页(身份、通知、审计);look 的界面;发起表单换成 `SchemaForm`;全部改用 `TaskCard` | `pages/apps/edit/*`、`canvas/*`、`pages/runs/*`、`pages/ToolsPage.tsx`、`pages/SettingsPage.tsx`、`run/decode.ts`、`run/RunControl.tsx`、`run/RunPanel.tsx`、`store/studio.ts`、`store/chat.ts`、`pages/ChatPage.tsx`、`lib/terms.ts`、`types.ts`、`api/capabilities.ts`、`check-app-editor`、`check-capabilities`、`check-copy`、`check-all` 以及 §4.5 列的那些更新 | + +每个包对外提供的函数见附录 C。 + +### 6.3 验证节奏 + +**开发阶段先把基础功能做完,验证集中放到后面,不在 check-all 上耗时间。** + +| 阶段 | 谁 | 跑什么 | 不跑什么 | +|---|---|---|---| +| 构建 | 各包代理 | 后端:自己改到和新增的 pytest 文件(`-n 8`)。前端:`tsc -b`,自己新写的单项检查脚本各跑通一次 | **不跑 check-all**;不反复跑全量 pytest | +| 交付前 | 各包代理 | 后端包:全量 pytest 一遍(`-n 8`)。前端包:`tsc -b` + `check-copy` | check-all | +| 复核 | 复核代理 | 只读代码,对照清单 | 任何检查 | +| 合并接通后 | 编排者 | **完整关口一次**:全量 pytest(`-n auto`)、`tsc -b`、`check-copy`、泄露扫描、`check-all --stacks 2`、另起一套开着 local 登录的服务跑 `check-auth` | — | +| 修正轮 | 编排者 / 包代理 | 只重跑受影响的单项检查和相关测试;最后全量 pytest 一次 | check-all 只在合 main 前、而且修正动过前端时再跑一次 | + +另外两条规矩: +- 不许用 `git stash`:所有 worktree 共用同一组 stash。要临时保存,用补丁文件; +- 跑 check-all 要加锁(`/tmp/agentlab-fix/checkall.lock`)。 + +### 6.4 冻结文件与跨包规则 + +- `db/models*.py`、`db/base.py`、`core/events.py`、`frontend/src/typesApp.ts` 在契约骨架之后**冻结**。要改,向编排者提,由编排者统一改。 +- 跨包只能调用附录 C 里列出的函数;要加新函数,记进「契约偏差」清单。 +- 一个包的改动弄坏了已有测试,由这个包负责修。 +- 依赖别的包运行时行为的测试,先用替身;集成阶段接上真实现,在集成修正里一并跑通。 + +### 6.5 验收的数据与模型 + +- **V1–V3、V5**:确定性的,用演示模型(Mock)加夹具: + - 虚构零售库 `backend/tests/fixtures/acceptance/retail.py`; + - 假外部系统 `backend/tests/fakes/fake_erp.py`(新); + - 假 OIDC `backend/tests/fakes/fake_oidc.py`(新); + - 不碰任何真实业务库。 +- **V4**(look 和应用润色)用真实模型:本机配置的助手模型、deepseek-v4.1-flash、glm-5.3。 + - 需要开发库副本时,复制数据目录前先请你放行; + - 真实库名、表名只留在 `/tmp`,不进仓库。 + +--- + +## 第七部分 验证与验收 + +### 7.1 V1 定时任务上的看板应用 + +**准备**: +1. 用上传表格建一个源(数据版本固定,结果可复现); +2. 工作流:取数 → 口径卡 → 报告 → 成果,受管级发布 v1,配好定时任务; +3. 从 v1 派生应用,发布,授权给 user 角色; +4. 「立即运行」两次。 + +**验收点**: +- user 看到的印章写着「截至… / 口径 v1 · 哈希 / 正式出具 / 定时任务第 2 次」。 +- 点数字能看到指标链,一路追到单元格;遮罩列已遮。 +- 发布 v2、定时任务改跑 v2 之后,应用页面照实提示「钉在 v1」。 +- 把应用钉住项升到 v2: + - 不选处置,被 `app.upgrade_policy` 拦下; + - 选 incomparable,趋势在升版处断开。 +- 退役: + - 退役 v2(当前版)被拒; + - 发布 v3 之后退役 v1,退役确认里列出受影响的应用。 +- 可见性表达式写了算术,被 `app.expr_arith` 拦下。 + +### 7.2 V2 审批流程应用,接一个假的外部系统 + +**假外部系统**: +- 认 `Idempotency-Key`,有查询接口; +- 能注入四种情况:正常、写成功后超时、500、4xx; +- 浏览器验收时用 `scripts/fake-erp.py` 起服务,作为登记端点(开 `allow_intranet`,配 test 和 prod 两个环境)。 + +**流程图**:入口 → 读端点查额度(作证,编号 Q)→ 口径卡 → 人工节点(财务岗位,`all`,`PT1H`,超时升级给总监岗位)→ 写入付款(需要确认)→ 成果。 + +**验收点**: + +| 场景 | 期望 | +|---|---| +| 两人都同意 | 流程恢复 | +| 其中一人驳回 | 走驳回分支;`human.resolved` 里带上会签明细 | +| 把时间拨过时限 | `tasks.tick` 升级给总监岗位,发出通知 | +| 动作确认 | 确认卡显示遮罩后的预览和哈希 | +| 确认之后上游状态被改动 | 哈希对不上,拒绝发送 | +| 在 sent 之后重启进程 | 收成「结果未知」 | +| 有查询能力 | 自动核实为已生效、自动接着跑,假外部系统只收到 **1 次**请求 | +| 去掉查询能力,走核实待办,选「未生效,重新发送」 | 第 2 次发送的幂等键带 `:2` | +| 探索运行、端点没有 test 环境 | 走演练,假外部系统收到 0 次请求 | +| 入站 signal 钩子 | 能解决「等付款回调」的待办 | +| 重复投递 | 按幂等键去重 | +| 签名错误、超出时间窗 | 拒绝 | + +### 7.3 V3 登录模式 + +- **off**:`X-Actor` 照常可用,界面上挂横幅。 +- **local**:引导出第一个管理员,再建 builder 和 user,然后验: + - user 访问 `/api/workflows` 得到 403; + - user 读没有授权的运行和工件得到 403; + - user 读到的查询快照已遮罩; + - 伪造的 `X-Actor` 被忽略,审计照实记录。 +- **oidc**(`fake_oidc.py`:discovery、授权、令牌、RS256 JWKS): + - groups 声明能映射到 admin 和 builder; + - 同步过来的组成员只读; + - nonce、state 防重放。 +- `reset-admin` 能找回管理员。 + +### 7.4 V4 助手按需查看 + +- look §2.8 的 9 个场景,加两个: + - 应用润色「首页先看指标再看报告,加一张按门店的表」:系统先代为查看 1 次钉住的版本,应用检查通过; + - 模型提出澄清问题,`ask` 结束这一轮。 +- 每个场景记录:模型调用次数、查看次数、system 字数、第一个操作到达的时间、总用时、自查修正轮数。 + +### 7.5 V5 跨重放协议的升级安全 + +扩充 `backend/tests/test_replay_side_effects.py`,monkeypatch 把 `PROTOCOL` 改成 3: + +| 停在哪里 | 期望 | +|---|---| +| 人工节点 | 能恢复 | +| agent 半路的审批 | 拒绝恢复;而且应用发布时已被 `process.wait_mid_agent` 拦下 | +| 写入节点开头等确认 | 能恢复,只发 1 次 | +| 结果未知 | 核实后接着跑,豁免生效,不重发 | +| 子图里的人工节点 | 发布时被 `process.human_in_subgraph` 拦下 | + +### 7.6 回归 + +- F2:正式运行带图接着跑得到 409;接着跑之后 `started_by` 不变。 +- F3:上游只有草稿改动时,下游不被逼着处置。 +- F4:删除被钉住的工作流得到 409。 +- 存量待审批补建出 Task;老前端调 `/api/approvals/{id}/decide` 仍然可用。 +- 关闭登录时,现有的全部测试和检查脚本照常通过。 + +--- + +## 第八部分 风险与对策 + +| 风险 | 对策 | +|---|---| +| 范围大,6 个包合并时冲突 | 契约骨架 + 文件独占 + 附录 C 只列函数级契约;编排者按依赖顺序合并 | +| 开了登录,破坏现有检查和流程 | 默认 off,所有现有检查在 off 下跑;`check-auth` 另起一套服务 | +| SQLite 写入争用(tick、钩子、会签) | 事务尽量短,用条件 UPDATE;`busy_timeout` 已经是 5000 | +| 以后改重放协议,正在等的实例恢复不了 | 发布检查加 `_stale_replay` 豁免;`replay.py` 写明规矩;V5 测试进全量测试 | +| 对 user 角色来说,遮罩从「减少暴露」变成了边界 | 用 `masking.py` 的派生遮罩;user 角色不给 `tool_snapshot` 原文;README 照实写哪些角色看得到原值 | +| 按需视图花费失控 | 点「刷新」才跑;同一个键只跑一份;每个应用有每日上限 | +| 端点作证被误用 | 只认登记里「平台执行」的端点;工件类型和所属运行双重核对 | +| 自绘图表能力有限 | v1 只做折线和柱状,不做交互缩放 | +| 应用生成的质量 | 派生是确定性的,助手只润色,应用检查兜底,真实模型验收 | +| 单进程的容量 | 应用是第一个会带来多人并发的功能。「不上 Postgres」继续守,但验收时量出并发上限,写进 README | + +--- + +## 第九部分 待拍板 + +已拍板的不再列:范围(应用平台 + look)、先出文档再实施、look §4 四项按推荐。 + +| # | 问题 | 推荐 | 理由 | +|---|---|---|---| +| Q1 | 导航怎么加 | 只加「应用」一项(⌥9),待办放在页内,徽标按来源分开 | F16:只放得下一项;待办是使用者视角,不该挤进记录页 | +| Q2 | 开登录后,user 角色能不能问数据 | 默认不能,给管理员一个开关 | 问数据能查任何接入的源,还没有按数据源的权限控制(P4) | +| Q3 | 凭据模式 | 服务凭据 + 身份断言,不存代用户的 token | 流程会等几天,超过 token 寿命;存刷新令牌会扩大泄露面 | +| Q4 | 探索运行写入、端点没有 test 环境时 | 演练(不发送,记 simulated) | 画布试跑不该被挡(P3),也不该碰生产系统;照实标「演练」(P1) | +| Q5 | 登录 | 只做通用 OIDC,留好适配器接口 | 飞书、企业微信、钉钉不是标准 OIDC,各要一套适配;先看实际需要 | +| Q6 | 待办通知 | ~~只用 Webhook 的四种格式,不做邮件~~ **已修订(2026-10,next-round/00-总方案.md X12、04 B2)**:加邮件——SMTP 可选、口令加密存、只确认交给了邮件服务器、退信不追踪(照实写);个人邮件渠道只发到账号邮箱;默认发给待办处理人由管理员开关、缺省关 | 原理由:发送核心现成可用;SMTP 要另配服务器和退信处理。修订理由:Webhook 只能发到群,派给个人的待办没有触达个人的路;退信处理改为照实不追踪,不另起退信服务(理由写在 notify/mail.py 的模块说明) | +| Q7 | 定时任务的通知存储 | 不迁移,新对象写通用投递表,两边共用发送核心 | F14:SQLite 改不了可空性,迁移要重建表,风险大于收益 | +| Q8 | 受管级发布、版本退役由谁做 | 只给 admin | 受管是最强的承诺,退役会影响正在用的应用 | +| Q9 | 会签 | any / all(一票否决)/ quorum(m) 都做 | 都在待办服务层汇总,引擎不用改,多做两种的成本很小 | +| Q10 | 端点读取的结果作证 | 纳入这次范围 | 外部系统来的数字也要有出处(P1);按查询快照的形状落,下游都不用改 | + +--- + +## 附录 A 代码位置索引(main@fc002bd) + +**发起、版本、治理** + +| 位置 | 内容 | +|---|---| +| `backend/app/engine/launch.py:45` | `launch_run` | +| `backend/app/engine/launch.py:92` | 只认当前发布版(F1) | +| `backend/app/engine/launch.py:147` | `notes`:发起时定下的事件,在封存范围内 | +| `backend/app/engine/launch.py:155` | `CATALOG_DRIFT` 提醒 | +| `backend/app/engine/governance.py:33` | `lint_for_publish` | +| `backend/app/engine/governance.py:127` | `publish_issues` | +| `backend/app/engine/governance.py:613`、`:643` | `unresolved_caliber_upgrades`、`func.max(WorkflowVersion.version)`(F3) | +| `backend/app/engine/autofix.py:700` | `_PLANNERS` | +| `backend/app/api/workflows.py:167` | `delete_workflow` | +| `backend/app/api/workflows.py:228` | `VersionDetailOut` | +| `backend/app/api/workflows.py:245` | `get_version` | +| `backend/app/api/workflows.py:324` | `publish_workflow` | + +**运行、断点、审批** + +| 位置 | 内容 | +|---|---| +| `backend/app/engine/runner.py:60` | `setup`(启动时 running → interrupted) | +| `backend/app/engine/runner.py:181` | `start` | +| `backend/app/engine/runner.py:278` | `resume` | +| `backend/app/engine/runner.py:449`、`:519` | `continue_failed`、覆盖 `started_by`(F2) | +| `backend/app/engine/runner.py:542` | `cancel` | +| `backend/app/engine/runner.py:619`、`:717` | `_drive`、每段的时长上限 | +| `backend/app/engine/runner.py:821` | `_handle_updates`(中断 → Approval) | +| `backend/app/engine/runner.py:864` | `_finalize` | +| `backend/app/engine/runner.py:1201` | `_approval_default` | +| `backend/app/engine/runner.py:1226` | `_stale_replay`(F9) | +| `backend/app/engine/runner.py:1314` | `_close_approvals` | +| `backend/app/engine/replay.py:32` | `PROTOCOL = 2` | +| `backend/app/engine/nodes/human.py:31` | 表单是静态配置 | +| `backend/app/engine/approval.py:60` | `read_decision` | +| `backend/app/engine/nodes/multi.py:610` | `run_subgraph`(F8) | +| `backend/app/engine/evidence.py:224` | `next_exec` | +| `backend/app/api/runs.py:28` | `actor_of` | +| `backend/app/api/runs.py:298` | `continue_run` | +| `backend/app/api/runs.py:525`、`:551`、`:595` | `approvals_router`、`list_approvals`、`decide` | + +**工具、能力、安全** + +| 位置 | 内容 | +|---|---| +| `backend/app/tools/registry.py:26`、`:46` | `ToolContext`、`ToolSpec` | +| `backend/app/tools/registry.py:98` | `call_is_dangerous` | +| `backend/app/tools/registry.py:142` | `build_tools` | +| `backend/app/tools/registry.py:278`、`:324` | `prepare_args`、`call_tool` | +| `backend/app/tools/trust.py:48`、`:59`、`:84`、`:171` | `call_policy`、`asks_trustable`、`run_snapshot`、`ask_gate` | +| `backend/app/tools/net.py:17`、`:20`、`:98`、`:157` | `_BLOCKED_PORTS`、`_is_private`、`assert_safe_url`、`safe_request` | +| `backend/app/tools/custom.py:44`、`:127`、`:160`、`:183` | `schema_to_model`、`_run_http`、`_run_python`、`build_custom_tools` | +| `backend/app/tools/mcp_manager.py:18`、`:211` | `_server_config`、`_keyed` | +| `backend/app/tools/datasource.py:176`、`:205`、`:460`、`:681` | `_query_description`、`build_datasource_tools`、`_make_query_tool`、`_make_schema_tool` | +| `backend/app/engine/nodes/tools.py:186`;`backend/app/engine/nodes/llm.py:511`、`:874` | 证据防伪按名字前缀认(F11) | +| `backend/app/engine/verify_agent.py:841` | 唯一的 `mask_results=True` | +| `backend/app/data/engine.py:376` | `masked_columns` | +| `backend/app/engine/masking.py:2806` | `mask_query_result` | +| `backend/app/data/catalog.py:79`、`:81`、`:335` | `TABLE_FIELDS`、`COLUMN_FIELDS`、`validate_notes` | +| `backend/app/engine/expressions.py:455`、`:543`、`:692` | 根名字、`parse_expression`、`eval_expression` | + +**数据模型与迁移** + +| 位置 | 内容 | +|---|---| +| `backend/app/db/models.py:34` | `DataSource` | +| `backend/app/db/models.py:447` | `McpServer` | +| `backend/app/db/models.py:466` | `CustomTool` | +| `backend/app/db/models.py:485` | `Workflow` | +| `backend/app/db/models.py:506`、`:513` | `WorkflowVersion`、级联删除(F4) | +| `backend/app/db/models.py:535` | `Run` | +| `backend/app/db/models.py:609` | `Approval` | +| `backend/app/db/models.py:803`、`:836` | `ConversationTurn`、`meta` | +| `backend/app/db/models.py:846`、`:883`、`:914` | `Schedule`、`ScheduleFire`、`WebhookDelivery`(F14) | +| `backend/app/db/base.py:134`、`:225`、`:231`、`:244` | `_COLUMN_MIGRATIONS`、`_INDEX_MIGRATIONS`、`_migrate`、`init_db` | +| `backend/app/core/events.py:11` | `EventType` | +| `backend/app/main.py:151` | `include_router` | +| `backend/requirements.lock:109` | `jsonschema==4.26.0` | + +**调度与通知** + +| 位置 | 内容 | +|---|---| +| `backend/app/scheduler/service.py:32` | `ACTIVE_RUN`(含 interrupted) | +| `backend/app/scheduler/service.py:172`、`:191`、`:250` | `input_fields`、`check_fields`、`fire_once` | +| `backend/app/scheduler/loop.py:26`、`:114` | 节拍 15 秒、`tick` | +| `backend/app/scheduler/webhook.py:47`、`:48` | `FORMATS`、`EVENTS` | +| `backend/app/scheduler/webhook.py:445`、`:506`、`:635`、`:720` | `build_request`、`send`、`notify_due`、`_state_of` | +| `backend/app/scheduler/auto_compare.py:97` | `sealed_now` | +| `backend/app/api/compare.py:100` | `pick_base` | + +**证据与渲染** + +| 位置 | 内容 | +|---|---| +| `backend/app/engine/evidence.py:91` | `EvidenceEntry` | +| `backend/app/engine/evidence.py:611`、`:691`、`:702` | `render_number`、`render_metric`、`render_cell` | +| `backend/app/engine/evidence.py:851`、`:2737`、`:3025` | `build_catalog`、`compose_doc`、`verify_doc` | +| `backend/app/api/evidence.py:239`、`:843` | `_sealed`、`_chain` | +| `backend/app/report_export/build.py:224`、`:288`、`:307`、`:364` | `_render_block`、`render_body`、`build_report`、`render_html` | + +**助手**(look 文档引用的其余位置在 main 上没变,变了的见 §5.5) + +| 位置 | 内容 | +|---|---| +| `backend/app/api/copilot.py:75`、`:134`、`:341`、`:371`、`:459` | `_tool_catalog`、`_datasource_context`、`GenerateIn`、意图为回答的约定、`_history_section` | +| `backend/app/api/copilot.py:838`、`:878`、`:1004`、`:1090`、`:1192` | `_STREAM_PROTOCOL`、`_parse_op_line`、`_apply_op`、`_iter_ops`、`_SELF_CHECK_ROUNDS` | +| `backend/app/api/copilot.py:1779`、`:1800`、`:2114`、`:2296`、`:2496` | `_repair_request`、`/generate-stream`、`assist_publish_fix`、`assist_upgrade`、`/from-run` | +| `backend/app/api/copilot_context.py:108`、`:223`、`:249`、`:279`、`:427`、`:570`、`:599` | `PICK_SYSTEM`、`_modes`、`graph_tables`、`plan_context`、挑表返回空被记成失败、`op`、`section` | +| `backend/app/api/governance.py:90` | `/exploratory-clusters` | +| `backend/app/data/introspect.py:30`、`:425` | `_PREVIEW_COLUMNS`、`describe_table` | +| `backend/app/seed.py:38`、`:358` | `TEMPLATES`、`seed_defaults` | + +**前端** + +| 位置 | 内容 | +|---|---| +| `frontend/src/App.tsx:52`、`:102`、`:322` | 心跳、路由、`ApprovalBadge` | +| `frontend/src/components/CommandPalette.tsx:53` | `PAGES`(导航) | +| `frontend/src/run/RunControl.tsx:82`、`:242` | `FieldsForm`、`FormalLauncher` | +| `frontend/src/run/RunPanel.tsx:38` | `ApprovalCard` | +| `frontend/src/store/catalog.ts:43` | `HEARTBEAT_MS` | +| `frontend/src/store/studio.ts:1896` | `case 'context'` | +| `frontend/src/run/decode.ts:3052`、`:3097` | `CONTEXT_FALLBACK_TEXT`、`case 'model'` | +| `frontend/src/lib/terms.ts:3420`、`:3421` | 「参考了 N 张表的字段」「另有 N 张表只提供了表名」 | +| `frontend/src/pages/ChatPage.tsx:307` | 「在画布里打开」 | +| `frontend/src/pages/runs/RunDetailView.tsx:403` | 「提取模板」 | +| `frontend/src/api/client.ts:714`、`:811` | 聚类接口(没有调用方)、`fromRun` | +| `scripts/check-shell.mjs:187` | 480px 高度检查 | + +## 附录 B 术语 + +| 术语 | 含义 | +|---|---| +| 应用 | 已发布工作流版本的使用面:入口、视图、实例、待办、动作、询问的组合 | +| 应用版本 | 应用发布时生成的不可变快照:spec、钉住项、岗位绑定、级别 | +| 钉住项 | 应用引用的某个工作流版本(带 graph_hash) | +| 入口 | 应用里发起一件事或一次查询的表单 | +| 视图 | 展示运行产物的区域;来源分最近封存、按需、本实例三种 | +| 印章 | 视图上的「截至 / 口径 / 出具档位 / 来源」 | +| 实例 | 应用里发起的一次正式运行,即一件正在办的事 | +| 待办 | 一次中断面向人的那一面;可以派给多人,按策略汇总 | +| 岗位 | 图里人工节点写的角色名;绑定到人或组在应用版本里做 | +| 会签 | 一条待办派给多人,按 all 或 quorum(m) 汇总 | +| 动作 | 调用工具节点对外部系统的一次写入 | +| 生效记录 | ActionRecord:动作的权威记录,带幂等键、状态、哈希、回执 | +| 结果未知 | 请求已发出、拿不到确定结果的状态;只能核实,永不自动重发 | +| 演练 | 探索运行里、端点没有 test 环境时的写入:不发送,照实记录 | +| 能力 | 图里任何一个可调用的工具,连同它的读写效果、参数、返回、语义、审批策略 | +| 端点 | 管理员登记的外部系统地址和凭据,分 test / prod 两个环境 | +| 退役 | 工作流版本不再接受新的正式运行;进行中的实例照常走完 | +| 运行来源 | 这次运行从哪里发起,记进封存范围 | +| 查看(look) | 助手在一轮里主动查看表、能力或工作流版本 | +| 三个面 | 问(推理)、建(承诺)、用(使用与生效) | + +## 附录 C 跨包接口契约 + +契约骨架提交时,这些函数先写好签名,桩里抛 NotImplementedError。 + +| 提供方 | 函数 | 说明 | +|---|---|---| +| BE-1 | `current_principal() -> Principal` | 当前请求的身份:用户名、角色、组、是否经过认证、登录模式 | +| BE-1 | `require_role(role) -> Depends` | 路由级依赖 | +| BE-1 | `can_read_run(principal, run) -> bool`、`can_read_artifact(principal, artifact_id) -> bool` | 运行和工件的读权限 | +| BE-1 | `get_capability(name) -> Capability \| None` | 能力登记(治理后的值) | +| BE-1 | `capability_snapshot(spec) -> dict`、`capability_versions_for(spec) -> dict` | 发起时的治理快照、发布时的能力版本 | +| BE-1 | `is_platform_query(name) -> bool` | 端点读取能不能作证(替换 F11 的三处判断) | +| BE-1 | `endpoint_request(capability, args, *, environment, idem_key, on_behalf) -> EndpointResult` | 平台执行器:发请求、落快照 | +| BE-1 | `masked_paths(name) -> list[str]` | 能力的遮罩字段 | +| BE-2 | `task_service.open(approval) -> Task`、`respond(task_id, principal, decision, values, note, payload_hash)`、`tick(now)`、`reassign(...)` | 待办 | +| BE-2 | `action_service.prepare(...)`、`send(...)`、`reconcile(...)`、`tick(now)` | 动作 | +| BE-2 | `notify.send(channel, message) -> Outcome` | 通知发送核心 | +| BE-2 | `hooks.verify(hook, headers, body) -> HookRequest` | 入站签名校验 | +| BE-3 | `published_versions(session, workflow_id) -> list[WorkflowVersion]` | 「已发布且未退役」的唯一说法 | +| BE-3 | `start_entry(app_id, entry_id, values, principal) -> Run` | 应用入口发起 | +| BE-3 | `validate_form(form, values) -> list[FormError]`、`input_schema(fields) -> dict` | 表单 | +| BE-3 | `apply_app_op(spec, op) -> Applied`、`lint_app(app_version_draft) -> list[Issue]`、`derive_app(version) -> AppSpec` | 应用操作、检查、派生 | +| BE-3 | `describe_version(version) -> str` | 给 look 的「工作流版本」详情 | +| BE-4 | 生成过程协议:`generate(system, request, applier, checker, look_kinds) -> AsyncIterator[op]` | 四个入口和 `/app-stream` 共用 | +| BE-4 | look 事件的形状(L2) | 前端解码依据 | +| FE-5 | `SchemaForm` props:`{schema, value, onChange, errors, readOnly?}` | 表单 | +| FE-5 | `TaskCard` props:`{task, onResolved, compact?}` | 待办卡 | + +## 附录 D 核对方法 + +- **代码位置**:全部用 `git grep -n … main` / `git show main:<路径>` 取得,基准 main@fc002bd。工作区所在的分支比 main 旧,没有用它。 +- **事实核对**:F2、F3、F4 逐行读过 main 上的源码(`runner.py:449–540`、`governance.py:613–669`、`models.py:513`)。 +- **匿名**:全文没有真实的数据源名、表名、主机名、部署地址;示例名都是虚构的。本文没有引用本机开发环境的计数。要提交前,再按泄露扫描的规则过一遍。 +- **look 文档**:这次没有改动,写之前和写之后的 sha256 一致。 + +## 附录 E 实施中拍定的细节(2026-10-14) + +用户没有逐条回复第九部分,Q1–Q10 一律按推荐执行。实施中又拍定了下面这些细节,和正文不一致的地方以这里为准。 + +| # | 细节 | 定法 | 理由 | +|---|---|---|---| +| D-a | 探索运行里,端点没有测试环境时 | 写入走演练;**读取走生产** | 读不改外部系统;一律演练会让问数据用不了只登记了生产环境的端点。修正 §2.11「运行时也只能走 test 或演练」:这句只对写入成立 | +| D-b | 身份断言的主体 | `sub` = 运行发起人;确认动作的人放在 `act` 声明(RFC 8693) | 外部系统看到的是「谁发起的这件事」,确认人另外可查 | +| D-c | 开了 user_can_chat 时 user 的问数据运行 | 只能是探索运行、来源 chat;只允许 input、llm、agent、report、output、retrieve、transform、branch、metrics、merge 节点;agent 只能用数据源工具和只读内置工具;不能正式运行(正式运行只经应用入口);查询结果在工具边界先遮;WebSocket 按运行判读权限 | 没有按数据源的权限控制,只能把 user 能做的事收窄到「问」 | +| D-d | 对话归属 | 新建写 `created_by`;开登录后 user 只看、只改自己的对话;老对话只有 builder 以上可见 | 对话里有别人的问题和带数据的回答 | +| D-e | 应用编辑器的助手对话 | `kind=app`、`app_id`、`created_by`;按应用取或建;应用润色带历史 | 润色是多轮的 | +| D-f | 按新口径补算 | 只在钉住项升版且处置为 recompute 时可用;用旧版本最近 N 次封存运行的输入,以新版本逐个发起正式运行(来源 app_backfill),同一输入只补一次 | 让 recompute 的趋势真的有历史点,而不是只画新版本 | +| D-g | 钩子 | spec 里声明,`app_hooks` 按名字关联、只存密钥和启停;入站时按当前发布版的声明认 kind 和入口 | 一份声明,不在两处各存一份入口 | +| D-h | 助手润色的权限 | 只放行页面树的增删改移和页面标题;`app_set` 只放行 task_ui 和表单的外观键 | A8「助手只润色」由服务端保证,不只靠提示词 | +| D-i | 能力字段语义的审阅 | 沿用数据目录「项」的状态;在能力登记下另开审阅队列,排序思路同数据目录 | 「同一种说法」:语义的确认方式和数据目录一致 | +| D-j | 按需视图的每日上限 | 设置项 `apps.on_demand_daily_limit` | — | +| D-l | 登录设置 | 只经 `/api/auth/settings` 读写,通用设置接口不暴露、不能覆写 | 防止绕过引导和管理员映射检查 | +| D-m | 应用详情 | 只有 manage 能拿到草稿和岗位绑定 | 岗位绑定里有人和组的名单 | +| D-n | 删运行 | 有演练以外的生效记录的运行一律不能删(含强制删除);删工作流的阻挡也算上已退役版本的生效记录 | P8:生效记录是和外部系统对账的依据 | +| D-q | 子图固定版本 | 正式运行发起时,子图固定的版本必须已发布且未退役;发布检查先预读子工作流 | A1「已发布版本只有一种说法」延伸到子图 | +| D-r | 运行接口 | RunOut 带来源;按来源筛选(`none` = 来源未记录) | X3 | +| D-s | 界面偏好 | 开登录且不是 admin 时只存本地 | 通用设置只给 admin 写 | + +另外两条实施事实: +- 开发库在 17:46 被这个分支的代码跑过一次启动迁移,多了空表和可空列,只增不改,main 不受影响。时间早于代理开工,最可能是开发服务热重载。 +- MCP 的 http 地址指向本机或内网的存量服务,升级后首次启动自动登记成开了内网访问的端点(`mcp-<服务名>`)并绑定,写审计。之后新建的内网 MCP 须管理员先登记端点。 diff --git a/docs/design/caliber-authoring-optimization.md b/docs/design/caliber-authoring-optimization.md new file mode 100644 index 00000000..7c444b1c --- /dev/null +++ b/docs/design/caliber-authoring-optimization.md @@ -0,0 +1,48 @@ +# 计算口径:作者侧与审阅侧的优化决定 + +> 2026-11-04 复盘 11-03 至 11-04 的口径相关改动(指标定义卡与四种指标类型、口径目录与试跑、业务文档起草与多轮续跑、descriptor/usage)后的决定。本文只写结论和落地顺序;接口和数据模型的细节以代码与测试为准。 + +## 缺口 + +1. **能看,不能改。** 指标定义卡与四种指标类型进了 schema、校验、descriptor、目录和证据标签,但画布的指标编辑器只有 id / 名称 / 单位 / 表达式四栏;草稿页只读;规则编译只有接口。定义卡有形状、没有作者。 +2. **审阅对象不对。** 草稿页不显示 SQL,试跑只回标量值;没有结果表、列映射、SQL 差异。业务的人判断不了数对不对,技术的人只能跳画布;目录页试跑成功后连运行记录都留不下。 +3. **两处真相。** 草稿 payload 与 workflow graph 各说各话:改 payload 不重建图,画布改图后草稿页不更新。 +4. **编排过重。** 10 轮串行、60 条上限、15 分钟轮询,靠截断抢救、问题去重、入口合并、日期预填、提示词补丁兜底;没有取消、重试、单组重试。 +5. **概念与状态过载。** 文档起草、接一次运行、可沉淀的问题三套创建路径,7 种草稿状态;`questions_open` 不阻断试跑,`path_pending` 是遗留。 +6. **资产面缺管理。** 目录只列每个工作流最新已发布版本,没有版本对比、使用情况、退役、试跑历史。 +7. **定位没说清。** 设计文档写「不做从指标定义编译 SQL 的语义层」,起草主路径却由模型写 SQL;docstring 里的重新解释没有写进设计文档和界面文案。 + +## 决定 + +- **D1 口径图是唯一真相。** 草稿在第一次建图后,指标、取数、入口都以 workflow 当前版本里的图为准。画布与草稿编辑共用保存逻辑:每次实际修改新增版本,旧快照(含已发布版)不可覆盖。PATCH 在当前图上改口径卡,不从 payload 重建取数节点,保留节点 ID、SQL、入口和其他编排。试运行明确固定当前版本,强制只读,记录图、执行内容和输入的哈希;执行内容修改后旧结果显示待重新验证,只改布局则保留原版本证据。晚结束的旧试运行不能覆盖新结果或并发编辑。payload 里的指标快照只留给起草中途和旧草稿兼容。 +- **D2 定义卡分两层。** 核心:名称、同义词、单位、小数位、定义说明、负责人、生效日期、引用原文;高级:适用范围、不包含、分子、分母、维度、过滤。四种指标类型都必须在界面上可编辑;规则与评估的结构先用 JSON 编辑,后续再换结构化表单。指标参数(`params`)目前没有运行时语义,先不承诺、不推广,后续要么实现「按参数生成口径实例」,要么从 schema 和目录里删掉。 +- **D3 审阅面必须有证据。** 草稿页与目录页的试跑都要给:取数模板 SQL、执行后的结果表(前若干行、总行数、遮罩格如实显示)、列与指标的对应。目录页的试跑要留下运行链接,刷新不丢。发布前再给一份只读检查清单(图能过发布校验、SQL 单条只读且表在目录里、入口字段与占位符一致、试运行成功、每条指标有引用原文),不拦人,只把没通过的项说清。 +- **D4 状态收敛。** 界面只有四种阶段:起草中 / 待审 / 已丢弃 / 失败;`ready`、`questions_open`、`path_pending`、`attached` 都归「待审」,细状态保留在接口和标签的悬停提示里,接口兼容不迁移存量数据。三套创建路径最终收敛为一个「新增口径」向导(从文档 / 从运行 / 手动),输出同一种草稿。 +- **D5 起草分组。** 一次模型调用尽量交多组「能共用一条 SQL 的指标」;服务端逐组校验,一组不合格只丢这一组,并只对这一组重写一次(最多三组并行),修好就并入。一轮写不下时按 `uncovered` 再续,最多 4 轮;不再一组一轮、10 轮串行。起草是离线辅助,回答问题时仍不做「定义 → SQL」的编译;发布的是人审过、跑过的 SQL。 +- **D6 目录按口径管理。** 目录按口径(工作流)分组,展开是版本列表:版本、发布时间、级别、变更摘要、指标数、入口、依赖、与上一版的差异、使用情况、退役动作、试跑历史。「新增口径」是主按钮,草稿箱独立。 +- **D7 口径身份不动。** 不新建「口径」实体表;身份仍是「工作流@版本 / 口径卡」。账本、证据、召回、descriptor 都建在这个身份上,换身份牵一发动全身,收益不抵成本。 + +## 落地顺序 + +- **P0(本次)**:补全草稿指标的 grain / time_field / numerator / denominator / dimensions / filters;payload 记 `groups`;多组指标 id 建图前全局重排,payload、图、试跑值指同一件事;试跑响应带查询结果集;草稿页展示 SQL 与结果表;目录试跑留运行链接;画布指标编辑器补定义卡与四种类型。 +- **P1(下一轮)**:草稿编辑写图、图回读草稿、界面四阶段、发布前检查、「新增口径」主入口都已落地。三来源向导(从文档 / 从运行 / 从可沉淀的问题)等起草流程稳定后再收成一个入口。 +- **P2(已落地)**:起草改成一次交多组、逐组校验、坏组并行重写一次、最多 4 轮;前端按组显示「这条 SQL 出哪几个指标」。「指标清单」单独成阶段留作后续优化,等实际反馈再决定。 +- **P3**:目录分组、版本对比、使用情况、退役、试跑历史(D6)。 + +## 验收 + +- 后端:相关 pytest 全过;试跑结果集的遮罩与截断有测试。 +- 前端:`pnpm build`、`node scripts/check-copy.mjs` 全过;四种指标类型切换不丢字段;草稿页与目录页在旧数据(没有 `queries`)下不报错。 +- 人工:拿一份真实业务文档走一遍「起草 → 看 SQL / 结果 / 引用 → 改定义 → 试跑 → 发布」,确认每一步都能拿到判断所需的信息。 + +## 2026-11-06 架构修复 + +- **验证分开执行和质量。** 试运行的 `validation` 给出 `pass / fail / unknown`;运行成功不等于口径验证通过。缺数、截断、SQL 错误、复算不一致、显式约束失败和可比性未知,都会进入质量结论与出具缺口。旧证据没有质量版本记录时要求重新验证。试运行指标值和查询表使用相同遮罩,接口不再附带未经处理的原始运行输出。 +- **显式约束可以执行。** 指标 `contract` 支持结果形状、粒度唯一性、非空列、单位、数值范围,以及来自同一行的分子/分母、子集关系和倍率。自动起草的标量指标默认要求一行结果,不能静默取多行中的第一行。自然语言粒度、过滤等描述尚未被证明与 SQL 等价时仍显示待审;这不是通用的自然语言到 SQL 编译器。 +- **分离指纹用途。** `definition_hash` 覆盖指标声明;`semantic_hash` 加入上游 SQL、数据源和绑定;`execution_hash` 忽略画布布局。旧 `graph_hash` 不改算法,历史封存仍可核验。可比性规则升级到分析方法版本 `@2`,复核历史结果会明确显示方法变化。 +- **调查有完成条件和预算。** 追问原因或发现基线异常会触发继续调查,核对闭合、可比性、下钻关联与整体贡献覆盖;子层占比沿父层累计。默认最多追加两轮、最低下钻一层、覆盖程度为 0.8,仍受总步数、查询和费用预算约束。无法补齐时封存停止原因并限制正式出具,完成贡献分析也不等于证明因果。可用 `investigation` 评测期望检查状态、深度、查询预算和停止原因。 +- **起草生命周期持久化。** `CaliberDraftJob` 记录属主、状态和尝试次数,支持停止和重试。启动时将遗留任务标为中断;只有用户重试才继续模型调用。同一材料可恢复已校验分组,材料变化则重新规划;并行分组修复使用独立会话。共用任务管理器拒绝重复启动同一任务。 +- **应用服务与影响追踪。** 发布检查、封存证据读取和起草编排提取为公共服务。版本使用情况展示传递依赖的工作流与应用,退役时提供影响清单;无法解析的历史图明确列出,不能按无依赖处理。 +- **一并修复的通用问题。** SQLite 证据追溯的编译兜底增加授权回调的 SELECT 计数,避免某些版本把复合查询编译成临时表、没有 Yield 时被误认为单表直接投影。microVM 在缺少可选 SDK 时返回明确的执行失败结果,不直接抛导入异常。评分输入拒绝 NaN/Infinity,起草任务操作错误在详情中可见,旧请求不覆盖后来打开的草稿。 + +接口和回归覆盖上述行为;真实业务口径的审阅、模型是否能选择有效业务维度、以及生产数据上的调查效果仍需用业务样例验收。 diff --git a/docs/design/catalog-profiling-jobs.md b/docs/design/catalog-profiling-jobs.md new file mode 100644 index 00000000..57b4692e --- /dev/null +++ b/docs/design/catalog-profiling-jobs.md @@ -0,0 +1,200 @@ +# 数据剖析重新设计:逐表剖析的后台任务 + +> 状态:方案已定(2026-10-14,§6 四项都按推荐),未动代码。基于 main `fc002bd`。文中表名、列名都是虚构的举例。 + +## 1. 现状(已对照代码核实) + +| 问题 | 代码里的样子 | 用户看到的 | +|---|---|---| +| 一次同步长请求 | `POST /catalog/profile` 做完整个剖析才返回(`api/catalog.py`);进程内集合 `_RUNNING` 防重入 | 进行中只有「已用时长 / 总时长上限」一条横条,代码注释原话「不是进度」;**不能停**;关掉页面报告就没了 | +| 预算按「一次剖析」算 | `max_queries=60`、`max_total_s=120`,注释原话「接口是同步的,不能让一个请求无限地挂着」 | 一张有 4 条关系、6 个码值列的表要 25–30 条查询(行数 1–2;每条关系抽样 1 + 父表核对按 1000 个一批 2 + 唯一性 1–2;码值每列 1;日期 1)。选 50 张表,前两三张做完,其余整张记「查询次数用完」——**「剖析只做了几张」的根源** | +| 默认范围窄 | 不选表时只挑 10 张「目录里有待核实关系」的表;为跳过空表,先拿一半查询次数去估候选表的行数(`_skip_empty`) | 没有关系候选的表(码值、业务日期一样有用)默认永远轮不到 | +| 结果不留痕 | 行数估算、日期范围、跳过了什么只在那次响应里;日志只记查询次数 | 表清单、表详情看不出这张表剖析过没有、什么时候、结论是什么;对业务库发过哪些查询无从查 | +| 和起草没协调 | 起草任务核对有效记录条件也打业务库;剖析与起草可同时跑 | 业务库压力叠加;先剖析后起草时,起草新提的关系没被核对 | + +起草在上一轮已经从同步请求改成后台任务(`data/catalog_jobs.py`:任务表 + 逐表表、进程内执行、停止、重启标中断、重试),剖析是同一类问题,应该走同一套做法。 + +## 2. 设计理念对照 + +| 理念 | 这次怎么落 | +|---|---| +| 照实说 | 每张表、每个检查项照实写状态和原因;「没查成 ≠ 结论错了」,旧结论原样保留(沿用 `_Skip.carry`);停下时写清已写进目录的和没做的 | +| 留痕 | 任务、逐表结果、发过的查询(字面量去掉)落库;表清单和表详情能看到最近一次剖析 | +| 只读遮罩 | 同一个只读守卫、同一条 `run_query`;遮罩列不进任何查询;留痕的 SQL 去掉字面量,AgentLab 自己的库里也不留抽样到的键值 | +| 一种说法 | 剖析和起草共用一套任务生命周期、状态词、接口形状、界面组件;预算的叫法全站一致 | +| 不挡生成 | 剖析从不挡运行和表助手;只和同源的起草任务排队 | + +## 3. 目标体验(产品) + +### 3.1 四个场景 + +1. **新接入一个源**:探查结构 → 起草(后台)→ 点「数据剖析」,范围选「需要剖析的表」→ 剖析排在起草后面自动开始 → 关掉页面去做别的,回来看逐表结果。 +2. **日常维护**:表清单多一列「剖析」,一眼看出哪些表没剖析过、上次没做完、剖析之后又冒出了待核实的关系;按它筛出来一键剖析。 +3. **单张表**:表详情里「数据剖析」一节,看上次的行数、日期范围、核实了什么、跳过了什么;点「剖析这张表」。 +4. **出问题**:连接断了、库响应慢,任务自己停下并写明原因;「重试没做完的表」只跑剩下的。 + +### 3.2 剖析对话框(照起草对话框的结构重做) + +**开始前** +- 范围:已选的表 N 张 / 当前筛选 N 张 / **需要剖析的表 N 张(默认)** / 全部表 N 张(不含视图)。 +- 预算按表写:「每张表最多 40 条查询、60 秒;单条查询 10 秒;每条关系抽 2000 个键值」,再给上限的估算「这次最多约 1,600 条查询」。 +- 醒目一句:剖析会对业务库发只读查询,一条一条地发,可随时停止。 +- 同源有起草任务在跑时:按钮写「排队剖析」,下面一句「起草任务结束后自动开始」。 + +**进行中** +- 头部:`12 / 40 张表 · 已发 318 条查询 · 用时 4 分 12 秒`,按表数的进度条(真进度),「停止」按钮。 +- 逐表一行: + + ``` + [进行中] orders 关系 3/4 · 码值 5/6 · 日期 1/1 23 条查询 · 18 秒 正在核对 customer_id → customers.id + [完成] customers 关系 1/1 · 码值 2/2 6 条查询 · 3 秒 核实 1 条关系,取到 2 列码值 + [部分] events 关系 0/2 · 码值 1/3 40 条查询 · 60 秒 这张表的查询次数用完,剩 4 项没查 + [跳过] tmp_log 1 条查询 空表 + [排队] refunds + ``` +- 点一行展开:这张表的检查清单(每项的状态、原因、用了几条查询)、发现(沿用现有报告里的关系、码值、业务日期写法)、「看发过的查询」。 + +**结束后** +- 汇总:`40 张表:完成 33,部分 4,跳过 2,失败 1;核实 14 条关系、取到 51 列码值候选(含义待填写 37 列)`。 +- 筛选:有发现 / 有跳过 / 失败;「重试没做完的表」;码值「填写含义」的入口照旧。 +- 结束时页面右下角提示,可回到报告(对话框关着也行)。 + +**开始不了**(409):沿用现有五种情况的说法和下一步(未开启、停用、没有表结构、快照被改动、正忙),「正忙」改成说清楚是谁在跑、跑到哪。 + +### 3.3 其他位置 + +- **目录页头部**:起草、剖析各一个进度入口(`起草 12/40` `剖析 排队中`),点开对应对话框。 +- **表清单**:新列「剖析」:未剖析 / 3 天前 / 部分完成 / 失败 / 剖析中 / 排队;筛选项「需要剖析」。 +- **表详情**:「数据剖析」一节——上次剖析(时间、发起人、所属任务)、行数估算及来源(统计信息 / 数到上限)、日期范围、发现摘要、跳过的项、发过的查询;按钮「剖析这张表」(未开剖析时引到设置)。 +- **剖析设置**:「查询次数上限」「总时长上限」换成「每张表最多查询数」「每张表时长上限」;其余三项不变。 + +## 4. 架构 + +### 4.1 抽出共用的任务运行时,起草和剖析各自建表 + +新模块 `data/catalog_job_runtime.py`,从 `catalog_jobs.py` 里抽出来、不改行为: + +- `JobManager`:进程内一个 asyncio 任务一件事;`start / stop(grace) / wait / running / shutdown`。 +- `SerialWriter`:同一个任务的库写入一条一条来、shield 起来(停止时不打断写到一半的库操作),收尾前 `settle`。 +- `SourceLane`:**每个数据源一条队**(FIFO 的 asyncio 锁)。任务建出来是 `queued`,拿到这条队才 `running`。起草和剖析同源串行,不同源并行。 +- `interrupt_on_startup(job_model, task_model, message)`:启动时把排队、进行中的标 `interrupted`,没结束的表记 `cancelled`。**不自动续跑**(复制出来的数据目录启动时不能自己去查业务库,比起草更要紧)。 +- 状态词常量:任务 `queued / running / succeeded / partial / failed / cancelled / interrupted`,逐表 `pending / running / ok / partial / failed / skipped / cancelled`——起草已有的那套,剖析照用。 + +`catalog_jobs.py`(起草)改为用这些,**现有起草测试一条不改、全部要过**,这是抽取正确的判据。 + +剖析的任务在新模块 `data/catalog_profile_jobs.py`,新表(只加不改,迁移是加法): + +**`catalog_profile_jobs`**:`id, source_id, status, scope(selected|needed|all), settings(JSON,建任务时的快照), created_by, started_at, finished_at, queries, timeouts, errors, stop_reason(failed|slow|NULL), notice, error, retry_of`。部分唯一索引:同源只有一个排队或进行中的剖析任务(同起草)。 + +**`catalog_profile_tasks`**:`id, job_id, seq, table_name, status, checks(JSON:[{kind, target, path, state, reason, queries}]), queries, elapsed_ms, current(正在做哪一项的一句话), row_estimate(JSON), findings(JSON), skipped(JSON), date_ranges(JSON), query_log(JSON), added, updated, removed, version, error, started_at, finished_at`。 + +否决「和起草合成一张通用任务表(kind + JSON)」:要迁移刚稳定的起草数据、丢掉起草进度那些强类型列,换来的只是少两张表;共用的是生命周期代码,不是表。 + +### 4.2 剖析引擎:从「一次剖析」改成「一张表」 + +`catalog_profile.py` 保留全部检查逻辑(关系、码值、日期、`_apply` 并入规则、乐观锁写入),只换编排: + +- **`profile_table(cx, table, notes, hooks) -> TableResult`** 成为公开入口:先列全检查项(现在的 `_plan`),逐项执行,每项开始、结束经 hooks 回调(任务据此写 `checks / current / queries`),整张表结束写目录(现在的 `_write`)。 +- **`_Runner` 拆两层**: + - `SourceRunner`(整个任务一个):只读守卫、单条时限(取设置和数据源时限中小的)、**任务级止损**、查询留痕。 + - `TableBudget`(每张表一个):这张表的查询次数、时长。用完抛 `_Stop("budget"|"deadline")`,**只停这张表**——剩下的项记「这张表的查询次数用完 / 时长用完」并保留旧结论,表记 `partial`,下一张表拿满额预算。 +- **任务级止损**(替代原来的总量上限,见 §6 问题 1): + - 连续 3 条查询失败(沿用现规则:多半是连接断了、没权限)→ 任务停,`stop_reason=failed`,写出第一行报错; + - 最近 10 条查询里 7 条超时 → 任务停,`stop_reason=slow`,「数据库响应慢,已停止,避免给业务库加压」; + - 剩下的表记 `cancelled` 并带上原因,可重试。 +- **空表**:每张表第一条查询就是估行数,确知是 0 行 → 表记 `skipped`「空表」(只花 1 条)。原来挑表前的批量预估(`_skip_empty`、`PROFILE_DEFAULT_TABLES`、`PROFILE_PROBE_TABLES`)删掉。 +- **表的状态**:每一项都有结论 → `ok`;有项因为运行原因没查成(次数、时长、超时、出错、守卫拒)→ `partial`;按规则不查的(遮罩、视图、取值太多不像码值、空表)不算没做完。写入一直冲突、整张表出错 → `failed`。 +- **停止**:正在跑的那条查询由数据库按单条时限截止(`run_query` 的 `_server_deadline`),这张表做完的项照常写进目录,没做的保留旧结论;后面的表 `cancelled`。 +- 表助手的只读查询、起草时的有效记录条件核对继续走单条查询的路径(一条查询、守卫、时限),行为不变。 + +### 4.3 范围怎么定(服务端定,界面只显示个数) + +- **需要剖析的表**:在表结构里、不是视图、有可查的项(关系候选、像码值的列、日期列),并且满足其一:从没剖析过;上次剖析没做完(partial / failed / cancelled);上次剖析之后目录里出现了新的待核实关系。 +- **全部表**:表结构里有可查项的表,不含视图。 +- 顺序:有待核实关系的在前 → 使用次数 → 表结构顺序(沿用现规则)。 +- `GET /catalog` 的每行加 `last_profile`(状态、任务、时间、发现与跳过计数)和 `profile_due`,对话框据此显示个数,表清单据此画「剖析」列。 + +### 4.4 同源排队与互斥(一条规则) + +- 同一个源:起草任务和剖析任务**排队串行**;同一种任务同时只有一个(排队或进行中),再建回 409 并说明正在跑的是哪个、到哪了。 +- 从表详情点「剖析这张表」时如果已有剖析任务在跑:409,告诉他这张表在不在那个任务里、排第几。 +- 导入目录、同步起草:同源有任一种后台任务时都是 409(`ensure_idle` 把剖析任务也算上)。 +- 运行、表助手的对话和只读查询:不受影响。 + +否决「剖析和起草并行」:业务库压力叠加;先剖析的表看不到起草新提的关系。否决「互斥直接报忙」:「起草完接着剖析」是最常见的顺序,排队让人点一次就能走开。 + +### 4.5 查询留痕 + +每张表记下发过的查询:`{at, kind, target, sql, ms, rows, outcome}`。`sql` 经 `sql_semantics.normalize_sql` 把字面量换成 `?`——IN 列表里抽样到的键值不进 AgentLab 的库;遮罩列本来就不进查询。条数受每张表的查询上限约束,大小有界。界面在逐表展开和表详情里「看发过的查询」。 + +价值:DBA 和数据负责人能回答「这次剖析对业务库查了什么」;出错、超时时能对着具体查询判断原因。 + +### 4.6 设置迁移 + +- 保留:`enabled`、`query_timeout_s`、`sample_size`、`max_scan_rows`。 +- 新增:`table_max_queries`(默认 40,1–200)、`table_max_s`(默认 60,10–600)。 +- 去掉:`max_queries`、`max_total_s`。启动时一次性迁移,把库里数据源 options 中这两个键删掉、记日志。不把旧值折算成新值:旧值按「整次剖析」算,含义不同,硬折算才是不照实。保存时如果还带着旧键(老页面),只摘掉、不报错。 + +### 4.7 接口 + +| 接口 | 说明 | +|---|---| +| `POST /catalog/profile-jobs` | `{tables?: string[], scope?: "needed"\|"all"}`,202;未开启、停用、没有表结构、快照被改动、正忙 409(沿用机读码) | +| `GET /catalog/profile-jobs/active` / `latest` / `{id}` | 任务和逐表状态 | +| `POST /catalog/profile-jobs/{id}/cancel` | 停止;排队的直接取消 | +| `POST /catalog/profile-jobs/{id}/retry` | 新任务,只含 failed / partial / cancelled 的表 | +| `GET /catalog` | 每行加 `last_profile`、`profile_due`;`active_job` 旁边加 `active_profile_job` | +| `GET /catalog/{table}` | 加 `last_profile`(完整的逐表结果,含发过的查询) | +| ~~`POST /catalog/profile`~~ | 去掉(见 §6 问题 3) | + +### 4.8 保留多久 + +每个源保留最近 50 个结束了的剖析任务;更早的删掉,**但某张表最近一次剖析所在的任务不删**——表详情的「上次剖析」永远有。起草任务的保留不在这次范围内。 + +## 5. 否决的备选 + +| 备选 | 为什么不 | +|---|---| +| 同步接口改流式(SSE)推进度 | 关页面就断;不留痕;不能排队;停止要另做 | +| 前端循环逐表调同步接口 | 最省事,但关页面就停、两个标签页会抢、停止不干净——起草以前「每 12 张表一个请求」就是这条弯路 | +| 保留整次总量上限当主预算,只是调大 | 正是现在「只做了几张」的根源;调大又失去对单张表的约束 | +| 一张表内多条查询并发 | 更快,但违背「少量只读查询、一条一条发」的承诺 | +| 剖析结果写进目录的表级字段 | 行数、日期范围是观测,不是要人确认的事实;写进目录会抬版本、引发冲突、被导出导入带走 | + +## 6. 已拍板(2026-10-14,都按推荐) + +1. **整次任务要不要总量上限**。推荐**不设**:每张表有上限、任务级止损(连续失败、超时比例)、可随时停止,和起草「不设总时长上限」一致。备选:设一个默认的总查询数上限(如 2,000 条),到了就停、剩下的表记已停止。 +2. **起草和剖析同源**:推荐**排队串行**(§4.4)。备选:互斥直接报忙。 +3. **同步接口 `POST /catalog/profile`**:推荐**去掉**(前端是唯一调用方,留着要多维护一套互斥)。备选:保留。 +4. **查询留痕**:推荐**记**(字面量去掉,§4.5)。备选:不记,只记条数。 + +## 7. 项目:交付与验证 + +**做法**:集成分支 `feat/catalog-profile-jobs`(从 main `fc002bd`);workflow 两个完整代理按同一份接口约定并行(后端:运行时抽取 + 剖析任务 + 引擎改编排 + 接口 + 设置迁移;前端:对话框、头部入口、表清单列、表详情一节、设置、检查脚本),一个统一复核,各自修;我合并、跑全量关口、真实验收,合 main 前问你。 + +**后端测试(虚构夹具)** +- 抽取:现有起草任务测试原样全过。 +- 逐表预算:A 表用完自己的次数记 partial,B 表照样拿满额。 +- 止损:连续 3 条失败任务停、其余 cancelled 带原因;超时比例触发 `slow`。 +- 停止:做完的项写进目录,没做的保留旧结论;重启标中断不续跑;重试只带没做完的表。 +- 排队:起草在跑时剖析排队,起草结束自动开始;停掉排队的;同种任务 409;导入、同步起草在剖析时 409。 +- 范围:需要剖析 / 全部 / 已选;空表 1 条查询记 skipped;视图跳过;`profile_due` 的三种情形。 +- 留痕:IN 列表的值不出现在 `query_log`;遮罩列不出现在任何查询。 +- 设置迁移:旧键启动时删掉;老页面带旧键保存不报错;表助手和有效记录条件核对行为不变。 +- 保留:超过 50 个时删旧的,但不删某张表的最近一次。 + +**前端**:新检查脚本 `check-catalog-profile.mjs`(假接口 + 夹具):开始前的范围与个数、排队提示、进行中逐表行与检查项进度、停止、结束后的筛选与展开、重试、表清单列、表详情一节、设置两项新字段;同时把 `check-catalog` 里旧的剖析一节删掉(那一节并行时偶发点击超时,顺带解决)。check-all 变成 30 项。 + +**真实验收**(数据副本,**副本里 Oracle 源的剖析关掉**,不碰你的真实业务库):两个虚构零售 SQLite 源开剖析(一个带大表、视图、空表、遮罩列)。逐表进度对得上;中途停止不丢已写的;杀进程重启 → 中断 → 重试补齐;起草 + 剖析排队;拿你数据副本里那个带旧设置键的源验证迁移(只看设置,不发查询)。 + +**规模**:和起草可靠性那一轮相当(后端约 1,500 行含测试,前端约 1,000 行)。 + +**部署**:只加表、迁移是加法,升级步骤不变。10.0.0.11 还没重新部署:如果不急,等这一轮合入后一次部署到位;急的话按上次的清单先部署,这一轮之后再升级一次。 + +## 8. 不在这次范围 + +> **已在下一轮做掉(2026-10,next-round/00-总方案.md 第 7 节)**:剖析结果进 look 表详情(遗留 e、目录 A2,第 2 波);定时剖析改成数据源的定时维护(目录 B4:一条目标为数据源的定时任务,第 3 波接口、第 4 波界面);列级取值漂移改成取值观察(目录 B2,第 3 波)。下面的原文保留作对照。 + +- 剖析结果喂给表助手和对话助手(如「这张表约 120 万行,下单日 2023-01 至 2026-10」)——有价值,等这一轮和助手按需查看(docs/design/copilot-look-and-caliber-ledger.md)都落地后,放进 look 的表详情(`data/schema_view.py`)里做,不另走一条路;遮罩照 look 的 D6。 +- 定时剖析(按周重新剖析需要的表)——可以接到已有的定时任务上,单独一轮。 +- 列级取值漂移检测(码值多了新取值)——依赖留痕的历史,下一步。 diff --git a/docs/design/copilot-look-and-caliber-ledger.md b/docs/design/copilot-look-and-caliber-ledger.md new file mode 100644 index 00000000..ce16ad1a --- /dev/null +++ b/docs/design/copilot-look-and-caliber-ledger.md @@ -0,0 +1,897 @@ +# 助手按需查看与口径账本 + +> 设计文档 · 草案,待拍板 · 2026-10-14 +> +> - 本文档只定方案,不含代码改动。 +> - 文中的测量来自本机开发环境(开发者自测数据),只读接口取得。数据源名称一律匿名为「库 A」到「库 G」,示例里的表名(orders、refunds、stores 等)都是虚构的。 +> - 文中数字来自本机开发环境,按项目约定(真实环境的计数不进公开仓库)不宜原样提交;要提交时先把这些数换成概括的说法。 +> - 代码位置按 2026-10-14 的 main(分支 feat/report-html-export 上看到的版本)标注,实施前需要按最新 main 复核。 + +## 目录 + +- 摘要 +- 第一部分 问题与根因 +- 第二部分 近期方案:助手按需查看 +- 第三部分 远期设想:口径账本 +- 第四部分 待拍板 +- 附录 A 代码位置索引 · B 术语 · C 参考资料 · D 测量方法 + +--- + +## 摘要 + +**问题。** 画布助手和问数据用的是同一个助手。每一轮开始前,服务端按库的大小把表结构塞进 system 提示词,模型自己判断不了要不要看。用户只说一句「你好」,助手也会先花 2.7 秒挑表,再带着 57 张表的字段、206 个表名(和数据表有关的内容约 1.5 万字)去生成。 + +**根因。** 助手的主模型只能一行一行输出操作,没有任何查看表结构的手段。服务端只好替它猜要看什么,在它开口之前全部塞进去。「大库先挑表」「小库全给」「挑表失败退回全部表名」都是在这个前提下打的补丁。 + +**近期方案(第二部分)。** 让助手按需查看: + +- 默认只给数据源概览,每个库一行。 +- 模型需要看表时输出一行 `look`。服务端读到这一行就停下,一次给齐挑表结果、全部字段、数据目录、连接条件和补连接用的中间表,然后让模型接着写。 +- 闲聊只调一次模型;数据类请求一次查看到位。 +- 一个迭代内做完,不分期,也不先上临时补丁。 + +**远期设想(第三部分)。** 问数和工作流不合并界面,而是合在「口径」上: + +- 工作流发布、治理之后就是带版本的口径;问数时,用这些口径和 agent 现场拼出要跑的图。 +- 判断一个问题能不能用某份口径回答,靠证明两条 SQL 等价,不靠问题像不像;不等价时自动对账,并给每个数字标明它来自哪份口径。 +- 口径变了,把已经给出去的答案追回来。 + +其中四件事,这次检索的公开资料里没有查到先例。 + +**需要拍板的事项**见第四部分:近期 4 项,远期 2 项。 + +--- + +## 第一部分 问题与根因 + +### 1.1 现象 + +在问数据页说「你好」,过程里出现: + +``` +已完成 7.8 s +过程 2 步 +参考了 57 张表的字段 +「库 A」「库 B」未能按需求挑选,另有 206 张表只提供了表名 2.7 s +``` + +模型最后只回了一句问候。 + +### 1.2 复现与测量 + +本机后端接了 7 个启用的数据源,按现在的规则分成两类: + +| 数据源 | 类型 | 表数 | 现在怎么给 | 「你好」这一轮实际给了多少 | +|---|---|---|---|---| +| 库 A | Oracle | 53 | 超过 40 张,先挑表;挑表返回空,退回只列表名 | 表名约 1,400 字 | +| 库 B | MySQL | 153 | 同上 | 表名约 2,800 字 | +| 库 C | SQLite | 11 | 摘要放得下,全部给 | 摘要 1,078 字 | +| 库 D | SQLite | 3 | 同上 | 183 字 | +| 库 E | MySQL | 39 | 同上 | 3,155 字 | +| 库 F | SQLite | 1 | 同上 | 126 字 | +| 库 G | SQLite | 3 | 同上 | 434 字 | + +界面上的数字就是这样来的:57 = 11 + 3 + 39 + 1 + 3;206 = 53 + 153。 + +「你好」这一轮 system 提示词的构成: + +| 内容 | 字数 | 说明 | +|---|---|---| +| 节点手册 `NODE_REFERENCE` | 11,408 | 每轮都带,不在本次范围 | +| 输出协议 `_STREAM_PROTOCOL` | 2,231 | 同上 | +| 小库摘要(库 C 到库 G) | 4,976 | 和这句话无关 | +| 小库的数据目录和连接条件 | 最多 6,000 | 库 C、E、G 共 52 张表有目录,共用一份 6,000 字上限,基本用满 | +| 大库的全部表名(库 A、B) | 约 4,200 | 和这句话无关 | +| 工具清单(每个库列前 12 个表名),加上写 SQL 的硬性要求(534 字) | 约 2,000 | 和这句话无关 | + +和数据表有关的内容约 1.5 万字,占 system 提示词将近一半。 + +部署实例只接了库 B 一个库,每一轮都会先挑表,问题一样。测量方法见附录 D。 + +### 1.3 这一轮在代码里怎么走 + +以流式生成(`/generate-stream`,`copilot.py:1800`)为例: + +1. **定每个库怎么给。** `plan_context`(`copilot_context.py:279`)读各库的数据目录,`_modes`(`:223`)只按库的大小定: + - 表超过 40 张的库走「挑表」; + - 其余的库按详细摘要从短到长,放进 6,000 字的预算:放得下的「全部给」,放不下的也去挑表。 + - 这一步完全不看这一轮要做什么。 +2. **准备挑表。** `_picker` 拿挑表用的模型:同一个助手模型,关掉思考,输出额度 1,024。`_pick_need`(`copilot.py:111`)把这一轮的话,和这段对话前 3 轮的问题拼在一起,作为挑表的需求。 +3. **挑表。** 开流之后先跑 `plan.resolve`(`copilot.py:1879`),期间发心跳。挑表提示词要求「需求和数据库无关(闲聊、只改提示词或流程结构)时返回空列表」(`copilot_context.py:118`)。模型照做返回了空,`resolve` 却把它记成失败(`:427`「模型未挑出任何表」),走 `fallback`。 +4. **组装。** `_assemble`(`:451`):`all` 的库给全部表,`fallback` 的库只列表名。 +5. **拼数据源一节。** `section()`(`:599`): + - `all` 的库 → `_detail_block`:摘要、连接条件、数据目录; + - `fallback` 的库 → 只列表名,再补一句「写 SQL 前用 db_schema 确认字段」。 +6. **拼 system。** `system_for(context)`(`copilot.py:1816`)= 节点手册 + 工具清单(`_tool_catalog`,`:75`,每个库列前 12 个表名)+ 上面那一节 + 结构要求 + 输出协议。这一份一直用到自查修正结束。 +7. **通知界面。** `context.op()`(`copilot_context.py:570`)发给前端,界面显示「参考了 N 张表」。 +8. **生成。** 主模型由 `_iter_ops`(`copilot.py:1090`)读流式文本、按行解析成操作,没有绑定任何工具。 + +### 1.4 根因 + +| 编号 | 根因 | 位置 | 后果 | +|---|---|---|---| +| R1 | 主模型没有查看手段,只能被喂 | `copilot.py:1090` | 服务端只能事先猜、事先塞 | +| R2 | 给多少只按库的大小定,不看这一轮要做什么 | `copilot_context.py:223` | 闲聊也带着小库的全部摘要 | +| R3 | 挑表说「不需要」,被当成失败 | `copilot_context.py:118` 与 `:427` | 退回列出全部表名,反而给得更多 | +| R4 | 挑表在主模型之前串行跑,只要有大库就跑 | `copilot.py:1864`–`1891` | 每轮多 2–3 秒 | + +R3 还有一个旁证:前端为「挑了、但一张都不需要」准备好的说法(`decode.ts:3086` 的 `case 'model'` 分支,0 张表时显示「未挑中与需求相关的表」),后端从来没发过这种情况。 + +另外五个发现: + +- **F1 小库给的不是全部字段。** + - 详细摘要每张表只列前 6 列(`introspect.py:30` `_PREVIEW_COLUMNS = 6`),后面写「等 N 字段」;界面却写「参考了 57 张表的字段」「全部 N 张表」。 + - 大库里挑中的表反而给全部字段。结果是闲聊时小库给得太多,真问小库的数据时又给得不够。 +- **F2 在对话里接着说话时,挑表看的是前几轮。** `_pick_need` 会拼上前 3 轮的问题。在一段问过数据的对话里说「谢谢」,挑表模型照样按之前的问题挑表、给全部字段。 +- **F3 问数据时,助手自己根本不写 SQL。** + - `copilot.py:371` 起的约定:问数据时搭「输入 → agent → 报告 → 输出」。写 SQL 的是运行时的 agent 节点,它自己绑了 `db_schema__`(`tools/datasource.py:647`)。 + - 所以在这条路上,提前给助手的字段基本用不上。 +- **F4 事后查不到模型当时看了什么。** `context` 操作只用于界面显示(`studio.ts:1896`),对话记录里没有;挑表失败的原因只写在后端日志。 +- **F5 改一处要动四处。** + - 四个生成入口共用 `_datasource_context`(`copilot.py:134`):`/generate`(`:730`,界面已不调用,只在检查脚本里探测)、`/generate-stream`(`:1800`)、发布前修复 `assist_publish_fix`(`:2114`)、升级证据 `assist_upgrade`(`:2296`)。 + - `copilot.py` 有 2,731 行。 + +### 1.5 来历 + +- 2026-10-01 评估语义层时拆过两块工作:「按任务挑表」估 1–2 天,「助手建图前先探查」估 3–5 天。 +- 2026-10-03 数据目录阶段 1B(`e74097a`)做了前一块。当时要解决的问题是:大库只列表名,模型照着表名编字段,跑到查库那一步才报「no such column」。 +- 之后又一层层补上去: + - 补连接用的中间表; + - 现有工作流用到的表给全部字段; + - 表没探查全时的提示(`52aaaaf`); + - 库没有数据目录时提示先起草(`546d0e9`)。 +- 每一层都在回答「服务端怎么猜得更准」,没有哪一层在问「模型这一轮到底要不要看」。 +- 当初的痛点,现在已经有两层兜底: + - 搭完自查的 `sql_unknown_column`(`copilot.py:1327`)会把那几张表的真实列交回去让模型改; + - 运行时的 agent 会自己查表结构。 + +### 1.6 助手在系统里的位置 + +| 模型 | 位置 | 能不能自己查 | 表结构怎么来 | +|---|---|---|---| +| 运行时的 agent 节点 | `engine/nodes/llm.py` | 能 | 工具说明里列前 25 个表名(`tools/datasource.py:176`),字段要用时自己查 | +| 表助手(在 feat/catalog-loop 上,还没合进 main) | `data/catalog_assistant.py` | 能:原生工具调用,多轮、心跳、出错重试 | 只给这一张表 | +| **画布助手 / 问数据** | `api/copilot.py` | **不能** | 服务端事先挑好、事先塞进去 | +| 挑表、目录起草 | 单次分类调用 | — | — | + +助手是唯一一个只能「被喂」的。 + +--- + +## 第二部分 近期方案:助手按需查看 + +### 2.1 设计理念 + +方案里的每个决定都要对得上下面六条: + +| # | 理念 | 出处 | 在本方案里怎么体现 | +|---|---|---|---| +| P1 | 照实说:界面说的就是实际发生的 | 「参考了 N 张表」的文案约定 | 没查看就不出那一行;「查看了 4 张表」说的就是模型看到的那 4 张 | +| P2 | 留痕:模型看到了什么能查到 | 运行侧的表结构快照、查询快照、封存 | 每次查看记一条,进对话记录,也进下一轮的历史 | +| P3 | 不挡生成:辅助步骤失败不能让这一轮失败 | 挑表的约定 | 查看失败时交回表目录,由模型接手 | +| P4 | 只读、遮罩:不碰业务库,模型看不到遮罩值 | K3 修复 | 查看只读表结构缓存和数据目录;遮罩列不给值 | +| P5 | 同一张表只有一种说法 | db_schema、`/schema` 接口、助手 | 三处共用同一份表详情 | +| P6 | 模板定骨架、agent 填关节 | 项目路线 | 问数据时查库交给运行时 agent,助手只在需要时看 | + +同时守住路线里不可回退的决定:叙述层不做算术;没发布的图只能跑探索运行;正式运行只能从发布的版本发起。 + +### 2.2 目标与不做的事 + +目标: + +1. 默认不展开任何表,要不要看由主模型判断。 +2. 要看时一次拿齐:挑表结果、全部字段、数据目录、连接条件、补连接用的中间表。 +3. 闲聊、改结构只调一次模型;数据类请求一次查看到位,异常情况最多三次。 +4. 一个迭代内做完,不先上临时补丁。 + +不在这次范围内: + +- 运行时 agent 工具说明里的 25 个表名:它本来就是自己判断要不要查。 +- 向量检索。 +- 显式提示缓存:项目里目前没有用,另议。 +- 节点手册瘦身:约 1.1 万字,每轮都带,另议。 +- 数据目录、表助手本身。 + +### 2.3 整体流程 + +``` +用户一句话 + │ + ▼ +system:节点手册 + 输出协议 + 数据源概览(每个库一行)+ 什么时候该看 + │ + ▼ +主模型(流式输出,一行一个操作) + ├─ 不需要数据 ──► reply 或改图操作 … done ──► 自查 ──► 结束(只调一次模型) + │ + └─ 需要 ──► {"op":"look", "need": …, "tables": …, "sources": …} + │ 服务端读到这一行就停止本次输出,这一行之后的内容丢弃 + ▼ + 查看服务 + 点名的表:直接取,不调挑表模型 + 只给了需求:候选不超过 12 张就全给;否则用挑表模型按需求挑 + 补连接用的中间表;遮罩;排成文字;记一条记录 + │ + ├──► 发给前端:look 事件(过程里显示一行) + ▼ + 把结果作为下一条消息交回 ──► 主模型接着输出(已经落地的操作不重复) + … 还可以再 look(每轮最多 3 次)… done + │ + ▼ +自查:修正请求里带上这一轮的查看结果;修正时可以再 look 1 次 + │ + ▼ +前端把查看记录存进这一轮的 meta.looks;下一轮的历史里带上「上一轮查看过的表」 +``` + +### 2.4 关键决定 + +#### D1 机制:在逐行输出的操作里加一种 `look` + +- **决定**:模型要看表时输出一行 `{"op":"look",…}`。服务端读到这一行就停止本次输出,执行查看,把结果作为下一条消息交回,再发一次请求让模型接着写。 +- **理由**: + - 助手本来就是一行一行输出操作,`catalog_patch`、`reply` 已经是「不改图的操作」,加 `look` 不引入新的约定。`_parse_op_line`(`copilot.py:878`)不限制 op 的名字;前端遇到不认识的操作,不会报错。 + - 不依赖接入支持工具调用。`copilot_context._ask`(`:333`)的注释里记过:有的网关一调工具就报错,纯文本却能用。演示模型也能按脚本走这条路。 + - 只有一套机制,不需要再留一条「接入不支持工具调用时,退回预先塞表」的路。 +- **否决的做法**: + - 原生工具调用(`bind_tools`,运行时 agent 和表助手在用):要给不支持工具调用的接入和演示模型留退路,等于同时维护两套。 + - 保留预先塞表、前面加一步意图分类:还是替模型判断。 +- **代价**:查看之后要重新发一次请求,模型的思考接不上。走 OpenAI 兼容接入时,原生工具调用一般也不回传思考,差别不大。 + +#### D2 一次查看拿齐 + +- **点名的表**:直接取,不调挑表模型。 +- **只给了需求**: + - 候选库的表加起来不超过 12 张(`PICK_LIMIT`),就全给; + - 否则用现有的挑表步骤(`PICK_SYSTEM`,关思考,20 秒时限)按需求挑。 + - 需求由主模型来写,不再把前 3 轮问题拼在一起。 +- **中间表**:沿关系图补上两跳以内的中间表(沿用 `_complete`,`copilot_context.py:493`),一次合计不超过 16 张(`CONTEXT_TABLE_LIMIT`)。 +- **挑表失败**(超时、报错、回复认不出、挑出的表都不存在):交回候选库的表目录,每张表一行「表名|中文名|粒度」(沿用 `catalog.render_table_index`),写明失败原因,请模型点名再看。 +- **挑表返回空**:如实交回「按这个需求没有挑出相关的表」,附上表目录。 +- **字数上限**:每次结果有上限(暂定 12,000 字)。超出部分的表只给字段名,并写明这一点。 +- **重复查看同一批表**:直接回「这些表已在上面」,不再重新排一遍。 +- **次数上限**:每轮最多查看 3 次,自查修正时另给 1 次。用完之后再 look,回「查看次数已用完,按已查看的结构完成」。 + +#### D3 默认只给数据源概览 + +- 每个库一行,格式见 2.5.1。 +- 现有工作流用到的表(`graph_tables`,`copilot_context.py:249`)只列表名,注明「来自现有工作流」。 +- 工具清单里不再列表名,只写「共 N 张表,用 look 查看」。 +- 写 SQL 的硬性要求(534 字)留在 system 里:它短而且固定,改已有 SQL 时也要用。 +- 效果:「你好」那一轮,和数据表有关的内容从约 1.5 万字降到 1 千字以内。 + +#### D4 什么时候该看(写给模型的判断标准) + +| 场景 | 怎么做 | +|---|---| +| 闲聊、解释、改提示词、改流程结构、改不碰 SQL 的节点 | 不看 | +| 问数据 | 查库交给 agent 节点,它运行时会自己查结构。下面三种情况才看一次:从概览看不出该用哪个库;用户点了表名;用户说了口径。看到的表名写进 agent 的提示词,运行时少走弯路 | +| 直接写 SQL 的取数节点、修改已有的 SQL | 动笔前看,一次把要用的表都点上 | +| 用户说了一条数据事实 | 先看那张表的现有目录,再提目录修改 | + +另外规定: + +- 要看就先看,看完再规划,不要先搭节点再回头看。 +- 这一段对话里已经查看过的表,直接点名看,不要只给需求。 + +#### D5 不需要人参与的路径,由系统代为查看 + +- **发布前修复**(`assist_publish_fix`):要修的图里,调用工具节点的 SQL 用到的表(`graph_tables(base)`)。 +- **升级证据**(`assist_upgrade`):要升级的取数节点用到的表。 +- **做法**:服务端用同一个查看服务点名这些表,以「系统查看」的身份放进对话。记录格式和排出来的文字,与模型主动查看的完全一样。模型仍然可以再看。 +- **理由**:这两条路径没有人在等,而且要查哪些表是确定的,系统直接查能省掉一轮。 + +#### D6 同一种说法,同一套遮罩规矩 + +- 在 `data/` 下新增一个只读的表结构视图(暂名 `schema_view.py`),提供三样东西: + - 库概览; + - 表目录; + - 表详情:字段、数据目录、连接条件、两跳的连接路径。 +- 助手查看、运行时的 `db_schema__`、`/schema` 接口,共用同一份表详情。 +- 遮罩规矩:遮罩列只给列名和类型。它的注释、目录里的码值、带具体取值的备注都不给。这和表助手是同一条规矩;哪些列是遮罩列,以 `masked_columns(source.options)`(`data/engine.py:370`)为准。 +- 这样也顺手补上一个现有缺口:现在 `db_schema` 和助手都会把遮罩列的目录原样交给模型。 +- **代价**:运行时 agent 看到的目录会少一些(遮罩列那部分),要在 README 里写明。 + +#### D7 留痕 + +- 记录字段见 2.5.7、2.5.8。 +- 记录去三个地方: + - 实时发给前端; + - 前端存进这一轮的 `meta.looks`。沿用 meta「前端写、前端读」的做法(`db/models.py` 里 ConversationTurn 的 `meta`);这次多了一个服务端的读者; + - 下一轮 `_history_section`(`copilot.py:459`)写一行「上一轮查看过:库 B 的 orders、refunds」,追问时模型可以直接点名看,不用再挑。 + +#### D8 自查 + +- 修正请求(`_repair_request`,`copilot.py:1779`)里带上这一轮的查看结果,和起草时看到的是同一份文字。 +- 修正时可以再看 1 次,比如 `sql_unknown_column` 指向了一张没查看过的表。 +- 不新增「没查看就写 SQL」的拦截:列名写错有 `sql_unknown_column` 拦,目录里的事实有基于目录的 SQL 检查拦。再加一条,只会多一轮修正。 + +#### D9 四个入口共用一个生成过程 + +- 把生成过程抽到新模块(暂名 `api/copilot_gen.py`):起草 → 查看(停下、执行、接着写)→ 自查修正,产出一串操作。 +- `/generate-stream` 把它转成 SSE;`/generate`、`assist_publish_fix`、`assist_upgrade` 只收结果。 +- `copilot.py` 只抽出生成过程和数据访问两块;节点手册、解释、排版、复核不动。测试里 import 的名字保留,从新模块再导出。 + +### 2.5 规格 + +#### 2.5.1 数据源概览(system 里每个库一行) + +格式示例(虚构): + +``` +已接入的数据源(要看表结构时用 look,见下): +- 库 B(MySQL,只读):订单业务库(示例说明)|共 153 张表,都有数据目录 +- 库 C(SQLite,只读):未填说明|共 11 张表,10 张有数据目录 +- 库 H(PostgreSQL):未填说明|共 205 张表,只探查了 200 张 +- 库 I(MySQL):未填说明|结构探查失败:连接超时 +- 库 J(SQLite,只读):未填说明|设了遮罩 +现有工作流用到的表:库 B 的 orders、refunds(字段要用时 look) +``` + +每行包含:名称、数据库类型、是否只读、说明、表数(没探查全时写明)、有数据目录的表数、有没有设遮罩、没探查或探查失败的原因(沿用 `introspect.why_empty`)。 + +#### 2.5.2 look 行(模型输出) + +```json +{"op":"look","need":"按门店统计 9 月退款金额,要订单、退款和门店维度","tables":[{"source":"库 B","table":"refunds"}],"sources":["库 B"]} +``` + +| 字段 | 必填 | 说明 | +|---|---|---| +| `need` | 是 | 要查什么,一句话。用作挑表的需求,也写进记录和界面 | +| `tables` | 否 | 点名要看的表。只有一个候选库时可以不写 `source`;`table` 认全名、只写表名、大小写不同这几种写法(沿用 `catalog.resolve_table_name`) | +| `sources` | 否 | 限定在哪些库里找。不写时为全部候选库;无论写不写,都只在用户限定的范围内 | + +规则: + +- 给了 `tables` 时只取点名的表(加上补连接用的中间表),不调挑表模型;没给 `tables` 时按 `need` 挑表。 +- `need` 为空:回「look 要写 need」,不算次数。 +- 库名不存在或不在用户限定的范围:记为没找到,在结果里写明。 +- `tables` 超过 16 张:只取前 16 张,并写明。 + +#### 2.5.3 查看服务的处理顺序 + +``` +look(need, tables, sources, by): + 候选库 = 用户限定的范围 ∩ sources(不写 sources 时为范围内全部库) + 如果给了 tables: + 选中 = 逐个解析 tables,解析不了的记入「没找到」 + 否则: + 表池 = 候选库里的全部表 + 如果表池不超过 PICK_LIMIT 张:选中 = 表池 + 否则: + 选中 = 挑表模型(need, 候选库的表目录) + 挑表失败或返回空 → 返回「表目录 + 原因」,status = index + 给出的表 = 选中 + 两跳以内的中间表(合计不超过 CONTEXT_TABLE_LIMIT) + 文字 = 表详情(遮罩后)+ 连接条件 + 连接路径;超过 LOOK_RESULT_CHARS 的部分只给字段名 + 记录 = {id, by, need, status, 各库的 named / picked / joined / missing, 表结构同步时间, 目录版本, 用时, 原因} + 返回 (文字, 记录) +``` + +#### 2.5.4 交回给模型的结果(示例,虚构) + +``` +查看结果(第 1 次,这一轮最多 3 次) +需求:按门店统计 9 月退款金额,要订单、退款和门店维度 + +库 B(MySQL):按需求从 153 张表中挑出 3 张,另为连接补上 1 张 + · refunds[表]:refund_id、order_id、refund_amount、refund_time、status …(全部 14 列) + 数据目录:粒度 一行一笔退款;refund_amount 单位 元;status 码值 1=成功 2=失败;有效记录条件 status = 1 + · orders[表]:… + · stores[表]:… + · order_items[表](为连接补上):… + 表之间的连接条件(写 JOIN … ON … 时照抄): + refunds.order_id = orders.order_id(多对一) + orders.store_id = stores.store_id(多对一) + +没找到:库 B 的 refund_detail(表结构里没有这张表,可能没探查到;别拿名字相近的表顶替) + +接着输出操作。已经输出、已经落地的操作不要重复。 +``` + +#### 2.5.5 接着写时的消息结构 + +``` +[system] 和起草时相同,不变 +[human] 用户这一轮的请求(含历史) +[assistant] 模型到 look 那一行为止的输出(look 之后的内容已丢弃) +[human] 查看结果 + 「接着输出操作,已经落地的不要重复」+ 已落地的节点 id 列表 +``` + +去重规则,以 `_apply_op`(`copilot.py:1004`)现在的行为为基础: + +- `add_edge` 现在会直接追加,不查重。接着写时,和已有连线完全相同的 `add_edge` 要忽略,也不转发给前端。 +- 同 id 的 `add_node` 现在会整体覆盖。接着写时保持这个行为,但和已落地节点完全相同的不转发。 +- `plan` 操作接着写时只保留第一次的。 + +#### 2.5.6 上限与常量 + +| 常量 | 值 | 说明 | +|---|---|---| +| `LOOK_LIMIT` | 3 | 起草阶段每轮最多查看几次 | +| `REPAIR_LOOK_LIMIT` | 1 | 自查修正阶段另给几次 | +| `PICK_LIMIT` | 12(沿用) | 挑表最多挑几张;候选表不超过这个数时不挑、全给 | +| `CONTEXT_TABLE_LIMIT` | 16(沿用) | 一次查看最多给几张表的详情(含中间表) | +| `PICK_TIMEOUT_S` | 20(沿用) | 挑表时限 | +| `LOOK_RESULT_CHARS` | 12,000(新,暂定) | 一次查看结果的字数上限 | + +#### 2.5.7 发给前端的 look 事件 + +```json +{"op":"look","id":"L1","by":"model","need":"…","status":"ok","elapsed_ms":2310, + "sources":[{"source":"库 B","source_id":"…","named":[],"picked":["refunds","orders","stores"], + "joined":["order_items"],"missing":["refund_detail"],"total":153, + "catalog_tables":153,"schema_synced_at":"…","catalog_versions":{"refunds":7}}]} +``` + +| 字段 | 说明 | +|---|---| +| `id` | 这一轮内的编号:L1、L2… | +| `by` | `model`:模型发起;`system`:系统代为查看 | +| `status` | `ok`:给了表详情;`index`:挑表失败或返回空,交回了表目录;`failed`:查看本身出错 | +| `sources[].named / picked / joined` | 点名的、按需求挑出的、为连接补上的(全名) | +| `sources[].missing` | 点了名但没找到的 | +| `total` / `explored` | 这个库的表数;探查截断时多带 `explored` | +| `catalog_tables` | 有数据目录的表数。为 0 时界面提示先去起草(沿用现有入口) | +| `schema_synced_at` / `catalog_versions` | 表结构的同步时间、给出的各表目录版本(留痕用) | +| `reason` | `status` 不是 `ok` 时的原因 | +| `elapsed_ms` | 这次查看用时,含挑表 | + +#### 2.5.8 查看记录 + +- 前端把每个 look 事件原样存进这一轮的 `meta.looks`。 +- 下一轮 `_history_section` 读 `meta.looks`,写一行:「上一轮查看过:库 B 的 refunds、orders、stores」。 +- 系统查看(`by: system`)照样记录。 + +### 2.6 界面 + +| 情况 | 过程里显示 | +|---|---| +| 没查看 | 不出这一行 | +| 模型查看 | 标题「查看了 4 张表」,副标题「库 B · 按需求挑选」,带用时。展开后每个库一行:点名的、按需求挑出的、为连接补上的;另外写没找到的表;库没有数据目录时,给出去起草的入口 | +| 挑表失败 | 标题「没挑出来,已把表目录交给助手」,展开写原因;后面通常紧跟一行点名查看 | +| 系统查看 | 「按要修的 SQL 查看了 3 张表」 | +| 多次查看 | 每次一行,按先后排 | + +- 删掉这三条文案(`terms.ts`、`decode.ts`):「参考了 N 张表的字段」「另有 N 张表只提供了表名」「未能按需求挑选,已提供全部表名」。 +- 浮条:查看进行中显示「正在查看表结构…」,结束后显示标题。 +- 新旧版本混用: + - 新前端遇到老后端的 `context` 操作,照常显示(保留解码); + - 老前端遇到 `look` 事件,不处理(`studio.ts` 对不认识的操作不做任何事)。 + +### 2.7 改动范围 + +**后端** + +| 文件 | 改动 | +|---|---| +| `data/schema_view.py`(新) | 库概览、表目录、表详情(含遮罩)。从 `copilot_context`、`introspect.describe_table`、`catalog.render_table_notes` 里抽出来 | +| `api/copilot_context.py` | 改写成「概览 + 查看服务 + 记录」。挑表变成查看里的一步;删掉 `_modes`、`section` 里按 `all` / `fallback` 组装的部分,以及 `listed` | +| `api/copilot_gen.py`(新) | 生成过程:起草;读到 look 停下、执行、接着写;次数上限;去重;自查修正 | +| `api/copilot.py` | 四个入口改用生成过程;改 `system_for`;`_tool_catalog` 不列表名;删掉 `_pick_need`;`_history_section` 加上一轮查看过的表;`_repair_request` 带上查看结果;`_STREAM_PROTOCOL` 加 `look` | +| `tools/datasource.py` | `db_schema__` 改用共用的表详情 | +| `api/datasources.py` | `/schema?table=` 改用共用的表详情 | +| `providers/mock_model.py` | 如果需要,演示模型按脚本走一次 look | + +**前端** + +| 文件 | 改动 | +|---|---| +| `run/decode.ts` | look 事件翻成过程里的步骤;保留 `context` 的解码 | +| `store/studio.ts` | look 的浮条;写 `meta.looks` | +| `lib/terms.ts` | 加新文案,删旧文案 | +| `run/__tests__/synthetic.ts`、`dev/preview.tsx` | 样例换成 look | +| `scripts/check-chat.mjs` 等 | 桩数据里的 `context` 换成 look | + +**文档**:README 里助手一节;`copilot_context.py` 的模块说明重写。 + +**测试** + +- 重写:`test_copilot_table_pick.py`(15 个用例),改成查看服务的用例。 +- 更新:`test_catalog_impact.py`、`test_catalog_schema_truncated.py`、`test_copilot_sql_alias.py`。 +- 新增: + - look 行的解析和校验。 + - 查看服务:点名、按需求挑、候选不超过 12 张时全给、挑表失败交回表目录、挑表返回空、没找到、探查截断、用户限定范围、遮罩、字数上限、重复查看。 + - 生成过程:读到 look 就停、接着写时的消息结构、已落地的操作不重复、次数上限、输出额度截断、演示模型。 + - 四个入口,包括系统查看。 + - 历史里的「上一轮查看过」。 + - 前端:decode 出 look 步骤、文案、写入 meta。 + +### 2.8 验收 + +| 场景 | 现在 | 目标 | +|---|---|---| +| 「你好」 | 挑表 + 生成,2 次调用,7.8 秒;和数据表有关的内容约 1.5 万字 | 1 次调用;没有查看那一行;和数据表有关的内容不到 1 千字 | +| 问数据(库 B) | 挑表 + 生成 | 最多 1 次查看,挑对表;或者不看,直接交给 agent | +| 画布上写周报 SQL | 挑表 + 生成 | 1 次查看;自查不报 `sql_unknown_column` | +| 追问「那上个月呢」 | 拼上前 3 轮问题重新挑表 | 不看,或者点名查看(不调挑表模型) | +| 在问过数据的对话里说「谢谢」 | 照样挑表、给全部字段 | 不看 | +| 说一条数据事实 | — | 查看那一张表之后提目录修改 | +| 挑表失败(模拟超时) | 206 个表名塞进 system | 交回表目录,模型点名再看,查看不超过 2 次 | +| 遮罩列 | 目录原样交给模型 | 只给列名和类型 | +| 发布前修复、升级证据 | 挑表 + 生成 | 系统查看 + 生成,不调挑表模型 | + +- **每个场景要记录**:模型调用次数、查看次数、system 字数、第一个操作出来的时间、总用时、自查修正轮数。 +- **真实模型**:本机配置的助手模型,加上之前验收用过的 deepseek-v4.1-flash 和 glm-5.3。数据用开发库的副本,放在 /tmp,真实名字不进仓库;复制数据目录需要你放行。 +- **关口**:后端全量测试、tsc、文案检查、泄露扫描、check-all(decode、stream、ui、chat)。 + +### 2.9 实施组织 + +- **前提**:先把 feat/catalog-loop 合进 main。遮罩规矩和表助手都在那个分支上,先合进来,就不会两边各写一套。之后基于最新的 main 开集成分支。 +- **方式**:一个 workflow,两个完整的代理并行: + - 后端包、前端包,按 2.5 的接口约定开发; + - 然后统一复核,各自修; + - 最后由编排者合并、跑全量关口、做真实模型验收,合 main 之前问你。 +- 不先上临时补丁。 + +### 2.10 风险与对策 + +| 风险 | 对策 | +|---|---| +| 数据类请求多一次主模型调用,慢几秒 | 验收时量出来;点名和追问不调挑表模型;问数据可以不看 | +| 模型该看的时候不看 | 自查兜底;验收统计 `sql_unknown_column` 的出现比例 | +| 模型看的次数太多 | 每轮 3 次上限,重复查看直接短路 | +| 接着写的时候重复输出已落地的操作 | 接着写的提示里列出已落地的节点;完全相同的操作忽略(2.5.5) | +| 接着写时模型要重新思考,成本变高 | 交回结果时提醒「接着写」;验收时记录总 token | +| 遮罩规矩用到 `db_schema` 后,运行时 agent 看到的目录变少 | 符合「模型看不到遮罩值」的原则,在 README 写明 | +| 新旧前后端混用 | 新前端保留 `context` 解码;老前端忽略 look | + +### 2.11 为远期预留 + +查看的对象做成可以扩展的类型:现在只有「表」,第三部分要加上「已治理的口径」。 + +look 交回的结果按对象类型分块排列。记录里的 `sources` 以后可以并列一个 `calibers`。这样接入口径时不用改协议。 + +--- + +## 第三部分 远期设想:口径账本 + +这一部分回答一个更大的问题:问数和工作流能不能合二为一。这里只提出假设,同时给出反驳和验证办法,不是实施计划。 + +### 3.1 两者的本质区别:推理和承诺 + +问数每一轮其实就是:助手搭一张图(输入 → agent → 报告 → 输出),跑一次探索运行,给出答案(`copilot.py:371` 起的约定)。工作流也是「图 + 运行」。两者在引擎上已经是一回事,区别不在「对话还是画布」,而在下面这些: + +| | 问数 | 工作流(发布、治理之后) | +|---|---|---| +| 产出 | 一次**推理**:这一次、这份数据、这个模型的判断 | 一份**承诺**:同一个口径,每期都这样算,出具前要过契约 | +| 运行类别 | 探索 | 正式(只能从发布的版本发起) | +| 生命周期 | 跟着对话走,依赖上下文 | 有版本,可以定时,升版要处置 | +| 谁在用 | 要答案的人 | 定口径、对数负责的人 | + +### 3.2 现状:两者之间的连接基本是断的 + +| 方向 | 现在的做法 | 问题 | +|---|---|---| +| 问数 → 工作流 | 「在画布里打开」:把这一轮的图复制成一张新工作流,名字是「问数据:」加问题的前 24 个字(`ChatPage.tsx:298`) | 复制之后,和原来的问题、运行没有关联 | +| 运行 → 模板 | 运行详情页的「提取模板」(`/copilot/from-run`,`copilot.py:2496`):只保留实际执行过的节点,输入改成参数,落成草稿 | 要人自己想到去点 | +| 重复的问题 → 模板 | `/governance/exploratory-clusters`(`governance.py:90`):用本地向量做贪心聚类,提示「用提取模板固定下来」 | 接口已经有了,界面上没有任何地方调用(`client.ts:638` 没有引用) | +| 工作流 → 问数 | 没有 | 问数用不上已治理的口径 | + +本机开发库的情况(开发者自测数据,不代表真实使用): + +- 33 个问数对话,共 72 轮; +- 47 个工作流,其中 12 个由「在画布里打开」复制而来;6 个已治理、2 个已发布、39 个草稿; +- 正式运行 29 次,探索运行 200 次以上。 + +### 3.3 「合二为一」的四种可能,逐个看 + +#### H1 界面合一 + +- **设想**:一个页面,左边对话、右边画布,问完就是一张可以保存的图。 +- **好处**:少一个入口;从问到存只要一步。 +- **反驳**: + - 两类人做的事不一样:要答案的人不想看图,定口径的人需要精确控制。 + - 更关键的是,界面合在一起会淡化「探索」和「正式」的边界:聊出来的数和周报里的数长得一模一样,而这正是路线前五个阶段花力气分开的东西。 + - 旁证:Dify 的 Chatflow 和 Workflow 用同一套画布,照样分成两种应用。Chatflow 有对话记忆、不能定时触发;Workflow 一次跑完、可以定时。 +- **结论**:不做。 + +#### H2 工作流吞掉问数 + +- **设想**:问数只是一个特殊的「问答工作流」,聊天页是它的前端。 +- **反驳**:每个问题需要的图都不一样。固定成一个工作流,就只剩一个什么都干的 agent,这正是路线里只留给探索层的「全动态」。每个问题的结构、证据的细致程度、能沉淀成模板的路径都会丢掉。 +- **结论**:不做。 + +#### H3 问数吞掉工作流 + +- **设想**:一切靠对话搭,画布退成只读的查看器。 +- **反驳**:发布和治理要人逐处审改(钉版本、口径卡、出具契约),对话不适合做精确的逐处审阅。去掉画布,承诺就没人签字了。 +- **可取之处**:画布可以换个定位,从「编辑器」变成「承诺工作台」,也就是把推理变成承诺、由人签字的地方。 +- **结论**:不做,但吸收它对画布的重新定位。 + +#### H4 对象合一 + +- **设想**:「问题」是唯一的对象,按「探索 → 模板 → 出具」逐步成熟。 +- **评价**:方向是对的,但业内已经有人做了: + - Hex Threads(2025-10):每段对话背后就是一个 notebook 项目,数据团队可以接手审改;发布出去的应用,又会成为之后回答的上下文。 + - Snowflake Cortex Analyst:会根据使用情况和查询历史,推荐值得认证的查询,由人审核。 +- **结论**:这一层该做,但做了只是跟上业内,不构成差异。 + +### 3.4 方案:口径账本 + +一句话:**工作流是词汇,问数是造句。** + +- 发布、治理过的工作流(连同里面的取数节点和口径卡)是带版本的口径。 +- 问数时,现场把这些口径(钉住版本的子图)和 agent 拼起来回答。 +- 每个数都记着它来自哪份口径。 +- 口径和答案之间的关系,记在一本账上。 + +#### 3.4.1 组合式问数 + +> **已修订(2026-10,next-round/00-总方案.md X12、02 做法 4、拍板账本-L3)**:不用子图做骨架,改成服务端按固定版本**确定性内联实例化**(`use_caliber`:取数节点照抄、口径卡用 `caliber_from` + `nodes` 改名映射,`app/ledger/derive.py` 是全站唯一的派生图生成器)。原因:子图的证据不交回父图,报告里的数点不到口径的取数;内联之后证据链和普通取数一样。下面的原文保留作对照。 + +- 助手搭的图 = 钉住版本、已治理的子图做骨架 + agent 填补缺口。这就是「模板定骨架、agent 填关节」用在问数上。 +- 子图节点已经能按「工作流 + 版本」调用(`engine/compare.py:123` 列出的配置项:`workflow_id`、`workflow_version`、`input`、`assign_to`)。 +- 守住不可回退的决定:拼出来的图仍然是没发布的图,整轮按探索运行算。只有当整个问题在绑定参数之后等价于某一个已治理的工作流时,才去跑那个版本的正式运行,按三档出具。 + +#### 3.4.2 认口径靠证明等价,而不是看问题像不像 + +> **已修订(2026-10,X12、02 做法 1–2)**:「字面量换成占位符、所以参数不同不算不同」改成**先绑定参数再证明**:`sql_semantics.bind_template` 从这次的 SQL 里认出口径的参数值(只当启发),按绑定渲染口径的 SQL,再由 `explain_sql_diff` 判等价(执行守门进语料)。只换占位符会把「2024 年」和「2025 年」认成同一个数;绑定之后同一个口径不同参数的答案各自有出处。出错方向仍然只会少给信任。 + +**业内的「已验证答案」是怎么认的:** + +- Databricks Genie:原样用了某条带参数的示例查询,才标为已验证。 +- Power BI:按触发短语做精确匹配或语义相似匹配。 +- Snowflake Cortex:记下这次用了哪条已验证查询。 + +**这类做法会往哪个方向出错:** + +- 错配了照样打上「已验证」。比如问题里多了「含退款」,还是被认成了已验证。 +- 资产过期了还在出数。有人记录过:trusted asset 建在 orders_v2 上,迁到 v3 之后它照样出数,而数是错的。 + +**这里的做法:** 拿这次实际执行的 SQL,和口径里的 SQL 判断是否等价。 + +- `sql_semantics.normalize_sql` 把字面量换成占位符,所以日期、门店这类参数不同不算不同。 +- `explain_sql_diff` 只有在两条 SQL 规范化之后一字不差时才判为等价,否则一律判为不等价,并给出非空的差异条目。 + +**这样出错的方向就反过来了**:会出现「本来能认的没认出来」,但只会少给信任,不会错给信任。 + +#### 3.4.3 口径对账 + +不等价时也不沉默,要说清差在哪、差多少。示例(虚构): + +> 按你的问法是 1,234,按已治理口径「周销售」v3 是 1,180。差别:这次按下单时间筛选,口径按支付时间;这次没有排除部分退款。 + +- **差在哪**:由 `explain_sql_diff` 给出。双盲复算、运行对比已经在用它。 +- **差多少**:要把口径在同一份数据上再跑一次。只读,用户点开时才跑。差值在口径卡里算,不让叙述层做算术。 + +#### 3.4.4 给每个数字标口径 + +- 现在答案里的数已经能点开看出处(`[[v:Q1.r0.列名]]`)。 +- 在这之上再加一层:这个数来自「已治理口径 vN」(等价已证明),还是「这次的推理」。 +- 业内是给整条答案或整张图打一个「已验证」。 + +#### 3.4.5 双向闭环:沉淀和召回 + +**沉淀(推理 → 承诺)** + +- 触发:同一类问题反复出现(用聚类接口发现),或者某个探索答案被反复引用、导出。 +- 流程:进认证队列 → 用现有的「从运行提取模板」提取 → 人在画布上审改、发布。 +- 模板会记住自己是从哪些问题沉淀来的。 +- 这一块业内有类似的推荐队列(Cortex),不新。 + +**召回(承诺变了 → 回头处理已经给出去的答案)** + +- 触发:口径升版、目录里的事实被驳回、表结构有变动。 +- 查找:顺着证据链反查所有建立在旧版本上的答案,包括问数的每一轮、导出的报告、定时推送。 +- 处理:在新版本上重算,只有数真的变了才通知,附上差异,可以一键重新出具。 +- 这样,路线里「口径升版是必须处置的事件」,就从正式运行延伸到了所有答案。现有的血缘工具只会通知下游报表;已经交付出去的 AI 答案,没查到有人回头追。 + +#### 3.4.6 一个问题的三条路径 + +``` +问题 + │ 助手查看:表 + 已治理的口径(look 的扩展) + ▼ +有没有一份已治理的口径,绑定参数后与整个问题等价? + ├─ 有 ──► 路径一:跑那个版本的正式运行 ──► 三档出具(正式 / 降级 / 不出具) + │ + └─ 没有 ──► 拼图:钉住版本的口径子图 + agent 填缺口 ──► 探索运行 + │ + ▼ + 跑完后,把这次实际执行的 SQL 和相关的口径比对 + ├─ 等价(字面量不算)──► 路径二a:数字标「来自口径 vN」,可一键按该口径正式出具 + ├─ 不等价但相关 ────────► 路径二b:口径对账(差在哪、差多少;点开时才算) + └─ 没有相关的口径 ──────► 路径三:纯探索,数字标「这次的推理」 +``` + +#### 3.4.7 守住的决定 + +| 决定 | 怎么守住 | +|---|---| +| 没发布的图只能跑探索运行;正式运行只能从发布的版本发起 | 拼出来的图整轮是探索运行;只有路径一跑正式运行 | +| 叙述层不做算术 | 对账的差值在口径卡里算 | +| 口径升版是必须处置的事件 | 召回把它延伸到所有已给出的答案 | +| 不做「从指标定义编译出 SQL」的语义层 | 不从自然语言编译 SQL,只比较「实际执行的 SQL」和「口径里的 SQL」 | +| 数据目录只放事实 | 口径公式仍然放在口径卡里 | + +#### 3.4.8 账本记什么(概念) + +| 记录 | 内容 | +|---|---| +| 答案 → 口径 | 每个数来自哪份口径(`workflow_id@vN`、口径卡、查询快照),或标为「这次的推理」;等价判断的结果和差异条目 | +| 口径 → 答案 | 某份口径的某个版本,被哪些答案用过:问数的轮次、导出的报告、定时推送 | +| 口径 → 来源问题 | 这份口径是从哪些问题沉淀来的 | + +物理上建在现有的证据链之上(内容寻址的工件、表结构快照、查询快照、封存事件),不另起一套存储。 + +### 3.5 和业内对照 + +| 做法 | 业内现状 | 判断 | +|---|---|---| +| 在对话里问,从认证过的资产里答 | Databricks Genie(verified answer)、Power BI(verified answers)、Snowflake Cortex Analyst(verified query repository) | 不新 | +| 按使用情况推荐要认证的东西 | Snowflake 的 verified query suggestions(2025-10-09 起预览):按使用情况和查询历史推荐,人工审核 | 不新 | +| 对话和可审计的工件是同一个东西的两个视图 | Hex Threads(2025-10-01):每段对话背后是一个 notebook 项目 | 不新 | +| 自由提问改为以指标为锚 | Tableau 的 Ask Data 于 2024 年下线,由 Pulse(指标层)接替 | 业内趋势 | +| 同一套画布分成对话型和一次性两种应用 | Dify 的 Chatflow 和 Workflow(v0.6.0,2024-04) | 不新(佐证不合并界面) | +| 靠证明等价来认口径,失败时只少给信任 | 没查到,业内都是匹配 | **新** | +| 不等价时自动对账:差在哪、差多少 | 专门查过,没查到 | **新** | +| 一个答案里逐个数字标口径 | 没查到,业内是整条答案或整张图打标 | **新** | +| 口径变了,追回已交付的 AI 答案并重新出具 | 血缘工具只通知下游报表 | **新** | + +「新」的意思是:这次检索的公开资料里没查到,不保证一定没人做过。 + +### 3.6 已有的零件和缺口 + +| 需要的能力 | 已有的零件 | 还缺什么 | +|---|---|---| +| 钉住版本调用已治理的工作流 | 子图节点(工作流 + 版本) | 助手能查看、选用已治理的口径(look 的扩展) | +| 判断等价,参数不算不同 | `sql_semantics.normalize_sql`、`explain_sql_diff` | 和已治理口径批量比对;绑定参数 | +| 说清差异 | 双盲复算、运行对比在用 | 用到问数的答案上 | +| 沉淀 | 从运行提取模板、探索问题聚类接口 | 和问数轮次的关联;认证队列;界面入口 | +| 召回 | 证据链(表结构快照、查询快照、口径版本、封存)、目录漂移事件 | 反查「谁用过这一版」;重算后比对;通知 | +| 问数 → 画布 | 「在画布里打开」 | 复制后保留和原问题、原运行的关联 | + +### 3.7 对这个方案本身的反驳和风险 + +1. **判等价太严,可能几乎认不上。** agent 写的 SQL 和模板的写法常常不一样,结果大多数问题都落到对账,路由的价值就小了。用实验 E1 来量。 +2. **冷启动。** 本机只有 6 个已治理的工作流。口径少的时候,这套机制几乎不起作用,所以沉淀要先跑起来。 +3. **一个答案里有两种数,界面难做。** 但这正是「照实说」,难在设计,不在方向。 +4. **对账要多跑一次查询。** 只在点开时跑,走现有的只读守卫和时限,不自动跑。 +5. **召回可能太吵。** 只在重算后数真的变了才通知。 +6. **助手搭拼装的图更难。** 前提是助手能查看口径资产,这正好接上第二部分的「查看」。 +7. **开发库的数据代表性不够。** 本机是开发者自测数据。结论要等真实使用的数据,来自部署实例或者以后的遥测。 + +### 3.8 先做实验再决定(不写功能代码) + +> **已修订(2026-10,X12、02 做法 10)**:用户决定直接做,不先跑一次性实验;E1–E4 改成账本和评测持续给出的统计(`ledger/stats.py`、`GET /api/ledger/stats`,评测页的「口径命中与重复度」页签),判据照下表作参考,不再作开工的门槛。 + +脚本放在仓库外,只读。用开发数据的副本(放在 /tmp,复制需要你放行);部署实例只用只读接口。判据是建议值,可以调。 + +| 实验 | 方法 | 建议判据 | +|---|---|---| +| E1 等价命中率 | 拿问数历史里 agent 实际执行过的 SQL,和已治理工作流里的取数 SQL 批量判等价,统计命中率和差异类型的分布 | 用到相同表的问题里,命中率 ≥ 30% → 值得做路由;< 10% → 先不做路由,只做对账;介于两者之间 → 先加强规范化 | +| E2 对账看不看得懂 | 挑 10 对有对应口径但不等价的,在同一份数据上各跑一次(只读),看差多少、差异说明是否准确易懂 | 至少 8 对的说明既准确又看得懂 | +| E3 问题重复度 | 用聚类接口统计问数问题里,成员在 3 个以上的类占多大比例 | ≥ 20% → 沉淀有价值 | +| E4 召回规模(可选) | 统计每个已治理工作流的版本被多少答案引用,估算一次升版会召回多少 | 用来定通知的方式,不作为是否立项的依据 | + +两项关键判据都通过,才按「组合式问数 → 对账 → 召回」的顺序立项;只有 E2 通过,就只做对账。 + +### 3.9 和近期方案的衔接 + +- 第二部分的「查看」是这套设想的第一块地基:助手从「被喂」变成「会查」。 +- 下一步,可查看的东西从「表」扩展到「已治理的口径」。 +- D4 里问数据的那条规则以后会变成:先看有没有已治理的口径,再决定交给 agent 还是走路径一。 +- 第二部分 2.11 预留的扩展点,就是为这一步准备的。 + +--- + +## 第四部分 待拍板 + +**近期(第二部分)** + +1. **D1**:用逐行输出里的 `look`(推荐),还是原生工具调用? +2. **D6**:遮罩规矩一并用到运行时的 `db_schema__` 和 `/schema` 接口(推荐),还是只用在助手上? +3. **前提**:等 feat/catalog-loop 合进 main 再开工(推荐)? +4. **上限**:每轮最多查看 3 次、自查修正时另给 1 次、一次结果最多 12,000 字,这几个数可以吗? + +**远期(第三部分)** + +5. 要不要先做实验 E1–E3?需要放行把开发数据目录复制到 /tmp。 +6. 3.8 里的判据是否认可? + +--- + +## 附录 A 代码位置索引 + +| 位置 | 内容 | +|---|---| +| `backend/app/api/copilot_context.py:118` | 挑表提示词:「需求和数据库无关时返回空列表」 | +| `backend/app/api/copilot_context.py:223` | `_modes`:按库的大小定每个库怎么给 | +| `backend/app/api/copilot_context.py:249` | `graph_tables`:现有工作流 SQL 用到的表 | +| `backend/app/api/copilot_context.py:279` | `plan_context` | +| `backend/app/api/copilot_context.py:333` | `_ask`:挑表调用,结构化输出失败时退回纯文本 | +| `backend/app/api/copilot_context.py:427` | 挑表返回空被记成失败「模型未挑出任何表」 | +| `backend/app/api/copilot_context.py:451` | `_assemble` | +| `backend/app/api/copilot_context.py:493` | `_complete`:补连接用的中间表 | +| `backend/app/api/copilot_context.py:570` | `DatasourceContext.op`:发给前端的 context 操作 | +| `backend/app/api/copilot_context.py:599` | `DatasourceContext.section`:system 里的数据源一节 | +| `backend/app/api/copilot.py:75` | `_tool_catalog`:每个库列前 12 个表名 | +| `backend/app/api/copilot.py:111` | `_pick_need`:拼上前 3 轮问题 | +| `backend/app/api/copilot.py:134` | `_datasource_context`:非流式的三个入口共用 | +| `backend/app/api/copilot.py:371` | 问数据(intent=answer)的搭图约定 | +| `backend/app/api/copilot.py:459` | `_history_section` | +| `backend/app/api/copilot.py:730` | `/generate`(非流式) | +| `backend/app/api/copilot.py:838` | `_STREAM_PROTOCOL` | +| `backend/app/api/copilot.py:878` | `_parse_op_line` | +| `backend/app/api/copilot.py:1004` | `_apply_op` | +| `backend/app/api/copilot.py:1090` | `_iter_ops`:主模型只读流式文本 | +| `backend/app/api/copilot.py:1327` | 自查 `sql_unknown_column` | +| `backend/app/api/copilot.py:1779` | `_repair_request` | +| `backend/app/api/copilot.py:1800` | `/generate-stream` | +| `backend/app/api/copilot.py:1816` | `system_for` | +| `backend/app/api/copilot.py:1864`–`1891` | 开流前规划、流里挑表、组装 system | +| `backend/app/api/copilot.py:2114` / `:2296` | `assist_publish_fix` / `assist_upgrade` | +| `backend/app/api/copilot.py:2496` | `/copilot/from-run`:轨迹 → 模板 | +| `backend/app/api/governance.py:90` | `/governance/exploratory-clusters` | +| `backend/app/data/introspect.py:30` | `_PREVIEW_COLUMNS = 6` | +| `backend/app/data/introspect.py:425` | `describe_table` | +| `backend/app/data/catalog.py:1152` | `resolve_table_name` | +| `backend/app/data/catalog.py:2010` | `render_table_index` | +| `backend/app/data/engine.py:370` | `masked_columns` | +| `backend/app/data/sql_semantics.py` | `normalize_sql`、`explain_sql_diff`(模块说明在文件开头) | +| `backend/app/engine/compare.py:123` | 子图节点的配置项 | +| `backend/app/tools/datasource.py:176` | `_query_description`:查询工具说明列前 25 个表名 | +| `backend/app/tools/datasource.py:647` | `_make_schema_tool`:运行时的 `db_schema__` | +| `frontend/src/run/decode.ts:3041` | `CONTEXT_FALLBACK_TEXT` | +| `frontend/src/run/decode.ts:3086` | `case 'model'`:0 张表的说法(从未用到) | +| `frontend/src/store/studio.ts:1896` | `case 'context'` | +| `frontend/src/lib/terms.ts:2863` | 「参考了 N 张表的字段」 | +| `frontend/src/pages/ChatPage.tsx:298` | 「在画布里打开」:复制成新工作流 | +| `frontend/src/pages/runs/RunDetailView.tsx:403` | 「提取模板」 | +| `frontend/src/api/client.ts:638` | 聚类接口(没有调用方) | + +## 附录 B 术语 + +| 术语 | 含义 | +|---|---| +| 一轮 | 用户发一句话,到助手这次答完(含自查修正) | +| 查看(look) | 助手在一轮中主动要求看表结构;一次查看给齐挑表结果、全部字段、数据目录、连接条件 | +| 挑表 | 用挑表模型按需求从表目录里挑出相关的表;新方案里是查看内部的一步 | +| 数据源概览 | system 里每个库一行的介绍,不含任何表 | +| 系统查看 | 不需要人参与的路径上,由服务端代模型点名查看 | +| 口径 | 一个指标的算法定义。在本项目里落在口径卡(metrics 节点)和取数 SQL 上,发布后带版本 | +| 已治理 | 工作流的发布状态之一(draft → published → governed) | +| 探索运行 / 正式运行 | 运行类别。没发布的图只能跑探索运行;正式运行只能从发布的版本发起,按三档出具 | +| 三档出具 | 正式出具、降级出具、不出具 | +| 子图钉版本 | 子图节点按 `workflow_id` + `workflow_version` 调用固定版本的工作流 | +| 等价 | `explain_sql_diff` 判定两条 SQL 规范化之后一字不差;字面量不计 | +| 口径对账 | 不等价时说清差在哪、差多少 | +| 召回 | 口径或目录事实变了以后,找出用过旧版本的答案、重算并通知 | +| 口径账本 | 记录「答案 ↔ 口径 ↔ 来源问题」三方关系的账,建在证据链之上 | + +## 附录 C 参考资料 + +- [Databricks:Tune Genie Agent quality(verified answer)](https://docs.databricks.com/aws/en/genie-agents/tune-quality) +- [Azure Databricks:Use trusted assets in Genie spaces](https://learn.microsoft.com/en-us/azure/databricks/genie/trusted-assets) +- [QueryPlane:Databricks AI/BI Genie in Practice(资产过期问题)](https://queryplane.com/blog/databricks-ai-bi-genie-in-practice/) +- [Kumar Gauraw:Genie Agents setup, testing & governance](https://www.gauraw.com/databricks-genie-agents-setup-testing-governance/) +- [Snowflake:Verified query suggestions](https://docs.snowflake.com/en/user-guide/snowflake-cortex/cortex-analyst/verified-query-suggestions) +- [Snowflake 发布说明 2025-10-09:Verified query suggestions(预览)](https://docs.snowflake.com/en/release-notes/2025/other/2025-10-09-verified-query-suggestions) +- [Snowflake:Cortex Analyst REST API](https://docs.snowflake.com/en/user-guide/snowflake-cortex/cortex-analyst/rest-api) +- [Power BI:Prepare your data for AI — Verified answers](https://learn.microsoft.com/en-us/power-bi/create-reports/copilot-prepare-data-ai-verified-answers) +- [Tableau:Metrics and Natural Language Query Evolve with Tableau Pulse](https://www.tableau.com/blog/tableau-metrics-and-natural-language-query-evolve-tableau-pulse) +- [Tableau:Ask Data](https://help.tableau.com/current/pro/desktop/en-us/ask_data.htm) +- [Dify:Workflow & Chatflow](https://docs.dify.ai/en/cloud/use-dify/build/workflow-chatflow) +- [Hex:Introducing Threads](https://hex.tech/blog/introducing-threads/) +- [Hex:Fall 2025 Launch](https://hex.tech/blog/fall-2025-launch/) +- [ThoughtSpot:Release history](https://docs.thoughtspot.com/cloud/26.9.0.cl/release-history) +- [Sigma:Ad hoc analysis](https://www.sigmacomputing.com/blog/ad-hoc-analysis-and-reporting-definition) +- [Google Cloud:What is data lineage](https://cloud.google.com/discover/what-is-data-lineage) +- [Atlan:Data lineage explained](https://atlan.com/data-lineage-explained/) + +## 附录 D 测量方法 + +- 所有数字都通过本机后端的只读 GET 接口取得,没有复制数据库,也没有调用模型: + - `/api/datasources` + - `/api/datasources/{id}/schema` + - `/api/datasources/{id}/catalog` + - `/api/conversations` + - `/api/workflows` + - `/api/runs` +- 小库的「详细摘要」字数:`/schema` 接口返回的 `summary`,与 `_detail_block` 第一段用的是同一个函数 `introspect.summary(detail=True)`。 +- 大库的「只列表名」字数:按表名用「、」连起来,再加约 40 字的标题行估算。 +- 数据目录和连接条件那一块没有逐字重建;按小库里有目录的表数(52 张)判断,会用满 6,000 字的共用上限。 +- 节点手册、输出协议、写 SQL 的硬性要求、内置工具清单的字数:在后端环境里直接导入模块,取常量的长度。 +- 2.7 秒和 7.8 秒来自界面截图。挑表失败的原因没有落库,「挑表返回空」是根据用时(远低于 20 秒时限)和挑表提示词推断的;后端终端日志里应该能看到「助手挑表未成功(模型未挑出任何表)」。 diff --git a/docs/design/next-round/00-总方案.md b/docs/design/next-round/00-总方案.md new file mode 100644 index 00000000..4ddf4fa6 --- /dev/null +++ b/docs/design/next-round/00-总方案.md @@ -0,0 +1,179 @@ +# 下一轮总方案:评测、口径账本、目录维护、对外集成、遗留 + +> 状态:已拍板(2026-10-16):P1 英文界面本轮不做(只补集成接口错误码,第 5 波取消);P2 每波过关就合 main 并推送;P3 容器集成测试 + 验收时开发源只读账号;产品取舍表全按推荐。开工。代码基准 main `d135a17`。 +> 依据:五路只读调研(结论落到文件:行号,细案见 01–05)。真实库名、表名、计数都不进本文;验收一律用虚构库。 + +## 0. 范围 + +用户 2026-10-16 选定: + +| 编号 | 块 | 一句话 | +|---|---|---| +| 1 | 效果度量与回归评测 | 一把尺:题集 + 由 SQL / 口径算出的期望答案,换模型、改提示词、改目录后自动回归,能和上一次比、能做 A/B | +| 2 | 口径账本 | 问数据和已治理口径接起来:认口径靠证明等价,答案里逐个数标口径,不等价时对账,口径变了追回已交付的答案,重复的问题沉淀成口径 | +| 4 | 数据接入与目录维护(不含新数据库类型) | 几千张表的库能用;表结构变化、取值漂移看得见、能处理;可定时维护;剖析方言修正;Excel 期 5 四项 | +| 7 | 对外集成与触达 | 访问密钥(服务账号)调接口;邮件与待办汇总;安全头与分享链接;英文界面(层级待定) | +| — | 已知遗留 | 驳回≠失败、使用者选人、导入清单的认证标记、自由形蓝图「谁来做」、剖析结果进 look,外加几处小口 | + +不在本轮:行级数据权限(3)、生产化运维(5:保留清理、备份、迁移体系、高可用)、工程治理(6:CI、检查用种子数据、发版),以及新的数据库类型。评测会顺带做「评测运行的清理与工件回收」,但不是通用的保留策略。 + +## 1. 守住的理念和不可回退的决定 + +- **照实说**:不夸大、不悄悄降级;失败说清原因和下一步;算不了的写原因,不混进「错」。 +- **留痕**:谁、何时、依据什么都查得到;封存的东西不改写。 +- **不挡生成**:辅助步骤(比对、召回、观察、计量)失败不让主流程失败。 +- **只读与遮罩**:不写业务库;模型、使用者、通知、外部调用方都看不到遮罩值。 +- **一种说法**:同一件事一个叫法、一套机制。 +- **模板定骨架、agent 填关节**;**界面层不放逻辑**;**生效可查可对账**。 +- 不可回退:叙述层无算术;口径升版是被迫处置事件;没发布的图只能跑探索运行,正式运行只从已发布未退役的不可变版本发起;不做「从指标定义编译 SQL」的语义层;数据目录只放事实,口径公式在口径卡里。 + +## 2. 五块的要点(细节见 01–05) + +### 2.1 评测(01) +- **量的就是用户拿到的那一轮**:问数据里「要不要运行、要不要重来、重来说什么、输入怎么填」这几个决定从前端下沉到后端(`chat/turn.py`),问数据页照后端给的 `next` 走;生成过程抽成可直接调用的 `answer_ops`,SSE 只是包一层。评测和问数据页走同一条路。 +- **期望答案不靠人填数**:SQL 题(裸图探索运行,封存的查询快照)、口径题(拷出钉住版本的取数节点 + `caliber_from` 口径卡)、从运行认定(拷 SQL,人确认)。作答前后各算一次,数据变了就不判。 +- **评分五种结论**:对且可追溯 / 对但没引用 / 错 / 量纲不同 / 不能评分;准确率和可追溯准确率分开报,带 Wilson 区间。 +- **对比与 A/B**:逐题转移 + 归因(数据变 / 做法变 / 只参数变 / 路径变),A/B 共用期望、配对表 + 精确 McNemar。「目录的贡献」= 预设 A/B(当前目录 vs 关 vs 只用已核实);起草模型对照用影子起草的目录快照,不动线上目录。 +- **不污染**:运行来源 `eval`,聚类、白名单衰减、目录使用次数都只算「计入统计」的运行;评测运行可清理(含工件回收)。 +- **成本照实**:助手建图、挑表、复核的用量也记(问数据页这一轮的花费随之补全)。 + +### 2.2 口径账本(02) +- **认口径**:`sql_semantics` 新增参数绑定 + 证明(绑定只是启发,等价只由 `explain_sql_diff` 判,失败只少给信任)。运行封存后在后台比对,结果存成内容寻址工件 `caliber_match` + 封存后事件 `ledger.matched`,不建新表。 +- **逐数标口径**:来自口径 vN(等价已证明 / 助手选用)/ 相关口径(差在哪)/ 这次的推理 / 没比对。证据面板最上面多「口径」一步。 +- **三条路径**:整题等价 → 按口径正式出具(自动条件见拍板 L1);不等价但相关 → 口径对账(点开才跑一次确定性派生的只读图,差值在对账卡里算);没有相关口径 → 纯探索。 +- **组合式问数**:助手可以 `use_caliber` 选用口径,服务端按固定版本确定性内联实例化(取数节点照抄、口径卡 `caliber_from` + 节点改名映射)。**偏离原设计**:不用子图节点做骨架(子图证据不交回父图、使用者不能用子工作流)。 +- **召回**:口径发布新版后自动反查用过旧版的答案(问数轮次、导出、定时、应用),按参数去重成组,在同一份数据上算新旧两版之差,数真变了才开待办 `recall`;目录事实被驳回、表结构变动只标「依据有变」,按新目录跑 SQL 检查出现新错误才通知。 +- **沉淀**:认证队列(按 SQL 形状的确定性重复 + 只取问题的聚类 + 被反复用的答案),「固化成口径草稿」把被引用的查询变成固定 SQL,参数按跨轮变化的字面量推出,人审后发布,模板记住来源问题。 + +### 2.3 数据接入与目录维护(03) +- **大库**:全库对象索引(每种方言一条字典查询,上限 5 万)+ 探查范围规则(缺省 500、最多 2,000 张)+ 后台探查任务(和起草、剖析同源排队);`schema_cache` 只物化范围内的表、形状不变;look / `db_schema` 加 BM25 预筛和「按需加入」(点名范围外的表当场只读反射、落盘留痕)。「表已不在」只在真删除时说,范围外、反射失败、已删除分开说。 +- **结构变化**:每张表有结构版本;重新探查后出前后差异(疑似改名只说「可能」)和影响面(目录哪些项、哪些已发布模板写死的 SQL 会坏);发布时记表的结构版本,正式运行发起时提醒(不拦)。 +- **取值观察**:码值列(含人工确认、已列全的)、最新业务日期、按日行数、总行数;只和同口径的历史比;新码值、比例剧变、断档、缺天、量跌。机器唯一能改目录的事:数据证明不再列全时撤销「已列全」标记。 +- **变化记录**:一张 `source_changes`,审阅页「变化」标签页,处理动作都落到目录已有的写入接口;按 `seq` 游标给召回拉取。 +- **定时维护**:按数据源开、缺省关,到点 探查 → 剖析需要的表 → 取值观察,排队串行。 +- **方言修正**:IN 两边类型族、列类型存方言完整写法(ENUM 进码值候选)、Oracle 小写引号名、统计过期复核、无效日期过滤、「抽样」改说「按存储顺序取前 N 行」;方言金样 SQL + sqlglot 解析;容器集成测试与开发源只读验收要用户放行。 +- **Excel 期 5**:列表按期累积、穿插小计(核对后不进数据)、表格对象起草、CSV 走配方;历史重放和解析器升级比对推后;原件下载、「表格文件」节点不做。 + +### 2.4 对外集成与触达(04) +- **访问密钥**:服务账号(管理员建,不能登录)+ 个人访问密钥(管理员开关,缺省关);只存哈希、必设到期、可撤销与轮换;**权限一律封顶在使用者边界**(看到的都遮过);按路由白名单 `KEY_ROUTES` 放行,同一张表生成接口说明;发起入口必须带幂等键;视图数据接口 `agentlab.viewdata/1`(口径卡指标原值 + 印章 + 清单哈希);请求记录批量写、审计;开登录时收起 `/docs`;集成要用的接口补齐错误码。 +- **触达**:修好个人渠道「我发起的实例完成 / 失败」从不生效的问题;加邮件(SMTP,第五种格式,只发到账号邮箱,照实写「已交给邮件服务器」);待办每日汇总;密钥到期提醒;「对外访问地址」只留一个设置。IM 应用消息推后(和 IM 登录同一套企业应用凭据)。 +- **嵌入**:安全响应头(`frame-ancestors` 缺省 `'self'`,白名单可配;严格 `script-src` 先只上报);分享链接(管理员开关缺省关、必设到期、只读、只显示定时出数的视图、出处给下载报告、key 放在 `#` 后面);同站门户嵌整站只做配置和文档。 +- **英文界面**:分三层(L0 基础设施 / L1 使用者面 / L2 搭建者面),本轮做到哪层见拍板。 + +### 2.5 已知遗留(05) +- **驳回≠失败**:人的驳回统一抛 `NodeRejected`,运行终态记 `cancelled` + 新列 `end_reason=rejected`;通知、定时推送、发布单、记录页、问数据都读这一个字段;被驳回的不能「继续运行」。顺带:重启时被改成失败的排队运行补发 `run.failed`。 +- **使用者选人**:按用途给候选池(缺省「能用这个应用的人」,授权给所有人时必须输前缀),只给用户名和显示名;开登录时服务端核对,不在池里 422。 +- **导入清单的「是否认证」**如实记录(旧清单不改写)。 +- **自由形蓝图**:按导入图列出「谁来做」(含核实人),只定由谁做,会签和时限回画布改。 +- **剖析事实进 look**:行数、日期范围按当前遮罩重滤,附「只供写查询参考、不能作为回答里的数」,四处同一段文字。 +- **小口**:tick 测试改按 id 断言;目录页后台任务进度只放一处;`check-catalog-loop` 去掉剖析旧键。 + +## 3. 跨块统一的决定(编排者定,细案里的冲突以这里为准) + +| 编号 | 决定 | 否决的备选 / 理由 | +|---|---|---| +| X1 运行来源 | `ORIGIN_KINDS` 新增 `eval`、`ledger_issue`、`reconcile`、`recall`,全部只能由服务端写。「计入统计」谓词 `counted_runs()` 排除 `eval`、`reconcile`、`recall`(系统派生的运行);`ledger_issue` 是替用户正式出具,照常计入。聚类、白名单衰减、目录使用次数、「被哪些运行用过」、账本统计都只算它圈进来的运行;记录页默认不列这三类,并写明有几次没列出 | 各块各加各的过滤:漏一处统计就失真 | +| X2 E1–E4 只有一份实现 | 由账本给出(`ledger/stats.py`,从 `caliber_match` 工件、运行、审计现算)。评测页的「口径命中与重复度」页签直接调它;评测里逐题的 SQL 差异用同一个比对函数。**评测细案的 `eval/signals.py` 撤销**。聚类函数全站一份,只取问题原文、不含 history、排除评测运行 | 评测和账本各算一份 E1:两个数对不上 | +| X3 认口径与派生图的内核共用 | `sql_semantics.bind_template / match_template` 一份;「拷出钉住版本的取数节点 + 口径卡」的派生图生成器一份(`ledger/derive.py`),评测的口径题期望、账本的对账、召回都用它 | 评测 gold 和账本 derive 各写一份:口径的取法会走样 | +| X4 定时只有一套 | `Schedule` 加 `target_kind`(`workflow` / `eval_suite` / `source_maintenance`)和 `target_id`;认领、补跑、重叠跳过、触发记录、通知都只有一套。定时页统一列出、标明种类;数据源设置里的「定时维护」是同一条定时的快捷入口。`workflow_id` 是 NOT NULL,非工作流目标存空串(只加不改的迁移改不了约束,代码里一处封装) | 目录细案的单独 `source_maintenance` 表:第二套 cron 认领与补跑;评测已经要泛化 `Schedule`,增量成本共摊 | +| X5 后台任务运行时 | `data/catalog_job_runtime.py` 上移到 `core/job_runtime.py`(原路径再导出)。评测、后台探查都用它;`SourceLanes` 新增 `introspect` 一种,同源串行(起草、剖析、探查、导入、同步起草)。召回不建任务表:计划可从已有召回运行重算 | 各写一套队列:互斥会漏 | +| X6 通知事件一次登记 | `notify/service.EVENTS` 本轮新增:`source.changed`、`ledger.recall`、`eval.regressed`、`eval.failed`、`task.digest`、`key.expiring`。消息只放标题、状态、条数、链接:不放数字,不放表名、列名、取值。被驳回的实例走「实例结束」,文字取自 `instance_state` | 各块各改 `notify/service.py`:同一文件多方冲突 | +| X7 表的说法只有 `schema_view` 一处 | 剖析事实(遗留 e)、取值观察结论(目录 B2)、检索预筛和按需加入(目录 A2)、目录视图开关(评测)都经 `schema_view` / `catalog_view` 出;look、`db_schema`、`/schema`、表助手四处同一段文字,遮罩按渲染那一刻重滤(look D6) | 各处各拼:违背 D6 | +| X8 身份与名录一次改 | `Principal` 加 `via / key_id / scopes / app_ids`(全部带默认值);名录候选池(遗留 b)与服务账号排除(集成 A1)在同一个包里改 `auth/directory.py` | 分两包改同一文件 | +| X9 终态 | 驳回 = `cancelled` + `end_reason=rejected`;评测把驳回记成「路径:被驳回」不算失败;账本、召回、发布单、定时推送都按 `end_reason` 判 | 新增运行状态 `rejected`:终态集合 6 处、前端 112 处引用,漏一处就是一直等终态 | +| X10 叫法 | 访问密钥(不叫令牌、token、凭据)、服务账号、分享链接(不叫快照链接)、结构变化 / 取值变化(和已有的「目录有变化」分开)、评测 / 题集、来自口径(受管 / 已发布)· 等价已证明 / 助手选用、这次的推理、口径对账、召回、可沉淀的问题;出具档位沿用「完整出具 / 降档出具 / 不予出具」。全部进 `lib/terms*.ts`,过 check-copy | — | +| X11 迁移 | 只加不改:新表 `eval_*`、`source_objects`、`table_observations`、`source_changes`、`access_keys`、`access_key_requests`、`api_receipts`、`view_shares`;新列 `runs.end_reason`、`schedules.target_kind/target_id`、`schedule_fires.eval_run_id`、`users.kind/locale`、`workflow_versions.schema_versions`、`catalog_profile_tasks.snapshot_id`、`artifacts.kind` 索引。升级后第一次探查只建基线,不报「类型变了」 | — | +| X12 偏离已定文档 | 口径账本:§3.4.1 子图做骨架 → 内联实例化;§3.4.2 字面量不算不同 → 先绑定参数再证明;§3.8 先做实验 → 持续统计。应用平台:Q6「通知只用 Webhook」→ 加邮件(SMTP 可选、退信不追踪、照实说)。实施时在原文档标「已修订」 | — | + +## 4. 需要你拍板的问题 + +细案里一共约 50 条决定,大部分是架构取舍,我按推荐定了(见各细案「待拍板(本块)」)。下面是**真正需要你定**的: + +**影响范围和节奏** +- **P1 英文界面做到哪层**: + - A 本轮不做,只补集成接口的错误码; + - B 做 L0 基础设施 + L1 使用者面(应用、待办、登录、我的设置、通知;服务端按用户语言出视图文案)。约 1.3k 段前端文案、约 470 处服务端字符串,之后每个新功能都要两种语言一起写; + - C 全量(再加约 1 万段搭建者面文案、约 8k 处服务端字符串)。 + - 推荐 B:你选了第 7 类,L1 正好覆盖外部使用者碰得到的部分;搭建者面暂不值。 +- **P2 合并节奏**: + - A 每一波关口过了就合 main 并推送; + - B 全部做完、真实验收后再问。 + - 推荐 A:这一轮有 4–5 波,主干上还有别的会话在合入,长期分支冲突会越积越多。 + +**需要放行的操作** +- **P3 方言真库验证**: + - A 本机用 docker 起 PostgreSQL、MySQL、MariaDB、Oracle Free 四个容器跑集成测试(只在本机,拉镜像约几 GB);验收时再请你给本机 MySQL / Oracle 开发源的只读账号,只剖析 5–8 张你挑的表; + - B 只做容器测试; + - C 都不做,只靠金样 SQL 和 sqlglot。 + - 推荐 A。 +- **P4 真实模型验收**:评测和账本要用真实模型跑虚构库。需要一个可用的模型接入,有两种办法: + - 复制开发数据目录(含密钥,验收完删); + - 你在新数据目录里配一个接入。 + - 推荐后者。部署实例上的真实题集只在 10.0.0.11 上建、跑;重新部署之后才有。 + +**产品取舍(都给了推荐,可以一句「全按推荐」)** + +| 编号 | 问题 | 推荐 | +|---|---|---| +| 账本-L1 | 整题等价时什么情况自动发起正式运行 | 只在 Agent 独立写的 SQL 被证明等价、整题覆盖、版本受管、图里没有人工节点和写入、当天没超上限时自动;「助手选用」的只给一键 | +| 账本-L2 | 问数据运行时写 SQL 的 Agent 看不看口径定义 | 不看,保持独立解读(不然「等价已证明」只证明它抄对了) | +| 账本-L5 | 使用者能不能按口径正式出具 | 对账都可以;正式出具只限他有权使用的应用固定着的口径版本 | +| 账本-L6 | 口径升版后的召回 | 发布后自动排队重算,每次最多 50 组,数真变了才开待办 | +| 评测-D4 | 允不允许人工填写期望值 | 数值不许人填;文字要点题由裁判判,单列,不计入准确率 | +| 评测-D8 | 评测界面放哪 | 顶级导航「评测」,搭建者以上可见 | +| 评测-D9 | 预算缺省 | 同时 2 题、每题 900 秒、单次 2 美元、每日 5 美元(单独记账),首次真实验收后按实际调 | +| 评测-D12 | 多轮追问题 | 本轮只做单轮,数据模型留位置 | +| 目录-D2 | 一个数据源能不能跨多个 schema | 不能;要多个 schema 用「同一连接再接一个 schema」快捷建源 | +| 目录-D5 | 定时维护 | 做;按数据源开、缺省关、只在设定时段跑 | +| 目录-D6 | 观察人工确认过的码值列 | 照样观察;数据证明不再列全时,机器只撤销「已列全」标记 | +| 集成-I2 / I3 | 谁能持有访问密钥、能写什么 | 服务账号 + 个人密钥(管理员开关,缺省关);读、发起入口、刷新视图(必须带幂等键)、个人密钥可回复审批 / 补充输入 / 编辑稿件;新建时默认只勾读 | +| 集成-I4 / I5 | 通知渠道 | 加邮件,IM 应用消息推后;默认发给待办处理人由管理员开关,缺省关 | +| 集成-I6 | 分享链接 | 做;管理员开关缺省关、必设到期(最长 90 天)、只读、出处给下载报告 | +| 遗留-Q1 | 驳回的终态 | `cancelled` + `end_reason=rejected` | +| 遗留-Q3 / Q4 | 使用者选人 | 候选池缺省「能用这个应用的人」,只给名字;服务端核对直接生效 | + +## 5. 实施组织 + +### 5.1 分波 + +同一文件只在同一波里归一个包;每波 4 个完整代理并行 → 1 个统一复核 → 修复 → 编排者合并、关口、合 main(按 P2)。代理不拆太细(每包一个完整代理 + 统一复核),一次只开一个 workflow,避免限流。 + +| 波 | 包(每包一个完整代理,含该包的测试) | 依赖 | +|---|---|---| +| 1 底座与独立件 | **W1-a 评测底座**:运行时上移(X5)、运行来源与计入统计(X1,四种来源一次登记)、目录视图接缝、用量计量、`answer_ops`、问数据决定下沉(前端照做)、账本泛化(三本每日账)、`Schedule.target_kind`(X4)、通知事件登记(X6) · **W1-b 遗留**:驳回≠失败(X9)、导入清单认证、tick、补发 run.failed、使用者选人候选池、自由形「谁来做」 · **W1-c 目录方言与 Excel**:方言修正、金样、容器集成脚本、Excel 期 5 四项 · **W1-d 集成底座**:安全头、对外访问地址统一、个人渠道修复、集成接口错误码、收起 /docs | 无 | +| 2 内核 | **W2-a 大库**:对象索引、范围、后台探查、`schema_view` 预筛与按需加入、剖析事实进 look(遗留 e) · **W2-b 账本内核**:绑定与证明、比对器与工件、标签接口、运行↔轮次来源链、导出审计、E1–E4 统计 · **W2-c 评测题集与期望**:模型、接口、三种期望来源、口径升版处置、目录快照与影子起草、导入导出、零售样例题集 · **W2-d 访问密钥**:服务账号、密钥、门禁、幂等、视图数据、请求记录、接口说明(含前端设置页) | 波 1 | +| 3 功能 | **W3-a 变化**:结构差异与版本、取值观察、变化记录与处理、定时维护、`schema.drift` · **W3-b 账本组合与沉淀**:问数据看口径、`use_caliber`、`caliber_from.nodes`、正式出具、对账、认证队列与固化 · **W3-c 评测执行**:后台评测、作答驱动、评分与重评、统计、对比与 A/B、清理与工件回收、题集定时与通知 · **W3-d 触达与分享**:邮件、待办汇总、密钥到期、分享链接(含 /s 页与管理) | 波 2 | +| 4 收口 | **W4-a 召回**(消费 `source_changes`) · **W4-b 评测前端** · **W4-c 账本前端**(证据「口径」一步、轮次摘要、对账视图、认证队列、recall 待办卡、版本用量与召回预告) · **W4-d 目录前端**(范围面板、分批渲染与搜索、变化标签页、表详情、定时维护、运行横幅、后台任务条) | 波 3 | +| 5 英文界面(按 P1) | **W5-a 前端** L0 + L1 · **W5-b 服务端** 语言解析、消息目录、视图文案、通知按收件人语言 | 波 4(文案定型后) | + +### 5.2 每波的关口 +- 代理:只跑相关测试和相关单项检查;交付前后端全量一遍(不加 `-q`)。 +- 编排者:合并 → 后端全量 `-n auto` → tsc → check-copy → **这一波新增和改动过的检查脚本**(不默认重跑整套 check-all)→ 泄露扫描 → 按 P2 合 main。 +- 安全审查若被限流,编排者照例翻转录核对代理的命令。 + +### 5.3 最终验收(真实后端,全新数据目录 + 虚构库) +- 评测:零售样例题集在 deepseek / glm 上跑「当前目录 vs 关」各 3 次;抽查 5 道题的期望值和 SQL;聚类、使用次数不受评测影响;总成本 = 各运行用量 + 建图 + 复核。 +- 账本:受管口径「周销售」→ 等价的问法标「等价已证明」并自动出具;「含退款」走对账,三个数都点得开;`use_caliber` 只给一键;发布 v2 后召回规模和预告一致,只有数变了的开待办;同类问题问 3 次进认证队列,固化发布后再问标「等价已证明」。 +- 目录:3,000 张表的合成库(范围、后台探查、look 挑表 ≥ 9/10、按需加入);SQLite 夹具做 ALTER 后的结构变化与影响面;取值观察四类;定时维护;方言容器测试(按 P3)。 +- 集成:curl 走通访问密钥全流程(只看得到授权的应用、遮罩、幂等、撤销 401);假 SMTP 收件;分享链接与嵌入白名单;(按 P1)切到英文后使用者面只剩标「原文」的数据是中文。 +- 遗留:动作确认驳回后各处都写「被驳回」;使用者选人;自由形发布一次通过。 + +### 5.4 规模与成本(照实估,没核实) +- 代码:评测约 1.1–1.2 万行,账本约 0.7–0.8 万,目录约 1.4–1.8 万,集成约 0.65 万(英文 L0+L1 另加约 0.4–0.5 万,加翻译),遗留约 0.18 万。合计约 4.2–5 万行,含测试约四成。 +- 代理:4–5 波 × (4 个构建 + 1 个复核 + 至多 4 个修复),约 35–45 个代理。按前几轮每波约 300–400 万 token、构建 1.5–3 小时估,总计约 1,500–2,000 万 token、约 10–15 小时的代理时间,外加每波编排者合并、关口约 1 小时。限流风险按每波一个 workflow 控制。 + +## 6. 风险 + +- **文件冲突**:`db/models.py`、`db/base.py`、`main.py`、`api/copilot.py`、`notify/service.py`、`lib/terms.ts`、`types.ts` 几乎每包都碰。对策:波内按包分配文件主人;共享注册(事件、来源、迁移列表、路由)都在波 1 一次登记好。 +- **错给信任**:账本唯一的错误来源是判等价判错。对策:绑定只当启发;等价只由现有判等价 + 执行守门决定;绑定渲染后的 SQL 进等价语料;自动出具只给独立等价。 +- **成本与业务库压力**:评测、召回、观察、定时维护都会查业务库或调模型。对策:各自独立的每日账和上限;观察和定时维护缺省关;召回只跑口径的数据部分、不调模型;评测并发 2。 +- **噪声**:漂移误报、召回太吵。对策:最小行数、PSI 阈值、同口径才比、按指纹去重;召回只在差值不为零时通知。 +- **大库的 `schema_cache` 体积**:范围 2,000 张约 4–5 MB,要实测;必要时下一轮把冻结快照改成按表内容寻址。 +- **单进程前提**:队列、限流、请求缓冲、按需探查锁都在进程内,和现状一致;多进程部署要换成库里的锁(本轮不做,写进 README)。 +- **英文界面一旦上线**:之后每个功能都要两种语言一起写,check-copy 分语言。 + +## 7. 和已有文档的关系 + +- `copilot-look-and-caliber-ledger.md` 第三部分:按 X12 修订。 +- `app-platform-and-look.md`:Q6 修订(邮件);§3.12 权限矩阵加「访问密钥」「分享链接」两行;§3.10 接口表加新接口。 +- `catalog-profiling-jobs.md`:§8 的「剖析结果喂给助手」在本轮遗留 e / 目录 A2 做掉;「定时剖析」「列级漂移」在目录 B 做掉。 diff --git a/docs/design/next-round/01-效果度量与回归评测.md b/docs/design/next-round/01-效果度量与回归评测.md new file mode 100644 index 00000000..3460895c --- /dev/null +++ b/docs/design/next-round/01-效果度量与回归评测.md @@ -0,0 +1,318 @@ +> 分块细案(2026-10-16 只读调研整理,代码基准 main d135a17)。和 [00-总方案](00-总方案.md) 冲突的地方以总方案为准;总方案「跨块统一的决定」一节列出了改动过的条目。决定编号在本文件内有效,总方案里引用时带前缀(如 评测-D4)。 + +# 效果度量与回归评测 + +## 缺口 +- 没有一把尺:没有题集、没有期望答案、没有准确率。换模型、改提示词、改目录之后的好坏,只能靠临时验收脚本和 /tmp 里的配方判断 —— 退步要等用户撞上才知道,改进也说不清改进了多少 [scripts/check-*.mjs(只测界面);记忆 agentlab-verify-suite 的验收配方] +- 后端跑不了一轮问数据。重来、要不要运行、怎么填输入这些决定都写在前端 —— 评测要么照着抄一份(迟早走样,量出来的不是用户拿到的那一轮),要么做不了;这也违反「界面层不放逻辑」 [frontend/src/store/chat.ts:1271–1330、:1407–1456、:1754] +- 期望答案没有确定的来源:既没有「用 SQL 定期望」,也没有「用已治理口径定期望」。口径卡复算只有推演,而推演只在封存过的运行上算 —— 不做的话只能人填数,人填的数会过期、会填错,也说不清是哪个口径 [backend/app/engine/recompute.py;backend/app/look/calibers.py] +- 成本不全:助手建图、挑表、复核都不记 token 和金额 —— 按角色记成本、算每题成本都做不了;生产里问数据的花费也是低报的(违背照实说) [backend/app/api/copilot.py、copilot_gen.py、look/pick.py、engine/review.py] +- 量不了数据目录的贡献:目录在四处被读、没有统一开关、没有历史版本;评测期间目录被起草或剖析改了也不知道 —— 开目录和关目录、只用已核实的项、起草模型 A 和 B 的目录,这些对照都做不了,结果也没法复现 [tools/datasource.py:290;look/tables.py:164;data/sqlcheck.py:1087;engine/nodes/dual_check.py:792] +- 写作模型不是一个独立的角色,没法按变体对照 —— 「换写作模型」这一条对照没有落点 [engine/context.py:82;providers/factory.py:134] +- 评测运行会污染统计:聚类、白名单衰减、目录使用次数、记录页都会被刷高。聚类还把 history 一起拿去聚,E3 会偏高 —— 一跑评测,治理信号和目录的使用热度就失真了 [api/governance.py:90–125;data/catalog.py:2406] +- 删运行不回收工件引用和文件 —— 每次评测都有几十次运行和查询快照,只增不减,「可清理」做不到 [api/runs.py:479;core/artifact_store.py] +- 定时任务只能指向工作流 —— 做不了定期回归,目录被改、表结构变了带来的退步发现不了 [db/models.py:978;scheduler/service.py:280] +- E1、E3 只有设计,没有持续指标。E1 还缺「绑定参数」这一步:口径 SQL 里的 {{ }} 模板 normalize_sql 不认 —— 一次性实验量完就过期,换模型之后没人再去量 [docs/design/copilot-look-and-caliber-ledger.md §3.6、§3.8;data/sql_semantics.py:319] +- 每日账已经有两份重复的代码,评测要是再抄一份就是第三份 —— 违反一种说法;评测要是共用裁判或核查的账,就会挤占它们的额度 [engine/spend.py;engine/judge.py:1050] +- 没有统计上的诚实:模型有随机性,题集又小,没有区间、没有配对检验 —— 差两三题就可能被读成「变好了」或「变差了」 [(无)] +- 相对日期的问法不稳:提示词里没有今天的日期(只查了三个文件,没核实全部节点) —— 「上周」的理解每次可能不一样,评测结果跟着抖 [api/copilot.py;engine/nodes/llm.py;engine/nodes/report.py] + +## 做法 +### 1. 问数据一轮的「决定」下沉到后端 +- 做法:把前端的四条决定写成后端纯函数:next_step(final):有 error 级问题时,已自查过就判失败、老后端才重来一次;autorun=false 只建不跑;reply 和 asked 直接收尾;其余运行。fill_input(graph, question, history):规则和 fillInput 相同。retry_after_review(review, attempt):retry 为真、severity=broken、attempt+1<2。retry_instruction(question, reason, has_base):两种措辞原样搬过来。/copilot/review 的结果加上 retry_instruction,final 加上 next(run|ready|fail)。前端改成照后端的决定走;老后端没有这两个字段时退回现有逻辑。评测用无头驱动 run_turn() 把这几步串起来:answer_ops → next_step → launch_run(origin=eval) → 等终态(停在审批的取消掉,记「评测不代批」)→ 复核(用从 review_run 抽出来的同一个函数)→ 需要时带原图重来一次 +- 复用:engine/review.scan/should_retry/review;engine/launch.launch_run;run_manager.wait_idle/cancel;前后端常量对照测试的写法 +- 文件:新增 backend/app/chat/__init__.py、backend/app/chat/turn.py、backend/tests/test_chat_turn_policy.py(读 chat.ts 比 MAX_ATTEMPTS、fillInput 的字段规则);改 backend/app/api/copilot.py(review_run 拆出 review_of_run,返回 retry_instruction;final 带 next)、frontend/src/store/chat.ts(plan、settle 照 next、retry_instruction 走) +- 对上的理念:界面层不放逻辑;一种说法:评测量的就是用户拿到的那一轮,不另抄一份;照实说:停在审批照实记 +- 否决的备选:在评测里另写一份 Python 版编排:两份迟早走样,量出来的不是用户得到的结果。把整轮编排搬到后端、问数据页只负责播放:方向是对的,但要重做重连、断点、hydrate、取消,应该单独立项,本轮只搬「决定」。用无头浏览器驱动问数据页:慢、脆,成本还翻倍 +### 2. 生成过程抽成可直接调用的 answer_ops +- 做法:把 event_stream(copilot.py:1806–1919)原样搬成 async generator answer_ops(payload, *, catalog_view=None),产出的 op dict 不变,SSE 路由只负责包一层 _sse。开流前的准备(_sources、load_checkers、_look_service、_graph_system)一起搬走,用自己的短会话。评测直接迭代它,拿 model/look/check/final/reply/error 这些 op +- 复用:Generation、GraphApplier、GraphChecker、_layout_keeping、validate_graph、authored_issues 都不动 +- 文件:backend/app/api/copilot.py;新增 backend/tests/test_copilot_answer_ops.py(同一个剧本下,SSE 输出逐条和改之前一样) +- 对上的理念:一种说法:评测和问数据页走同一条生成路径 +- 否决的备选:评测经 HTTP 调自己的 SSE 接口:要伪造请求身份,还要再解析一遍 SSE。另写一份简化的生成:量不准 +### 3. 用量计量:助手建图、挑表、复核 +- 做法:新增 providers/usage.py,meter() 包住 get_usage_metadata_callback,按模型累计 input/output tokens 和调用次数。金额用 catalog.estimate_cost 算,价格目录里没有的模型记 unpriced_calls,和 _usage_of_events 同一个口径。只包生成(含挑表、接着写、自查修正)和复核这两段,不包 launch:运行任务会继承上下文变量,包进去会把运行里的调用重复计一遍。answer_ops 结束时多发一条 {op:'usage', by_model, cost_usd, unpriced_calls},问数据页把它记进这一轮的 meta.usage,界面上这一轮的花费 = 助手 + 运行 + 复核 +- 复用:langchain_core.callbacks.get_usage_metadata_callback(1.6.3 自带);providers/catalog.estimate_cost、unpriced_call +- 文件:新增 backend/app/providers/usage.py、backend/tests/test_usage_meter.py;改 backend/app/api/copilot.py、frontend/src/store/chat.ts(meta.usage)、显示这一轮花费的地方(lib/terms.ts runCostText 一类) +- 对上的理念:照实说:成本不低报,估不出的写「金额无法估算」;生效可查 +- 否决的备选:在每个调用点手写累计:挑表、BudgetFallback、复核各一处,迟早漏掉一处。只算运行的用量:问数据一轮里建图那一大块就没算进去 +### 4. 运行来源 eval 和「计入统计」谓词 +- 做法:ORIGIN_KINDS 加 eval,只能由服务端写,不进 CLIENT_ORIGINS。origin 记 {suite_id, eval_run_id, case_id, role: answer|gold|gold_after, arm, repeat},workflow_name 写成「评测:题集名·第 n 题」。新增一个 SQL 谓词 counted_runs(),写法同 hidden_runs(用 coalesce 防 NULL)。探索问题聚类、工具白名单衰减、目录的使用次数和「被哪些运行用过」,以后口径账本的统计,都只算这个谓词圈进来的运行。记录页默认不列评测运行,筛选里有「评测」,列表底下照实写「另有 N 次评测运行未列出」 +- 复用:engine/origin.py;auth/access.hidden_runs 的写法;list_runs 的 origin_kind 筛选 +- 文件:backend/app/engine/origin.py、backend/app/auth/access.py、backend/app/api/governance.py、backend/app/data/catalog.py:2406/2444、backend/app/data/catalog_review.py:217、backend/app/api/runs.py:157、frontend/src/typesApp.ts(OriginKind)、frontend/src/lib/terms.ts(ORIGIN_LABEL)、frontend/src/pages/runs/*、backend/tests/test_frontend_type_unions.py、新增 backend/tests/test_eval_origin.py +- 对上的理念:留痕:评测运行是完整的运行,可查、证据点得开;照实说:不悄悄藏起来,写明有几次没列出;生效可查 +- 否决的备选:把评测放进单独的库:证据、工件、封存都在主库里,拆开要两套会话、两套封存。跑完就删:丢了「为什么答错」的现场。不做标记:聚类和使用次数马上被评测题刷高 +### 5. 目录视图:读目录统一走一个接缝 +- 做法:新增 data/catalog_view.py,定义 CatalogView{mode: live|snapshot|off, statuses: 全部(去掉 rejected)| 只要 verified 和 confirmed, snapshot_artifact}。消费目录的四处一律经它取:tools/datasource._catalog_of(运行时工具和冻结快照)、look/tables.TableKind.load(助手查看)、sqlcheck.load_checkers(自查)、dual_check 的两处。运行从 Run.options['catalog_view'] 读视图(launch_run 已经收 options,会进 run.started 和封存),生成过程从 answer_ops 的参数读。缺省是 live,行为和现在一样。visible_notes 加一个 statuses 参数。冻结进表结构快照时,meta 里记下 catalog_view,证据面板能写「本次运行目录:关(评测对照)」。再加一条 lint 测试:这几个消费模块里不许直接 read_catalog,也不许直接查 CatalogNote +- 复用:catalog.visible_notes、frozen_catalog;Run.options 的快照语义 +- 文件:新增 backend/app/data/catalog_view.py、backend/tests/test_catalog_view.py;改 backend/app/tools/datasource.py、backend/app/look/tables.py、backend/app/api/copilot_context.py、backend/app/data/sqlcheck.py、backend/app/engine/nodes/dual_check.py、backend/app/data/catalog.py +- 对上的理念:生效可查可对账:这一轮用的是哪份目录写进封存;一种说法:只有一处取目录;数据目录只放事实:不动目录的内容 +- 否决的备选:用上下文变量传视图:审批恢复、重启续跑之后就丢了。评测期间把线上目录关掉:真实用户跟着受影响。只关提示词、不关自查:量出来的只是一半的贡献 +### 6. 目录快照与影子起草 +- 做法:EvalCatalogSnapshot 把选定数据源的目录(每张表的 version 加 notes)存成内容寻址工件(kind=eval_catalog_snapshot),记下谁、什么时候拍的,以及来源:当前目录,或者影子起草(用哪个起草模型、起草要求、是否先去掉模型起草的项)。影子起草调 catalog_draft.run_model_draft,Hooks 把每一段写进内存里的目录副本,合并用 catalog 的纯函数;不调 write_entry、不碰线上目录。起草只看表结构、不查业务库,所以不占数据源的队,只起草题集用得到的表,开始前先估算花费。评测的变体用快照 id 指定目录;同一份快照能被多次评测引用,用来做「起草模型 A 的目录 vs 起草模型 B 的目录」 +- 复用:catalog_draft.run_model_draft 和 Hooks;catalog 的合并纯函数;artifact_store.put_json;draft_model 起草模型的取值链 +- 文件:新增 backend/app/eval/snapshots.py、backend/tests/test_eval_snapshots.py;改 backend/app/data/catalog_draft.py、backend/app/data/catalog.py(需要的话把合并入口暴露成纯函数) +- 对上的理念:对照不改线上目录(只读的精神);留痕:快照不可变、记来源;一种说法:起草还是同一个起草引擎 +- 否决的备选:让用户先用模型 A 起草线上目录、拍快照,再用模型 B 重新起草:为了做对照改了线上,真实用户跟着受影响。给目录加历史版本表:范围太大,评测只需要不可变的快照 +### 7. 题集、题目、评测、结果的数据模型 +- 做法:新增 db/models_eval.py 和 api/evals.py。 +EvalSuite:名称、说明、默认变体、预算、是否归档、乐观锁 version、created_by。 +EvalCase:suite_id、seq、kind(ask 问数据题 | workflow 钉版本的工作流题)、question(可以用定时任务的同一套变量)、datasource_ids、workflow 题的 workflow_id@version 和输入模板、expectations、gold 来源(sql: 数据源 + SQL 模板;caliber: workflow_id/version/node_id/metric_id + 输入模板;from_run: run_id、查询编号、确认人和确认时间)、tags(口径陷阱、拒答、清单、相对日期……)、enabled、version、case_hash。 +EvalRun:suite_id;status 用后台任务的状态词;trigger manual|schedule|pair;variant 原样存一份,另存实际解析出的模型 id;fingerprint(提示词指纹、目录快照 id 和哈希、各数据源 schema 哈希、题目哈希、评分规则版本);pair_id/arm;baseline_id;预算快照;计数;按角色的用量;stop_reason、error、created_by、起止时间。 +EvalResult:eval_run_id、case_id、case_hash、repeat、status(用逐项状态词)、run_id、gold_run_ids、path(reply|asked|ready|build_failed|run_failed|ran)、verdict(pass|fail|ungradable|skipped)、scores、review、tier、judge 计数、looks、calls、分段耗时、按角色的用量、sql_match、details_artifact、current(正在做什么)、error。 +题目改过 case_hash 就变,和上一次比时标「题目改过,不可比」 +- 复用:TimestampMixin、new_id;部分唯一索引和乐观锁的写法(CatalogProfileJob、Schedule.version) +- 文件:新增 backend/app/db/models_eval.py、backend/app/api/evals.py;改 backend/app/db/base.py(导入新模型,由 create_all 建表)、backend/app/main.py(路由挂 builder 级) +- 对上的理念:留痕;生效可查可对账:每次评测记全指纹;不做从指标定义编译 SQL:期望答案用人写的或治理过的 SQL,不从自然语言生成 +- 否决的备选:一张「kind + JSON」的通用任务表:丢掉强类型列,catalog_job_runtime 的说明里已经否决过。题目存成 YAML 进仓库:部署实例上的真实题目不能进公开仓库,改题也没有留痕 +### 8. 期望答案:SQL 探针和口径探针 +- 做法:每道题作答前算一次期望,作答后再算一次;两次不一样就记「作答期间数据变了,不判」。 +SQL 题:建一张 input → 调用工具节点(db_query__数据源,args.sql=渲染后的 SQL)→ output 的裸图,以 role=gold 跑探索运行。得到的是封存的查询快照,期望值取快照里指定的格、列或整张表。 +口径题:按 look/calibers 的可复用规则,从钉住版本的图里拷出这张卡读的取数节点(id、配置都不变),后面接一个 caliber_from 指向 workflow_id@version 的口径卡,跑探索运行,期望值就是口径卡的指标(取整、单位、比率都由口径卡自己定)。不可复用的卡不能当口径题,照实写原因。 +从运行认定:把某次问数据运行里的一条查询 SQL 拷进 SQL 题,人在界面上确认(记确认人、时间、出自哪次运行);之后每次评测都按这条 SQL 重算,不冻结数值。 +变量:题目和 SQL 里的 {{schedule.*}} 用定时任务那份 VARIABLES/template_values,按评测的「截至时间」渲染。 +保存题目时试算一次:过只读守卫;用到遮罩列的一律拒收(认法和证据面板的派生遮罩相同,认不准就拒);取不到值、超时都照实说。 +A/B 两臂共用同一次算出的期望答案 +- 复用:launch_run(裸图探索运行);调用工具节点;metrics 节点的 caliber_from;look/calibers.card_inputs/views_of;scheduler/service.VARIABLES/template_values;data/guard.check;engine/masking 的派生遮罩 +- 文件:新增 backend/app/eval/gold.py、backend/tests/test_eval_gold.py;改 backend/app/api/evals.py(试算接口) +- 对上的理念:叙述层无算术:期望里的比率由 SQL 或口径卡算;只读与遮罩;不挡生成:期望算不出来记「无法评分」,不算模型答错;模板定骨架:口径题考的就是骨架里的那一段 +- 否决的备选:发起钉版本的正式运行当期望:会被 pick_base 和应用视图的「最近一次封存」取到,正式运行也不该为了评测而发。引用最近一次封存的正式运行:数据一直在变,期望会过期。直接调 run_query 不建运行:期望没有封存快照,事后说不清当时查的是什么。人填数:见 D4 +### 9. 评测执行:后台任务、并发、预算、止损 +- 做法:新增 eval/runner.py,用后台任务运行时。一次评测是一个任务,每题 × 重复次数 × 臂是一个逐项任务。进度和正在做什么(正在算期望 / 正在建图 / 运行中 / 正在复核 / 正在评分)随时落库;能停,能只重试没做完的;服务重启标中断、不自动续跑,因为复制出来的数据目录启动时不能自己去调模型、查业务库。 +并发:同时最多跑 max_parallel 题(缺省 2),且不超过 max_concurrent_runs 的四分之一;A/B 两臂的同一题同时开跑。每题时限缺省 900 秒,超时取消运行、记超时。 +预算:开跑前按上一次的每题平均花费估算并显示;另设单次评测金额上限和评测每日金额上限(单独一本账)。每题开跑前预留,结束后按实际找补;触顶就不再开新题,剩下的记「已到上限」。 +止损:连续 3 题助手调用失败,或最近 10 题里有 7 题超时,就停下,原因照实写。 +不占数据源的队:开始时拍一份目录快照(缺省「当前目录」也拍),这样起草、剖析照常进行,也不影响这次评测的可比性。各数据源的上传表格版本和 schema 哈希,开始和结束各记一次,变了在结果里标出 +- 复用:data/catalog_job_runtime.py(上移,见 D13);engine/spend 账本(泛化后);launch_run;run_manager.cancel +- 文件:新增 backend/app/eval/runner.py、backend/tests/test_eval_runner.py;改 backend/app/main.py(startup/shutdown)、backend/app/core/job_runtime.py +- 对上的理念:不挡生成:评测的并发和账本独立,不挤占真实用户的运行和裁判、核查的额度;照实说:停在哪、为什么;留痕 +- 否决的备选:每题一个同步 HTTP 请求:几十道题要跑几十分钟,关掉页面就没了。占住数据源的队来保证目录不变:一次评测几十分钟,期间起草、剖析全被挡住。和裁判或核查共用每日账:评测一跑,当天的报告就因为到了上限而降档 +### 10. 评分 +- 做法:新增 eval/score.py。评分规则版本 SCORER_VERSION 记进结果,可以「按新规则重新评分」:只读封存的运行和期望快照,不再调模型、不再查库。 +期望种类:scalar(值 + 允许的相对误差,可以带维度键,如 {region: 华东})、table(多行,按维度键配对,可以要求前 N 名和顺序)、set(名单)、none(期望答「没有数据」:查询返回 0 行,答案里也没有数)、ask(期望反问)、text(见 D4)。 +从答案里取数:报告文档里 resolved 的 value 段(取 cite.value 原值,换算不影响),以及指标引用。正文裸写的数按出具校验同一套认法取(_NUM_RE、_SUFFIX_MULT、中文数字),容差按显示精度推。 +每个期望有五种结论:对且可追溯(引用的格或指标对上了)、对但没引用、错、量纲不同(比如差 100 倍,单列)、不能评分(遮罩、期望没算出来、数据在变)。 +维度配对:读被引用那一格所在快照行的非数值列,和期望的键比。期望值是 0–10 的小整数又没给键时,标「弱匹配」。 +题目结论:必需的期望全对就算通过;另外单报「可追溯通过」(全部经引用)。路径不对(该查不查、该答不答)直接不通过,并写明原因。 +答错的题,把引用那格所在查询的 SQL 和期望 SQL(口径题用口径取数节点的 SQL)用 explain_sql_diff 比一次,给出等价与否和差在哪个方面,这就是逐题的 E1。 +数值比较用双盲复算同一个函数(把 _agree 提到公共模块),设置里也叫「允许的相对误差」 +- 复用:engine/evidence 的文档结构和 iter_units;api/evidence 的封存读取(_sealed/_reports 公开成函数);issuance 的数字认法;dual_check._agree/_delta_pct;sql_semantics.explain_sql_diff;遮罩 loader +- 文件:新增 backend/app/eval/score.py、backend/app/engine/tolerance.py(_agree 搬到这里)、backend/tests/test_eval_score.py;改 backend/app/engine/nodes/dual_check.py、backend/app/api/evidence.py +- 对上的理念:叙述层无算术:叙述里自己算出来的数只能算「对但没引用」,不算可追溯;照实说:不能评分的单列,既不算错也不算对;只读与遮罩;一种说法 +- 否决的备选:用模型判答案对不对(LLM 当裁判)作主指标:结果是概率性的,换个裁判模型准确率就变。只看答案里有没有这个数:巧合会被算对(3 家门店正好等于期望的 3)。拿答案原文和期望原文比字符串相似度:数字的写法千变万化 +### 11. 指标汇总与统计 +- 做法:新增 eval/stats.py,所有汇总在后端算好,界面直接显示: +- 准确率和可追溯准确率,附 Wilson 95% 区间和样本数; +- 作答路径的分布; +- 出具档位分布:声明了出具的按 formal/degraded/withheld 分,没声明的单列「探索问答」; +- 复核结论分布(ok/annotated/rewritten/reverted/failed × degraded/broken),以及自动重来的次数; +- 结论裁判:变体里打开了才判,走按需裁判同一条路,事件落在封存之后(post_seal),花费记在评测的账上;按 supported/partial/contradicted/insufficient/未判 分; +- look:每题次数(分模型和系统)、触顶比例; +- 主模型调用次数; +- 耗时:建图、运行(墙钟和执行)、复核、整题,给 P50/P90; +- token 和成本按角色分:助手 = 建图 + 挑表 + 复核;写作 = 运行里的 llm.end,按 model 分;裁判;期望探针为 0; +- 报告引用率:numbers_cited/numbers、uncited_numbers、unresolved、unknown_entities; +- 逐题 E1 命中率。 +重复跑 k 次的题给通过率 +- 复用:runner._usage_of_events;engine/labels 里现成的叫法(ISSUANCE_LABEL 等);doc.stats +- 文件:新增 backend/app/eval/stats.py、backend/tests/test_eval_stats.py +- 对上的理念:照实说:给区间和样本数;界面层不放逻辑 +- 否决的备选:让前端自己算分布:同一个数会有两套算法。只给一个总分:看不出退步出在建图、查库还是写作 +### 12. 和上一次比、A/B 对照 +- 做法:选基准:同一题集、同一变体(模型、目录模式)里,比这次先发起、已经结束的最近一次;也可以用用户钉住的基线。选法同 pick_base,按发起先后,不按结束时间。 +逐题转移:通过→不通过(退步)、不通过→通过(进步)、不变。 +变化归因:期望值变了 →「数据变」;期望没变、答案执行的 SQL 规范文本不同 →「做法变」;只有字面量不同 →「只参数变」(多半是时间范围理解变了);作答路径变了 →「路径变」;其余写「说不清」。SQL 的 same/params_only/changed 和运行对比用同一条规则。 +顶部列出两次之间指纹里哪几项变了:助手模型、写作模型、目录快照、提示词指纹、表结构、题目、评分规则。 +A/B:一次发起两臂(共用 pair_id),共用期望答案,逐题同时跑。给出两臂各自的准确率、配对表(都对 / 都错 / 只 A 对 / 只 B 对)和精确 McNemar 双侧 p 值;p≥0.05 时写「这批题上看不出差别」。 +「目录的贡献」就是一组预设的 A/B:当前目录 vs 关,可以再加第三臂「只用已核实和已确认的项」 +- 复用:api/compare.pick_base 的选基准规则;engine/compare 的 SQL 三分规则;sql_semantics.normalize_sql +- 文件:新增 backend/app/eval/compare.py;改 backend/app/api/evals.py +- 对上的理念:生效可查可对账;照实说;一种说法:变化的叫法和运行对比一致 +- 否决的备选:两臂先后各跑一整批:两批之间数据变了,分不清差别来自目录还是来自数据。只比总准确率:「修好 3 题、弄坏 3 题」会被掩盖 +### 13. 持续指标 E1、E3 +- 做法:新增 eval/signals.py,做成一个「扫描」后台任务,可以手动跑,也可以跟着题集的定时一起跑。默认窗口 30 天,只看计入统计的问数据运行(counted_runs 且 origin 为 chat)。 +E1 口径命中率:口径集合取 CaliberKind(各工作流最新已发布、未退役版本里的源头口径卡)。每张可复用卡的取数 SQL 用 sqlcheck.tool_query 取出,先把 {{ }} 换成占位字面量再规范化,这一步就是设计文档 §3.6 里的「绑定参数」。问数据运行里 agent 执行过的查询(取查询快照,去掉核查角色的)只和同一数据源、用到相同表的口径 SQL 比,explain_sql_diff 带上 schema_cache。指标是用到相同表的查询里等价的比例,附差异方面的分布和最常见的不等价配对(留给以后的口径对账看)。结论按 (SQL 指纹, 口径 SQL 指纹) 缓存。 +E3 问题重复度:取问数据各轮的问题原文(conversation_turns.question,不含 history;同一轮里的自动重来只算一次),用本地向量贪心聚类,阈值同现有接口的 0.62。指标是成员不少于 3 的类覆盖的问题占比;另给规范化后原文完全相同的比例,当下限;记下用的是哪个向量模型。 +两条都画趋势,参考线用设计文档 §3.8 的判据(E1 ≥30% / <10%,E3 ≥20%)。 +顺手修 /governance/exploratory-clusters:改用同一个聚类函数,只取问题,排除评测运行。成员不少于 3 的类可以「加入题集」(只带问题,期望答案仍要按 SQL 或口径给),也可以「提取模板」 +- 复用:look/calibers;sqlcheck.tool_query;sql_semantics;memory/embeddings.embed_texts/cosine;governance 的聚类;后台任务运行时 +- 文件:新增 backend/app/eval/signals.py、backend/tests/test_eval_signals.py;改 backend/app/api/governance.py +- 对上的理念:生效可查:指标可以复算,扫描结果留痕;照实说:向量模型弱时写明这是下限;不做从指标定义编译 SQL:只比较实际执行的 SQL 和口径里的 SQL +- 否决的备选:照设计文档做一次性实验脚本:量一次就过期,换模型、改提示词之后没人再量。聚类继续用整份 input JSON:history 会把同一段对话里的不同问题聚成一类,E3 偏高 +### 14. 口径题的升版处置 +- 做法:口径题钉 workflow_id@version。开跑前对探针图调一次 unresolved_caliber_upgrades,和正式运行用的是同一个判断。有了新的已发布版本,或者钉住的版本退役了,这道题就跳过,写「口径已升版到 vN,待处置」。题目上三选一:recompute(改钉新版本,历史在这里断开,和上一次比时标不可比)、dual(新旧两版各算一个期望,都报)、incomparable(旧题归档,按新版本另建新题、重开基线)。没有默认值 +- 复用:engine/governance.unresolved_caliber_upgrades;UPGRADE_POLICIES 及其叫法 +- 文件:backend/app/eval/gold.py、backend/app/api/evals.py +- 对上的理念:口径升版是被迫处置事件 +- 否决的备选:自动跟到最新版本:上周和这周的分数悄悄换了口径。继续用旧版本又不提示:评测一直在量一份已经退役的口径 +### 15. 定时与通知 +- 做法:Schedule 加 target_kind(workflow | eval_suite)和 target_id。题集的定时把 workflow_id 存成空串(SQLite 放宽不了 NOT NULL,见风险),input_template 留空,run_class 不用。fire_once 按 target_kind 分支:是 eval_suite 就建一次评测,变体用题集的默认,基准用上一次同变体的那次。上一次评测还没结束时记 skipped_overlap;错过的触发按同样的宽限规则补。ScheduleFire 加 eval_run_id。通知:Webhook 和站内渠道各加两类事件,eval.regressed(有退步)和 eval.failed(评测失败)。消息里只有题集名、通过数、退步题数和链接,不放期望值,也不放答案里的数。定时页新建时先选「工作流」还是「评测题集」 +- 复用:scheduler/loop 的认领与补跑;cron;ScheduleFire;notify/core 的发送与签名;notify/service 的订阅 +- 文件:backend/app/db/models.py(Schedule、ScheduleFire 新列)、backend/app/db/base.py(_COLUMN_MIGRATIONS)、backend/app/scheduler/service.py、backend/app/scheduler/webhook.py、backend/app/api/schedules.py、backend/app/notify/service.py、前端定时页,新增 backend/tests/test_eval_schedule.py +- 对上的理念:一种说法:定时只有一套;遮罩:通知不带数 +- 否决的备选:题集自带 cron 和一套调度:两个地方设定时,两套补跑规则。只支持手动:「每周换模型前后」之外的退步(目录被改、表结构变了)发现不了 +### 16. 清理与保留 +- 做法:设置项 eval.keep_runs:缺省每个题集保留最近 20 次,加上钉住的基线和被 A/B 引用的那一对。更早的评测由清理任务删掉:评测结果行、明细工件、底下的运行(事件跟着级联删),再删这些运行在 artifacts 里的引用行。工件文件要等删完之后一条引用都不剩才删,因为同样的内容可能被别的运行引用。每次评测的总表(分数汇总)永久保留,界面上写「明细已清理」。新增 artifact_store.forget(ids),按这条规则回收。评测运行全是探索运行,删正式运行的那道拦截不受影响 +- 复用:delete_run 的规则;artifacts 引用表 +- 文件:新增 backend/app/eval/cleanup.py、backend/tests/test_eval_cleanup.py;改 backend/app/core/artifact_store.py +- 对上的理念:可清理;留痕:总表不删;照实说 +- 否决的备选:不清理:每次评测几十次运行加查询快照,库和工件目录只涨不降。按时间一刀切:会把还在当基线的那次删掉 +### 17. 设置、权限与账本 +- 做法:设置加一组 eval:max_parallel、per_case_timeout_s、per_run_max_usd、daily_max_usd、keep_runs、裁判缺省开关,照现有规矩 builder 读、admin 写。engine/spend.py 泛化成 Ledger(key),三本账各管各的:核查 verify_spend、裁判 judge_spend(judge.py 的 _charge/_settle 改用它,行为不变)、评测 eval_spend。新增 GET /api/settings/eval/spend;get_settings 不回写这本账。评测接口整组挂 builder 级(同定时、治理),期望值和查询原文只给 builder 及以上;使用者看不到评测页,也查不到评测运行 +- 复用:engine/spend.py;裁判的账;settings 的 role_by_method +- 文件:backend/app/engine/spend.py、backend/app/engine/judge.py、backend/app/api/settings.py、backend/app/main.py,新增 backend/tests/test_spend_ledger.py +- 对上的理念:不挡生成:账本各自独立;一种说法:第三本账不再抄一份代码 +- 否决的备选:评测不设上限:一次误操作就可能跑出 3 臂 × 5 次 × 100 题。和核查共用一本账:互相挤占 +### 18. 界面 +- 做法:顶级导航加「评测」,builder 以上可见,排在设置前面,快捷键 ⌥0。各部分: +- 题集列表:最近一次通过率、趋势、定时。 +- 题集详情:题目表。加题有四种方式:写 SQL 定期望(选数据源、写 SQL、点选格或列、试算);选口径(和蓝图助手同一份口径清单:工作流|口径卡|指标);从问数据运行认定;期望没有数据或期望反问。有口径升版待处置的题,顶部给提示。 +- 发起评测:选变体(助手模型、写作模型、裁判模型或不判,目录选当前 / 快照 / 只用已核实和已确认 / 关,重复次数),显示预估花费,预设「目录贡献对照」。 +- 评测详情:进度和每题正在做什么,能停止、能重试;总览卡片和各项分布;逐题表列出结论、期望和答案里的数、有没有引用、SQL 差在哪、look、耗时、花费,点开去运行详情。 +- 对比:指纹差异、转移、归因。 +- A/B 配对表。 +- 「口径命中与重复度」页签:E1、E3 趋势,最近一次扫描,成员不少于 3 的类,可以加入题集或提取模板。 +叫法统一放进 lib/terms.ts 的 EVAL_TEXT。记录页里的评测运行显示来源「评测」,能跳回对应的那道题 +- 复用:Tabs、StatusPill;runs/Telemetry 的读数样式;CommandPalette 的 PAGES;对比页的转移展示;剖析任务的进度展示 +- 文件:新增 frontend/src/pages/evals/*、frontend/src/typesEval.ts、scripts/check-evals.mjs;改 frontend/src/components/CommandPalette.tsx、frontend/src/App.tsx、frontend/src/api/client.ts、frontend/src/lib/terms.ts、frontend/src/pages/runs/*、前端定时页、scripts/check-shell.mjs(导航高度) +- 对上的理念:界面层不放逻辑;一种说法 +- 否决的备选:放进设置页:评测是日常工作,不是配置。放进治理页:现在没有治理页,治理接口在界面上一个调用方都没有 +### 19. 题集导入导出与零售样例题集 +- 做法:题集可以导出成 JSON(题目、期望来源、变量、标签;不含任何期望值、答案或运行),导入时按数据源名对上。仓库里附一份「零售样例题集」,配 retail.py 的虚构库,包含:支付日归属、退款、取消、华南高于华东;一道问「华中区」、期望答没有数据的题;一道问法含糊、期望反问的题。用于演示、验收和真实模型回归。真实业务的题集只在部署实例上导入,不进公开仓库 +- 复用:catalog_transfer 的导出导入做法;tests/fixtures/acceptance/retail.py +- 文件:新增 backend/app/eval/transfer.py、backend/tests/fixtures/eval/retail_suite.json +- 对上的理念:真实数据不进公开仓库;只读 +- 否决的备选:把题集硬编码在测试里:部署实例上用不了 + +## 待拍板(本块) +- D1 问数据一轮的「决定」(要不要运行、要不要重来、重来时说什么、输入怎么填)放在哪|选项:A 下沉到后端(chat/turn.py),问数据页照后端给的 next 和 retry_instruction 走,评测用同一套函数 / B 评测里另写一份 Python 版编排,加前后端对照测试防止走样 / C 整轮编排搬到后端,问数据页只负责播放|推荐:A | 评测必须量用户真正拿到的那一轮。A 只搬「决定」,前端少量改动就能做到一处定义,也符合界面层不放逻辑。B 有两份实现,靠测试只能挡住常量,挡不住流程上的差别。C 方向对,但要重做重连、断点、取消,应该单独立项 +- D2 评测运行怎么做到不污染正式数据|选项:A 运行来源记 eval,加一个计入统计的谓词(聚类、白名单、目录使用次数都按它算),记录页默认不列评测运行,并写明有几次没列出 / B 评测用单独的库 / C 跑完就删|推荐:A | 保留现场(证据点得开),统计又不失真。B 要拆证据、封存和工件。C 丢了答错的原因 +- D3 口径题的期望答案怎么算|选项:A 口径探针:拷出钉住版本里口径卡读的取数节点,接一个 caliber_from 口径卡,跑探索运行 / B 发起钉版本的正式运行 / C 引用最近一次封存的正式运行|推荐:A | A 用的是同一张卡、同一套取整和单位,结果封存可查,又不会被 pick_base 和应用视图当成「最近一次」。这个机制也正好是以后口径对账「在同一份数据上再跑一次」要用的。B 会污染正式运行的语义和应用视图。C 数据在变,期望会过期。代价是只有可复用的卡(输入全是查库格)能当口径题 +- D4 允不允许人工填写期望值|选项:A 数值一律不许人填,只能来自 SQL、口径或从运行认定的 SQL;文字要点题由裁判模型判,单列「模型判断」,不计入准确率 / B 允许人填数,标「人填」 / C 不做文字题|推荐:A | 符合「期望答案不靠人填数」和照实说。文字题本来就是概率性的判断,不能和确定性的准确率混在一起 +- D5 目录对照分几档|选项:A 当前目录 / 只用已核实和已确认的项 / 关,加上目录快照;预设对照是「当前 vs 关」 / B 只有开和关 / C 再细分到「只关提示词」「只关自查」|推荐:A | 「只用已核实的项」能量出模型起草、还没审过的那些项是帮忙还是添乱,直接关系到审阅值不值得做。C 增加的组合数和价值不成比例 +- D6 「起草模型」怎么做对照|选项:A 影子起草:用指定的起草模型把目录起草进评测专用的快照,不动线上目录 / B 先后起草两次线上目录,各拍一份快照 / C 本轮不做|推荐:A | 做对照不应该改线上目录,真实用户会跟着受影响。影子起草复用同一个起草引擎,约 300–400 行。按表计费,大库很贵:只起草题集用得到的表,开始前先估算 +- D7 评测的定时放在哪|选项:A 扩展现有定时任务,加 target_kind=eval_suite,定时页统一设置 / B 题集自带 cron,另起一套调度|推荐:A | 一种说法:认领、补跑、重叠跳过、触发记录、通知都只有一套。代价:Schedule.workflow_id 非空,评测目标要存空串,定时页多一个目标选择 +- D8 评测界面放在哪|选项:A 顶级导航新增「评测」(builder 以上可见,⌥0) / B 作为记录页的一个页签 / C 放进设置页|推荐:A | 评测是搭建者经常用的独立工作,E1、E3 和聚类结果现在也没有地方放。代价:导航从 9 项变成 10 项,要在 600、520、480 像素高的窗口里重新量一遍 +- D9 预算和并发的默认值|选项:同时 2 题;每题 900 秒;单次评测 2 美元;评测每日 5 美元(单独记账);每个题集保留 20 次 / 其他数值|推荐:按第一项的数值 | 全局运行并发是 20,2 题不会挤占真实用户。部署实例上的写作模型较便宜,100 题一次大约在这个量级。这一点没有实测,第一次真实模型验收后要按实际花费调整 +- D10 统计口径和什么情况下发「有退步」通知|选项:A 定时跑 1 次、对照默认跑 3 次;给出 Wilson 区间和精确 McNemar 检验,只展示不卡门;有一道题从通过变成不通过就发「有退步」通知 / B 只在差异显著时才通知 / C 不通知|推荐:A | 退步落到具体某道题上,人能直接去看;小题集上显著性很难达到,只按显著性通知会把真实的退步漏掉 +- D11 E1、E3 要不要直接做成持续指标,替代设计文档 §3.8 的一次性实验|选项:A 是:做成扫描任务,按窗口出趋势,判据沿用 §3.8 / B 先在仓库外按 §3.8 做一次性实验,再决定|推荐:A | 有了评测的底座之后,持续指标的增量成本只有约 500 行。一次性实验量完就过期,而且要把开发数据复制到 /tmp,这需要用户放行 +- D12 追问题(多轮)本轮做不做|选项:A 不做,只做单轮题;数据模型里留出多轮的位置 / B 做:评测自己建一段 kind=eval 的对话,按轮写进去|推荐:A | 多轮要和问数据页的历史拼法(_history_section 读 meta.looks、上一轮的图)完全一致,前提是轮次由后端落库,这属于 D1 里否决掉的那项整轮搬迁。现在硬做,又会多一份走样的实现 +- D13 后台任务运行时怎么复用|选项:A 把 data/catalog_job_runtime.py 上移到 core/job_runtime.py,原路径继续再导出 / B 评测直接从 data.catalog_job_runtime 导入|推荐:A | 运行时已经不只服务数据目录了;原路径再导出可以避免和正在改起草、剖析的工作冲突 +- D14 真实问数据也记助手建图、挑表、复核的用量吗|选项:A 记:answer_ops 发 usage,这一轮的 meta 记下来,界面显示这一轮的总花费 / B 只在评测里记|推荐:A | 照实说,现在问数据的花费是低报的;同一套计量顺便就用上了 +- D15 真实题集和真实评测在哪里跑|选项:A 真实题集只在部署实例(10.0.0.11)上建和跑;开发机只用零售样例题集 / B 把部署实例的数据拷到开发机上跑|推荐:A | 真实数据不出部署实例,也不进公开仓库。需要用户确认:在部署实例上跑评测会调模型(花钱)、会只读查询业务库,而且要重新部署之后才有评测功能 + +## 风险 +- 走样没有完全消除:决定下沉到后端只覆盖「决定」本身,重连、取消、复核 25 秒不等这些还在前端。评测里的复核总是等完,界面上超过 25 秒的复核用户其实看不到;结果里记下复核用时,超过 25 秒的单列 +- 随机性:同一配置跑两次结果不一样,小题集的区间很宽。默认展示区间和配对检验,对照默认重复 3 次,不拿单次总分下结论 +- 挤占生产:运行共用全局并发 20、没有优先级(runner.py:65)。评测并发限 2 且不超过四分之一,评测在跑时界面上标出来 +- 业务库压力:agent 的查询和期望探针都打业务库,还要作答前后各算一次期望。走数据源现有的查询时限,建议定时放在低峰 +- 数据漂移:作答前后期望不同的题不判;A/B 的同一题两臂同时跑 +- 目录接缝可能被绕过:以后新加的读目录代码要是绕过视图,对照就漏了。用 lint 测试禁止消费模块直接读目录,冻结快照里记下视图,证据面板看得出来 +- 遮罩:评测页会显示期望值和答案里的数。期望用到遮罩列的一律拒收,答案引用了遮罩格的不评分,接口只给 builder,通知不带数 +- 相对日期:提示词里没有今天的日期(没核实全部节点),「上周」的理解会抖。建议题目用定时变量写成明确日期,相对问法的题打标签单独看 +- 评分误判:巧合的数、认不出维度。用维度配对,小整数标弱匹配;评分规则带版本,可以重新评分 +- E1 可能显得偏低:规范化只做保持语义的变换,写法不同就判不等价(只会少给信任)。把模板 {{ }} 换成占位字面量可能改变语义(比如 IN 列表),换不了的记「比不了」,不记成不等价 +- E3 依赖向量模型:本地哈希的语义能力弱。同时给原文完全相同的比例当下限,并写明用的是哪个向量模型 +- 成本计量没核实:各接入在流式调用里给不给 usage_metadata(OpenAI 兼容接口要 stream_usage);get_usage_metadata_callback 隔着 BudgetFallback 包装能不能都截到。拿不到的照实写「金额无法估算」,要专门测 +- SQLite:Schedule.workflow_id 非空,ADD COLUMN 式的迁移放宽不了,只能存空串;评测写入多,单进程写锁会竞争。写入经 SerialWriter 串行,结果明细放进工件 +- 进程内任务:和起草、剖析一样,多进程部署时要换成库里的锁 +- 停在审批:评测里的运行停在审批(危险工具、人工节点)时,评测不代批,直接取消并记下;带人工节点的工作流题评不了,照实写 +- 影子起草的花费:起草模型按表计费,大库很贵。只起草题集用得到的表(从期望 SQL 推),开始前先估算 +- 过拟合:照着题集调提示词,分数涨了但泛化没涨。建议题集里留一部分「留出题」,只在定时里跑,调提示词时不看它们的明细 +- 和设计文档 §3.8 的关系:E1、E3 从「先做实验」变成持续指标,需要用户确认(D11) + +## 测试 +- test_chat_turn_policy.py:next_step 的各个分支(有 error 已自查、老后端重来、autorun=false、reply、asked);fill_input 和 chat.ts 的 fillInput 规则一致(读 chat.ts 比字段名正则和 MAX_ATTEMPTS);retry_instruction 的两种措辞;review 返回 retry_instruction +- test_copilot_answer_ops.py:同一个剧本下,SSE 输出和改之前逐条相同(含 reply、asked、check failed、error、截断);末尾多一条 usage +- test_usage_meter.py:剧本模型带 usage_metadata,生成、挑表、复核按模型累计;价格目录外的模型记 unpriced;meter 不包 launch 时,运行里的调用不会被重复计 +- test_eval_origin.py:客户端声明 origin=eval 被拒;探索问题聚类、工具白名单衰减、目录使用次数、「被哪些运行用过」都不计评测运行;记录页默认不列、筛选能列出来,接口返回未列出的数量 +- test_catalog_view.py:live、snapshot、只核实、off 四种视图在运行时 db_schema 的输出、助手查看结果、自查检查器、双盲复算里一致;冻结快照的 meta 记下视图;审批恢复后视图不变;lint:消费模块不许直接 read_catalog 或查 CatalogNote +- test_eval_snapshots.py:当前目录快照不可变;影子起草不写 catalog_notes(前后 version 不变)、不查业务库;「先去掉模型起草的项」选项生效 +- test_eval_gold.py:SQL 探针(变量渲染、只读守卫拒绝写语句、遮罩列拒收、超时照实说);口径探针(可复用的卡建图后算出的值和原工作流一致,不可复用的拒收并给原因);从运行认定会记确认人;作答前后期望不同时不判;口径升版待处置时跳过,三种处置各自生效 +- test_eval_score.py:scalar 的对且可追溯、对但没引用、错、量纲不同、遮罩不能评分;table 的维度配对和顺序;set;none;ask;弱匹配;叙述里自己算出的比率算「没引用」;重新评分只读、不调模型 +- test_eval_stats.py:Wilson 区间、精确 McNemar(和手算对照);转移与归因(数据变、做法变、只参数变、路径变);列出指纹差异 +- test_eval_runner.py:排队、并发上限、停止、只重试没做完的、重启标中断不续跑、单次和每日预算触顶、连续失败止损、每题超时会取消运行、停在审批会取消 +- test_eval_e2e.py:dual_check_shop 库加剧本模型,题目是「9/1–9/7 按支付日、不含退款的销售额」,期望 330。臂 A 按支付日答 330:通过、可追溯、SQL 等价;臂 B 按下单日答 310:不通过,差异方面是 time。目录对照:剧本只在查看结果里看到 business_date=paid_date 时才按支付日写,「当前 vs 关」的配对表为 1:0;再测一次和上一次比的转移 +- test_eval_signals.py:E1:模板 SQL 换成占位后和问数据 SQL 等价算命中,不同数据源不比,核查角色的查询不算;E3:只取问题、不取 history,同一轮的重来只算一次,有原文完全相同的下限,记下向量模型 +- test_eval_cleanup.py:保留规则(最近 N 次、基线、A/B 引用);删掉运行和引用行;被别的运行引用的工件文件不删 +- test_eval_schedule.py:题集的定时到点会建评测;重叠时跳过;错过的按宽限补跑;退步通知里不带数 +- test_spend_ledger.py:裁判、核查的行为不变;三本账互不挤占 +- test_frontend_type_unions.py:OriginKind 加上 eval,和服务端对上 +- 前端:新增 scripts/check-evals.mjs,在演示接入下走一遍:建题集、写 SQL 题并试算、遮罩列拒收的提示、发起评测看进度、停止和重试、结果表、对比和 A/B、E1/E3 页签;check-shell 的导航高度;tsc;文案检查 +- 真实模型验收(编排者在全部合入后跑):零售样例题集在 deepseek-v4.1-flash 上跑「当前目录 vs 关」各 3 次,人工抽查 5 道题的期望值和 SQL;核对没有污染(聚类、使用次数不变);核对总成本等于各运行的用量加生成和复核 + +## 验收 +- 一键从题集发起评测:界面上看得到每道题的进度和正在做什么,能停、能只重试没做完的;服务重启后标中断,不会自己续跑 +- 每道题的期望值都来自 SQL 或口径卡的封存快照(点得开),作答前后各算一次;没有人填的数值期望 +- 评分分开五种结论:对且可追溯、对但没引用、错、量纲不同、不能评分;准确率和可追溯准确率分开报,都带样本数和 95% 区间 +- 汇总给出作答路径、出具档位、复核结论、裁判结论(打开时)、look 次数、分段耗时、按角色的 token 和成本;成本里含助手建图、挑表、复核 +- 和上一次比:列出退步、进步和归因,并写明两次之间指纹的哪几项变了 +- A/B:同一题集「当前目录 vs 关目录」共用期望答案,给出配对表和 McNemar p 值;用影子起草的快照能做起草模型对照,线上目录前后的 version 不变 +- 助手、写作、裁判模型可以按变体切换,结果里写实际解析出的模型 id;节点自己点了模型的题照实标出 +- 评测运行在记录页能筛出来、证据点得开;聚类、工具白名单、目录使用次数都不受评测影响;记录页写明有几次评测运行没列出 +- 评测的每日花费单独记账,裁判和核查的每日额度不被占用;触顶时照实说 +- 期望用到遮罩列的题保存不了;评测页任何地方都不出现遮罩值 +- 口径题钉的口径升版之后,不处置就不会继续评 +- 题集能在定时页设定时;有退步时发通知,通知里不带数 +- E1、E3 每次扫描都留痕、有趋势,参考线同设计文档 §3.8;聚类接口只聚问题,不含评测运行 +- 问数据页行为不变(同一个剧本下 SSE 输出一致),这一轮多记了用量;前端照后端给的决定走 +- 清理之后总表还在、明细写明已清理;被共用的工件不会误删 + +## 工作量 +后端: +- 新增约 3,500–4,200 行。其中 eval 包约 3,000 行(gold 500、runner 500、score 600、stats 300、compare 300、signals 400、snapshots 350、cleanup 200、transfer 150、api 700),chat/turn 约 200 行,providers/usage 约 120 行,data/catalog_view 约 200 行,models_eval 约 250 行。 +- 改动约 900–1,200 行:copilot.py 抽出 answer_ops 和复核函数约 200 行(多为搬移);目录视图四处约 150 行;来源和计入统计谓词约 120 行;账本泛化约 150 行;定时、Webhook、通知约 350 行;设置约 80 行;artifact_store 约 60 行;evidence 和 dual_check 公开函数约 60 行。 +- 测试约 2,800–3,500 行,16–18 个文件。 + +前端:约 3,000–3,800 行。评测页 6–8 个组件,加类型、client、terms、定时页的目标选择、记录页的提示,chat.ts 照后端的决定走约 80 行;另有 check-evals.mjs 约 500 行。 + +建议拆成 5 个工作包,每包一个完整代理,最后统一复核一次: +- P1 底座与接缝:后台任务运行时上移、origin=eval 和计入统计谓词、目录视图、用量计量和 usage op、answer_ops 抽出、问数据的决定下沉加前端照做、账本泛化。约 2 天。 +- P2 题集与期望答案:模型和接口;SQL、口径、从运行认定三种期望来源;变量;遮罩拒收;试算;口径升版处置;目录快照和影子起草;导入导出;零售样例题集。约 2 天。 +- P3 评测执行、评分与对比:后台任务和并发、预算、止损;作答驱动;评分和重新评分;汇总统计;和上一次比;A/B;清理和工件回收;定时和通知。约 3 天。 +- P4 持续指标:E1、E3 扫描,修聚类接口。约 1 天。 +- P5 前端:评测页全部、导航、定时页、记录页、文案、check-evals。约 3 天。 + +依赖关系:P1 完成后才能做 P2、P3、P4;P2 完成后才能做 P3。P5 依赖 P2、P3 的接口:先把接口 JSON 的形状定下来,P5 就能和 P3 并行,最后接线。 + +按代理节奏,并行后约 6–7 个工作日;全部合入后,编排者另花约 1 天做完整关口和真实模型验收。 + +## 和其他几块的接口 +- backend/app/api/copilot.py(抽出 answer_ops、review 返回 retry_instruction、发 usage op):和任何改助手生成、口径账本「组合式问数」的工作同一个文件,这一块要先合 +- frontend/src/store/chat.ts(照后端的决定走、记 meta.usage):和改问数据页的工作冲突 +- 目录视图接缝改到 tools/datasource.py、look/tables.py、data/sqlcheck.py、engine/nodes/dual_check.py:和数据目录、剖析、按数据库类型(方言)改 sqlcheck 或 introspect 的工作同文件。用户说先不管数据库类型,但同文件仍要排好合并顺序 +- engine/origin.py 的 ORIGIN_KINDS、frontend/src/typesApp.ts 的 OriginKind、lib/terms.ts 的 ORIGIN_LABEL、test_frontend_type_unions.py:任何加运行来源的工作都要四处一起改 +- api/governance.py 的聚类:口径账本的「沉淀」也要用它。聚类函数只保留一份,评测和账本都调它 +- E1 里「口径 SQL 和执行 SQL 判等价、带绑定参数」,和口径账本 §3.4.2「认口径靠证明等价」是同一件事。建议放进一个公共模块(比如 look/calibers.py 旁边的 caliber_match.py),评测和以后的账本共用,不要各写一份 +- 口径探针(钉版本的取数节点加 caliber_from 口径卡,跑探索运行)就是口径账本 §3.4.3「口径对账:在同一份数据上再跑一次」要用的机制,账本以后可以直接复用 +- data/catalog_job_runtime.py 上移到 core/job_runtime.py 之后,catalog_jobs.py 和 catalog_profile_jobs.py 的 import 要跟着改;原路径保留再导出,可以避免冲突 +- engine/spend.py 和 engine/judge.py 的账本泛化:和改裁判、核查的工作同文件 +- db/models.py(Schedule、ScheduleFire 的新列)、db/base.py 的迁移列表、main.py 的路由和启动钩子:几乎每个包都会碰,要按合并顺序串行 +- 定时相关的 api/schedules.py、scheduler/service.py、scheduler/webhook.py、notify/service.py 和前端定时页:和其他改定时或通知的工作冲突 +- 导航改 components/CommandPalette.tsx 的 PAGES、App.tsx 的导航高度和 check-shell 的高度用例:同期如果别的包也加导航项,要一起量 +- api/evidence.py 里读封存报告的函数要公开成函数(_sealed/_reports),和证据面板的改动同文件 +- 部署实例 10.0.0.11:真实题集只在那里建,重新部署后才有评测功能;在那里跑评测要调模型、只读查业务库,需要用户同意 + +## 附:现状(代码位置) + +- 问数据一轮的编排在前端,后端没有能跑完一整轮问数据的函数。plan() 调 /copilot/generate-stream(intent=answer),收到 final 以后按 error 级问题和 autorun 决定失败、只建不跑还是运行。launch() 用 fillInput 填 {question, history} 后发 POST /api/runs(origin:'chat')。settle() 读库里的成果,调 /copilot/review,复核超过 25 秒就不等。review.retry 且 severity=broken 时,retry() 把原因和原图一起交回去重建一次(MAX_ATTEMPTS=2)。重来的那句话也是前端拼的 [frontend/src/store/chat.ts:1271 plan、:1301 retry、:1407 final 分支、:1500 launch(:1507 origin chat)、:1217 fillInput、:1238 historyOf、:1250 MAX_ATTEMPTS、:1754 settle、:318 REVIEW_TIMEOUT_MS] +- 生成过程全写在 SSE 路由里面:event_stream 内联了取数据源、读目录检查器、取助手模型、拼历史、组查看服务、Generation.draft/repair、自查、排版校验、final{graph, issues, autorun}。GenerateIn.provider/model 可以按请求覆盖助手模型,挑表和复核也走 copilot_model_spec,所以覆盖一次三处都跟着换 [backend/app/api/copilot.py:330 GenerateIn、:570 copilot_model_spec、:1758 generate_stream、:1806 event_stream;backend/app/api/copilot_gen.py:575 Generation(:597 calls 计主模型调用次数)] +- 问数据的搭图约定:input → agent(db_query__) → report → output。数字用 [[v:Q1.r0.列名]] 直接引用 agent 查过的格,比率、增幅才加口径卡。问数是探索运行,report 不写 claims。另有三条路径:直接回答(reply)、建图并执行、建图不执行 [backend/app/api/copilot.py:343 _user_message、:360 起 answer 分支] +- 答案里的数怎么来:report 节点把 [[v:]]、[[m:]] 解析成 segment(kind=value,cite.value 是原值,status=resolved),正文裸写的数记成 uncited_number 违规。doc.stats 里有 numbers、numbers_cited、uncited_numbers、unresolved、unknown_entities。证据接口按封存范围读报告文档 [backend/app/engine/evidence.py:117 Citation、:133 Segment、:3154–3228 stats;backend/app/api/evidence.py:383 evidence_graph] +- 出具档位由 output 节点按契约用 decide_tier 判(formal/degraded/withheld),和运行类别无关;问数答案一般不声明出具。问数据的可信度另看复核:scan 扫信号,severity 分 degraded/broken,RETRYABLE 里的信号才会自动重来 [backend/app/engine/nodes/io.py:275;backend/app/engine/issuance.py:24 _NUM_RE、:423 decide_tier;backend/app/engine/review.py:144 RETRYABLE、:170 scan、:266 should_retry;backend/app/api/copilot.py:2779 review_run] +- 用量:运行按 llm.end 事件加总 tokens、cost_usd、calls、unpriced_calls,每条 llm.end 都带 model。助手建图、挑表、复核这三处完全不记用量(copilot.py、copilot_gen.py、look/pick.py、review.py 里都没有 usage),所以问数据一轮的花费是低报的。环境里的 langchain-core 1.6.3 自带 get_usage_metadata_callback(用上下文变量按模型累计 usage_metadata) [backend/app/engine/runner.py:1438 _usage_of_events;backend/app/engine/nodes/llm.py:159;~/miniforge3/envs/agentlab/.../langchain_core/callbacks/usage.py:81] +- 各角色的模型怎么配:助手是 copilot 设置键(只能在「编排」页助手栏改),证据裁判是 judge 组(judge_model_spec 能按节点覆盖),核查是 verify 组,起草是 catalog_draft 组,门控是 run.tool_gate_*。写作模型没有单独的设置,取值顺序是:节点 config > 图 defaults > 按模型名反查接入 > 第一个启用的真实接入 [backend/app/api/copilot.py:567;backend/app/engine/judge.py:955;backend/app/api/settings.py:428 VERIFY_DEFAULTS、:580 CATALOG_DRAFT_DEFAULTS、:735 DEFAULT_SETTINGS;backend/app/engine/schema.py:122;backend/app/engine/context.py:82;backend/app/providers/factory.py:134、:174] +- 运行来源一共 11 种,客户端只能声明 chat/canvas/launcher/api。launch_run 接受 origin_kind、origin、options、workflow_name,服务端可以直接发起裸图的探索运行。options 是整次运行沿用的快照,会写进 run.started、在封存范围内。所有运行共用一个全局信号量(max_concurrent_runs=20),没有优先级 [backend/app/engine/origin.py:14 ORIGIN_KINDS、:29 CLIENT_ORIGINS;backend/app/engine/launch.py:54;backend/app/db/models.py:687 options;backend/app/engine/runner.py:65、:655 wait_idle;backend/app/core/config.py:99] +- 这些统计会被评测运行污染:探索问题聚类取所有非正式运行,把整份 input JSON(含 history)拿去做本地向量贪心聚类,界面上没有调用方;工具白名单衰减报表;数据目录的「使用次数」和「被哪些运行用过」,按所有运行的查询快照计数;记录页默认列出所有来源。pick_base 和应用视图的「最近一次封存」只看挂在工作流上的运行,裸图运行不受影响 [backend/app/api/governance.py:18 tool_usage、:90 exploratory_clusters;frontend/src/api/client.ts:770;backend/app/data/catalog.py:2406、:2444;backend/app/data/catalog_review.py:217;backend/app/api/runs.py:157;backend/app/api/compare.py:100;backend/app/apps/resolve.py:95] +- 清理:删运行只级联删事件。artifacts 引用行没有外键、不会跟着删,工件文件也不删。artifact_store 没有删除或回收的函数,现在只有上传表格快照有回收 [backend/app/api/runs.py:479;backend/app/core/artifact_store.py:45 put_json;backend/app/db/models.py Artifact;backend/app/data/table_versions.py:2214 gc] +- 定时任务只能指向工作流(workflow_id 非空),到点由 fire_once 调 launch_run。时间变量(today、last_week、last_month_start……)由 VARIABLES/template_values 按触发时间渲染。认领、补跑、重叠跳过、触发记录、Webhook 通知都是现成的 [backend/app/db/models.py:967 Schedule、:978 workflow_id;backend/app/scheduler/service.py:37 VARIABLES、:88 template_values、:280 fire_once;backend/app/scheduler/loop.py;backend/app/scheduler/webhook.py] +- 变化三分法:运行对比按「节点 + 第几次查询 + 工具」给查询配对,再按 normalize_sql 判 same/params_only/changed;指标分成 data_only/caliber_and_data/sql_changed/params_only/added/removed/incomparable。pick_base 按发起先后,挑成功结束并已封存的那次 [backend/app/engine/compare.py:1–70(模块说明)、:70 起的分类常量;backend/app/api/compare.py:100 pick_base] +- SQL 等价:normalize_sql 把字面量换成占位符;explain_sql_diff 要求规范形式一字不差才算等价,不等价时 items 一定不为空。差异方面有 time/time_range/filter/aggregate/expression/join/tables/group_by/limit/other 等。normalize_sql 不认得 {{ }} 模板 [backend/app/data/sql_semantics.py:319 normalize_sql、:5897 FacetDiff、:5920 SqlDiff、:7932 explain_sql_diff] +- 已发布口径的索引现成可用:CaliberKind.load 列出每个工作流最新已发布、未退役版本里的源头口径卡。card_inputs 判断卡能不能复用:输入全是 cell(nodes.X,0,'列'),且 X 是查库的调用工具节点。reuse() 给出 {workflow_id, version, node_id, metric_id, inputs}。metrics 节点的 caliber_from 从不可变的版本快照取卡,但不检查版本有没有退役。sqlcheck.tool_query 能取出调用工具节点里写死的 SQL。unresolved_caliber_upgrades 用来判钉住的版本有没有未处置的新版本 [backend/app/look/calibers.py:82 card_inputs、:124 views_of、:157 CaliberKind.load;backend/app/engine/nodes/metrics.py:235 _pinned_caliber;backend/app/data/sqlcheck.py:1050 tool_query;backend/app/engine/governance.py:679] +- 后台任务运行时:起草和剖析共用一套状态词(queued/running/succeeded/partial/failed/cancelled/interrupted,逐项是 pending/running/ok/partial/failed/skipped/cancelled),以及 SerialWriter、JobRun/JobManager、每个数据源一条队(SourceLanes)。服务重启时标中断、不自动续跑 [backend/app/data/catalog_job_runtime.py:1–30 说明、:123 SourceLanes、:251 interrupt_on_startup、:268 JobRun、:332 JobManager;backend/app/db/models.py:438 CatalogProfileJob] +- 每日账有两份一模一样的写法:核查用 engine/spend.py(verify_spend),裁判在 judge.py 里自己写了 _charge/_settle(judge_spend)。双盲复算的数值比较是 _agree:按相对误差比,原值为 0 时按绝对误差;设置里叫「允许的相对误差」 [backend/app/engine/spend.py:23;backend/app/engine/judge.py:859、:1050;backend/app/engine/nodes/dual_check.py:1001 _agree、:1012 _delta_pct] +- 目录在四处被读取,没有统一的开关:运行时的 db_schema/db_query 工具(目录同时冻结进表结构快照)、助手查看、自查用的 SQL 检查器(直接查 CatalogNote)、双盲复算。目录没有历史版本,只有每张表一个 version。项的状态有 proposed/verified/confirmed/rejected 四种,给模型看时只去掉 rejected [backend/app/tools/datasource.py:280、:290 _catalog_of、:753 frozen_catalog;backend/app/look/tables.py:164;backend/app/data/sqlcheck.py:1087 load_checkers;backend/app/engine/nodes/dual_check.py:792、:1190;backend/app/db/models.py:325;backend/app/data/catalog.py:69、:590 visible_notes] +- 现成的测试和验收零件:MockChatModel 的助手剧本只会搭「输入→查库 agent→成果」,答案是演示文字。dual_check_shop 有已知答案:按支付日、排除退款 330,按下单日 310,不排除退款 380/360。acceptance/retail.py 埋了退款、支付日、取消、华南高于华东几个陷阱,数据按「今天」滚动生成。test_retrieval_quality.py 是检索 hit@k 基线的先例,test_frontend_type_unions.py 是前后端常量逐个对照的先例 [backend/app/providers/mock_model.py:62;backend/tests/fixtures/dual_check_shop.py:1–12;backend/tests/fixtures/acceptance/retail.py:1–20;backend/tests/test_retrieval_quality.py;backend/tests/test_frontend_type_unions.py] +- 权限:治理、定时只给 builder;设置是 builder 读、admin 写;运行对比和质询要交出查询原文,只给 builder。助手和对话按 chat_scope 放行。后台任务里没有请求身份时,current_principal 当作 admin、未认证 [backend/app/main.py:196–258;backend/app/api/deps.py:108] +- 助手、写作、报告的提示词里没有找到注入今天日期的地方,「上周」这类相对问法要靠模型自己理解或用 SQL 的当前日期。只查了 copilot.py、nodes/llm.py、nodes/report.py,别的节点没核实 [backend/app/api/copilot.py;backend/app/engine/nodes/llm.py;backend/app/engine/nodes/report.py] +- 导航现有 9 项,App.tsx 的注释要求加导航项时在 600/520/480 像素高的窗口里各量一遍。向量默认用本地特征哈希,语义泛化弱 [frontend/src/components/CommandPalette.tsx:55 PAGES;frontend/src/App.tsx:334–337;backend/app/memory/embeddings.py:236;README.md「检索为何是混合的」] diff --git a/docs/design/next-round/02-口径账本.md b/docs/design/next-round/02-口径账本.md new file mode 100644 index 00000000..2568f8fa --- /dev/null +++ b/docs/design/next-round/02-口径账本.md @@ -0,0 +1,169 @@ +> 分块细案(2026-10-16 只读调研整理,代码基准 main d135a17)。和 [00-总方案](00-总方案.md) 冲突的地方以总方案为准;总方案「跨块统一的决定」一节列出了改动过的条目。决定编号在本文件内有效,总方案里引用时带前缀(如 评测-D4)。 + +# 口径账本:问数据和已治理口径接起来 + +## 缺口 +- 问数据看不到口径,也没有组合机制 —— 助手不知道有哪些已发布口径,「先看口径再决定」无从谈起;设计文档的 D4 规则落不了地 [backend/app/api/copilot.py:111-118] +- 设计文档的「子图做骨架」前提不成立 —— 子图的证据不交回父图,使用者又不能用子工作流。拼出来的答案里,来自口径的数既点不开出处,使用者也发起不了 [engine/nodes/multi.py:723-731;auth/chat_policy.py:32] +- 认口径缺参数绑定:没有从执行的 SQL 反推口径模板参数的函数 —— 只靠 explain_sql_diff,参数不同就判不等价;只靠 normalize_sql 文本,又会错给信任 [data/sql_semantics.py] +- 等价判定没有触发点,结果没处存,也没处展示 —— 逐数标口径、路径一和二a、召回的反查,都依赖一份可复核的比对记录 [engine/runner.py:1017-1031;api/evidence.py:778-818] +- 等价之后不知道列怎么对应 —— 答案里引用的列名和口径里的列名不同时,要靠位置对应(已实测:等价时列的顺序一致),现在没有接口给出这个映射 [data/sql_semantics.py(规范形式去掉了外层别名)] +- 从问数据发起正式出具:没有服务端来源,也没有使用者的权限规则 —— 路径一、二a 要正式运行,又不能让客户端冒充来源、自己传参数 [engine/origin.py:31;api/runs.py:108-127] +- 对账缺一张确定性派生的只读图;caliber_from 不支持节点改名 —— 差值要在口径卡里算。同一张图里放两份口径(组合多份、召回新旧两版)时,取数节点 id 会撞 [engine/nodes/metrics.py:235-290] +- 召回反查不全:导出没有记录,运行到轮次没有封存的链接,发布没有钩子,没有重算对比,也没有待办种类 —— 「口径变了,追回已交付的答案」现在找不全该追的对象,也没法通知 [api/evidence.py:2794;api/workflows.py:504-546;db/models_process.py:21] +- 沉淀出来的模板永远比对不上 —— from-run 保留 Agent,提取出的模板没有固定 SQL。聚类混着 history 不准,也没有按 SQL 形状的确定性重复检测。「在画布里打开」又丢了来源 [api/copilot.py:2625-2690;api/governance.py:109-117] +- E1–E3 没有数据来源 —— 用户要求实验指标改由账本和评测持续给出 [—] +- 设计文档的用词和全站术语冲突 —— 违反「一种说法」;check-copy 也会拦 [frontend/src/lib/terms.ts:18-22、150-152] + +## 做法 +### 1. 参数绑定与等价证明(认口径的内核) +- 做法:在 sql_semantics 新增 bind_template(template_sql, slots, sql, dialect, schema) 和 match_template(...),后者返回 TemplateMatch{equivalent, binding, unbound, diff: SqlDiff, level, columns}。步骤如下。① 只认口径取数节点 args.sql 里的 {{ input.<入口字段> }},可以带 trim / compact 过滤器。引用 vars.、nodes.、file_text 的整条判「不可比」,并照实给原因。② 按槽位所在位置生成哨兵:引号里用 '__agl_p{i}__' 字符串,裸写的用不可能出现的整数。依次在 optimizer、light、parsed 三级规范形式上,抹掉 Literal 的值后比指纹;一致就按位置配对字面量。含哨兵的口径字面量转成正则抽参数('{{ input.m }}-01' 这种半截的也行);不含哨兵的字面量必须相等;同一个槽位出现多处时,要取到同一个值。③ 三级都对不上时,退回「同一列、同一运算符的常量」这种启发式配对。它只用于对账说明和预填参数,不作为证据。④ 最后一步一律用 engine/expressions.render_template 按绑定渲染口径模板(和运行时是同一个渲染器),再调 explain_sql_diff(执行的 SQL, 渲染结果, schema=查询当时冻结的表结构, names=catalog_labels(目录))。等价与否只由这一步判。⑤ 等价时按位置给出列映射(已实测:外层别名不计、列顺序计)。SELECT * 按表结构展开之后同样按位置对应。 +- 复用:normalize_sql;_canonical_forms / _fingerprint;explain_sql_diff;catalog_labels;render_template;tests/test_sql_equivalence.py 的执行比对守门 +- 文件:改 backend/app/data/sql_semantics.py(新增两个函数并加进 __all__);新增 backend/tests/test_sql_binding.py;扩 backend/tests/test_sql_equivalence.py(绑定渲染后的 SQL 进语料) +- 对上的理念:照实说:失败只会少给信任。绑定只是启发,等价只由现有的判等价和执行守门决定。也不违反「不从指标定义编译 SQL」:只比较实际执行的 SQL 和口径里写的 SQL +- 否决的备选:只比 normalize_sql 文本:实测 'paid' 和 'refunded' 文本相同,会错给信任。把所有字面量都当参数:同样的错。在 sqlglot 树上做通用合一来判等价:绕开了现有的保真守卫和执行守门,正确性要从头证明。让模型判问题像不像:设计文档已经否决 +### 2. 等价判定的时机和账本的记法 +- 做法:新增 ledger/match.py。时机:运行成功结束、封存完、并且有查询快照时,在 runner 封存之后 spawn 去比对(和 notify_run 在同一个位置)。范围:探索运行(chat、canvas、trial、personal_app)。正式运行不比 SQL:它本身就是某一版的承诺,账本直接按 Run.workflow_id / version 记。候选口径:每个工作流最新的已发布、未退役版本(apps/versions.latest_published,和 look 是同一种说法)里查库的调用工具节点;先按同一数据源、sql_tables 有交集预筛。资源:每次运行限时(缺省 8 秒),在线程里跑,同时最多 2 个(照 auto_compare)。超时、解析失败的,照实记 skipped 和原因。只读封存范围内的快照(复用 _sealed 的读法),被改过的快照不比。结果:存一份内容寻址工件,kind=caliber_match,格式 agentlab.caliber_match/1。内容包括 run_id、manifest_hash、算法版本、考虑过的口径版本及其 graph_hash、每条查询的状态(proven / composed / related / none / skipped)和绑定、差异条目、列映射、每个数字的标签、整题判断。工件引用的 meta 记 {calibers:["wf@v"], related, counts}。然后用 run_append 的 sealed 方式追加一条封存后事件 ledger.matched {artifact, manifest_hash, algo}。启动时扫最近 N 天封存了、却没有 ledger.matched 的运行,补比一次;补比只读工件和版本表,不查业务库、不调模型。老运行在打开时按需比对,标「按现在的口径回看」。另外给 artifacts.kind 补一条索引。 +- 复用:run_append(封存后批注);api/evidence._sealed;auto_compare 的限时与并发;apps/versions.latest_published;query_snapshot_meta;ledger_enabled +- 文件:新增 backend/app/ledger/__init__.py、match.py、store.py;改 backend/app/engine/runner.py(封存后 spawn)、backend/app/core/events.py(LEDGER_MATCHED、LEDGER_RECALLED、LEDGER_BASIS_CHANGED)、backend/app/db/base.py(_INDEX_MIGRATIONS)、backend/app/main.py(启动时补比) +- 对上的理念:不挡生成:比对在封存后的后台跑,失败只记日志和 skipped。留痕:依据哪一版口径、哪个哈希、哪个算法版本、对着哪一次封存,都查得到。建在证据链上,不另起存储 +- 否决的备选:封存前在 runner 里比:运行结束要等 CPU 密集的比对,还会把外部的口径表混进运行自身的封存事实。在报告撰写节点里比:叙述层不该说出处,也拖慢生成。新建 caliber_uses 索引表:成了第二份真相,会和工件漂移。代价是反查要扫 kind=caliber_match 的工件引用,补上 kind 索引后可以接受 +### 3. 逐数标口径,进证据渲染 +- 做法:新增 ledger/labels.py,逐个处理封存的报告文档里的片段。单元格([[v:]]、[[table:]] 里的格):按目录条目找到查询快照,取那条查询的比对状态。指标([[m:]]):指标集工件里这个指标的每个输入,所在的快照都要对上同一份口径的同一版、绑定一致;指标式还要和口径卡相同(caliber_from 固定到那张卡,或者局部卡的式子按输入映射后 AST 相同)。满足了才算来自口径。标签分四种:caliber(来自口径 vN,how 分 independent「等价已证明」和 composed「助手选用」)、related(相关口径,附差在哪)、reasoning(这次的推理)、unknown(没比对、超时、快照读不出来)。整题:主报告里所有带标签的数都来自同一口径的同一版、绑定一致,就记 whole=equivalent,并附上还缺的入口字段。接口:GET /api/ledger/runs/{run_id} 返回标签、整题判断、能做的动作和不能做的原因。evidence_segment 的返回加一个 ledger 键;没比对过的运行不带这个键,老运行的返回一字不差。报告导出的片段面板带同一份标签,并写明「封存后比对,不在封存范围内」。遮罩:绑定的值等于这次运行查到过的遮罩值时(_Masks 的 secrets),不给值,也不提供代填出具;差异条目里的 SQL 片段走 _Shown 同一套遮罩。 +- 复用:build_catalog 的目录条目;_metric_chain 的取法;_Masks / _Shown;report_export.build +- 文件:新增 backend/app/ledger/labels.py;改 backend/app/api/ledger.py、backend/app/api/evidence.py(evidence_segment、export_report_html)、backend/app/report_export/build.py +- 对上的理念:照实说:一个答案里两种数并列,「助手选用」和「等价已证明」分开说。只读与遮罩。界面层不放逻辑。一种说法:同一个面板、同一套遮罩 +- 否决的备选:给整条答案打一个「已验证」:这是业内做法,正是要避免的错给信任。写进 turn.meta:那是前端写、前端读的数据,后端不解读,不能当账。写回报告文档:封存的工件不可变 +### 4. 组合式问数:口径做骨架,Agent 填缺口 +- 做法:① 问数据入口的查看对象加上 caliber:answer 时 kinds 加 caliber;CaliberKind.load 在开流之前用会话读好。问数据里的 CaliberKind 另给入口字段(describe_version 那一套:类型、必填、可选值)和「能不能组合」,仍然不给 SQL。② 协议加一种操作:{"op":"use_caliber","id":"c1","workflow_id","version","metrics":[…],"inputs":{字段:值}}。GraphApplier 不让模型自己写取数节点,由服务端按那一版确定性实例化:card_inputs 认出的取数节点照抄(args.sql 用 render_template 按 inputs 渲染成字面 SQL,节点 id 加前缀 c1_);口径卡用 caliber_from 固定到那一版,再加 nodes 改名映射 {X: c1_X}。inputs 按入口字段校验(类型、可选值、必填),不合规就拒收,并把原因交回模型。③ metrics._pinned_caliber 支持 caliber_from.nodes:按 AST 改写式子里 cell(nodes.X…) 和 nodes.X 的根。schema 校验、升版检查、证据的 _caliber_source、compare、recompute 都要一起认这个映射;没写 nodes 的老图行为一字不差。④ 提示:LOOK_WHEN 里问数据那一条改成「先看已发布口径。问法和口径定义完全对得上时用 use_caliber;口径只覆盖一部分时 use_caliber 加 Agent 补缺口;问法多了口径没有的条件(比如含退款)时不要套口径,交给 Agent,系统会自动对账」。运行时的 Agent 不看口径,保持独立解读。⑤ 节点手册里子工作流补上 workflow_version(copilot.py:200),但不把它当作问数的组合手段。 +- 复用:look/calibers.py 的 CaliberView、card_inputs、reuse_for、verify_reuse;blueprints/templates/_common.reuse_nodes 的做法;caliber_from 的运行时和升版处置;render_template;chat_policy 白名单(tool、metrics 本来就放行) +- 文件:改 backend/app/look/calibers.py、backend/app/api/copilot_context.py、backend/app/api/copilot.py、backend/app/api/copilot_gen.py(GraphApplier 处理 use_caliber)、backend/app/engine/nodes/metrics.py、backend/app/engine/schema.py、backend/app/engine/governance.py、backend/app/engine/recompute.py、backend/app/api/evidence.py(_caliber_source);新增 backend/app/ledger/compose.py;前端 run/decode.ts 展示这种操作的过程 +- 对上的理念:模板定骨架、agent 填关节:骨架由服务端从固定版本确定性展开,模型只选口径、填参数。没发布的图只能跑探索运行:拼出来的图照旧是探索运行。一种说法:复用蓝图「复用口径卡」的机制 +- 否决的备选:子图节点做骨架:子图证据不交回父图(multi.py:723-731),使用者白名单不允许子工作流(chat_policy.py:32),而且整个子工作流连报告一起跑。要走这条路,得先补子图证据外流、再核对放开白名单后的遮罩。让模型自己抄口径的 SQL:容易抄错,还得把 SQL 给模型,而 SQL 里可能写着遮罩值。局部复制口径卡的式子:丢了「定义取自哪一版」和升版处置 +### 5. 路径一、二a:按口径正式出具 +- 做法:新增 POST /api/ledger/runs/{run_id}/issue {workflow_id, version}。参数由服务端从比对工件里取绑定,不收客户端传的参数,前端只能选「按哪份口径」;缺的字段补入口默认值,再过 apps/forms 同一套校验。经 launch_run 发起正式运行,来源用服务端专用的新 origin_kind=ledger_issue,origin 里记 source_run、conversation_id、turn_id 和绑定的哈希;按 expect_hash 钉住 graph_hash。三档出具照正式运行原样走。自动发起要同时满足:整题等价、how=independent、版本是受管级别、图里没有人工节点和写外部系统的能力(复用 apps/backfill._side_effect,抽成共用函数)、当天没超上限。比对完就自动发起;不满足的只给一键,并写明为什么不能自动。同一运行、同一口径、同一绑定只发起一次(按 input_key 去重)。使用者能不能发起见 D5。结果挂回轮次:GET /api/ledger/runs/{id} 的 issued 列出 origin.source_run 等于这次运行的正式运行(origin_kind 有索引),档位文字用 TIER_TEXT。 +- 复用:launch_run(正式运行的全部门禁);apps/forms 校验;apps/resolve.input_key;apps/backfill._side_effect;issuance 三档;TIER_TEXT +- 文件:新增 backend/app/ledger/issue.py;改 backend/app/engine/origin.py(ORIGIN_KINDS 加 ledger_issue、reconcile、recall,只能由服务端写)、backend/app/api/ledger.py、backend/app/apps/backfill.py(_side_effect 抽出共用)、backend/app/api/settings.py(新增 ledger 一组:自动出具开关、每日上限、比对时限、召回上限) +- 对上的理念:正式运行只从已发布、未退役的不可变版本发起(照走 launch_run)。生效可查可对账:来源封存在 run.origin。照实说:不能自动的写明原因 +- 否决的备选:生成时由助手判定走路径一、直接发起正式运行:只能凭问题像不像,没有可证明的 SQL。把探索运行的结果「升格」为正式出具:违反「正式只从发布版本发起」。客户端传参数发起:参数必须来自可复核的绑定 +### 6. 路径二b:口径对账(差在哪、差多少) +- 做法:「差在哪」在比对时就给出:explain_sql_diff 的条目,labels=("这次","口径"),names 取数据目录里的中文名。这一步不跑查询。「差多少」要点开才跑一次对账运行:ledger/derive.py 按口径那一版确定性派生一张只读小图。图的结构:入口(口径的入口字段,默认值是绑定)→「这次的取数」调用工具节点(执行过的 SQL 原文,节点 id ours_q1)和口径的取数节点(模板里保留 {{ input.x }})→ 口径卡(caliber_from 固定那一版)→ 对账卡(metrics:差值、相对差,式子引用 cell(nodes.ours_q1,0,'列') 和口径卡的指标,on_missing=null)→ 出口(契约写 metrics_from;不加报告,不调模型)。参数如果是启发得来的、或者不全,先让人核对、修改,界面写明「参数按这次的 SQL 推断,请核对」。两边在同一次运行里同时查,差值在口径卡里算。面板上每个数都用 /evidence/metrics 的出处链;另外写出原答案当时的值,如果和这次重查的值不同,照实说「这段时间数据有变化」。列怎么配:差异条目没有兜底时按位置配;有兜底时让人选要对照口径卡上的哪个指标,这个选择记进对账运行的 origin。多行结果:被引用的行按维度列(非遮罩、非数值的列)经合并查询按键对齐,每个被引用的行出一个对账指标,最多 10 行;维度是遮罩列、或者合并键不唯一时照实拒绝。来源 origin_kind=reconcile(服务端写),是探索运行;使用者发起时带上 MASK_RESULTS / READ_ONLY 同一套选项。 +- 复用:CaliberView.reuse / card_inputs;caliber_from(含第 4 项的 nodes 映射);metrics 节点和 recompute 的取整、渲染;合并查询节点;evidence_metric 接口;explain_sql_diff 的说法;launch_run +- 文件:新增 backend/app/ledger/derive.py、backend/app/ledger/reconcile.py;改 backend/app/api/ledger.py +- 对上的理念:叙述层无算术:差值在对账卡里算。只读:走只读守卫和时限。点开才跑,不自动多花查询。照实说:推断的参数要人核对,数据变化单独说 +- 否决的备选:用 compare.py 在 Python 里算差:差值不在口径卡里,违背设计决定;而且 compare 是给同一工作流的两次运行配对用的,不适用于两条不同的 SQL。拿原答案的值当运行输入去减:混进了数据随时间的变化,原值的出处也断在跨运行这一步。每次比对都自动跑口径:多一倍查询,设计文档已经否决 +### 7. 召回:口径升版、目录事实被驳回、表结构变动 +- 做法:一、口径升版:commit_publish 提交之后排队处理。发布弹窗先给预告:旧版本被多少问数回答、导出、定时任务、应用用过,预计要重算几组。反查(ledger/uses.py)的对象包括:caliber_match 的 meta 里记着 W@旧版 的运行及其绑定;W 的正式运行;固定了 W@旧版 的工作流版本(子图或 caliber_from,用 apps/versions.pinned_upstream 找)发起的正式运行;定时任务最近 K 期(ScheduleFire.inputs);固定这一版的应用(pinning_apps);导出记录(见第 9 项);对应的问数轮次(运行 origin 里的 turn_id,老运行退回 conversation_turns.run_id 和 meta.runId)。按(旧版本、新版本、input_key(绑定))去重成组,每组发起一次召回运行(origin_kind=recall,探索运行)。派生图的结构:入口=绑定 → 旧版取数 + 旧版口径卡,新版取数 + 新版口径卡(用 caliber_from.nodes 避免撞 id)→ 召回卡算两版之差 → 出口。两版在同一时刻、同一份数据上算,所以差值只反映口径的变化;数据的变化另外拿原答案当时的值对照着说。以下情况不重算,照实记「无法重算:原因」:新版入口要的字段绑定里没有;口径的数据部分不能复用;图里会写外部系统。一次发布最多 N 组(设置,缺省 50),超出的写明「另有 M 组未重算」,可以在账本页手动接着算。计划照 apps/backfill 的写法,从已有的召回运行重算出来:幂等,服务重启后重算计划即可,不建任务表。二、目录事实被驳回或修改(只管影响数字的那一组:有效记录条件、去重、业务日期、关联、业务主键、度量类型):找出查过这张表、而且查询快照冻结的目录里看得到这一项的探索运行,追加封存后事件 ledger.basis_changed,界面标「依据有变」。对这些运行执行过的 SQL,按新目录跑一遍 SQL 检查(data/sqlcheck,纯计算),新出现错误级问题的才通知。受管口径受到的影响,由现有的影响面告知发布人。三、表结构变动(同步之后表或列被删、类型变了):标「依据的表结构已变」。受影响的口径版本,按最近一次正式运行的输入派生数据部分试算一次,算不出来才通知发布人。四、通知:重算后数真变了(召回卡的差值按指标的小数位渲染后不为零),才给答案的主人开待办。待办是新种类 recall,挂在召回运行上;派给对话的 created_by、导出人、定时任务的 created_by、应用管理者;关登录时派给任何人。待办里给旧口径的值、新口径的值、差值和原答案当时的值,每个都点得开;动作有「按新版重新出具」(走第 5 项)和「知道了」。数没变的只记账:在原答案的运行上追加 ledger.recalled,标「已按 vN 核对,不影响」。通知事件新增可订阅的 ledger.recall,消息照规矩只给标题、状态、链接,不放数字。 +- 复用:apps/backfill 的计划、去重和无副作用规则;input_key;caliber_from.nodes(第 4 项);派生图(第 6 项);run_append;process/tasks 里 review 的先例;notify_task;catalog_impact.table_impact;data/sqlcheck;scheduler/auto_compare(下一期推送已经会自动报「口径与数据都变」) +- 文件:新增 backend/app/ledger/uses.py、backend/app/ledger/recall.py;改 backend/app/api/workflows.py(发布后排队、预告)、backend/app/api/ledger.py(用量扩展、召回批次)、backend/app/db/models_process.py(TASK_KINDS 加 recall)、backend/app/process/tasks.py(open_recall 和对应处理)、backend/app/notify/service.py(事件、种类文字)、backend/app/data/catalog.py 或 catalog_review.py(审阅写入后的钩子)、backend/app/api/datasources.py(表结构同步之后的钩子,:857 附近)、backend/app/core/events.py +- 对上的理念:口径升版是被迫处置的事件,现在延伸到已交付的答案。叙述层无算术:两版之差在召回卡里算。照实说:数据变化和口径变化分开说,算不了的写原因。不挡生成:召回在后台跑,失败不影响发布。生效可查:每一组召回都是一次封存的运行 +- 否决的备选:拿新口径的现值直接和原答案的旧值比:混进数据变化,会大量误报。对「这次的推理」自动重问一遍:结果本来就不确定,变了也分不清是口径还是模型的原因。建召回任务表:运行本身就是记录,计划可以从已有运行重算。发布时同步重算:拖慢发布,还要查业务库 +### 8. 沉淀与认证队列 +- 做法:新增 GET /api/ledger/certify-queue,builder 以上可见。进队的信号有三种。① 确定性重复:问数探索运行里执行过、被报告引用、又没对上任何口径的查询,按(数据源、normalize_sql 文本)分组;出现在 ≥3 次运行、且来自 ≥2 段对话的进队。跨成员变化的字面量位置是参数候选,不变的是口径常量。② 问题聚类:沿用 exploratory_clusters,但只取 input.question,不含 history。③ 被反复用的答案:导出过 ≥2 次、做成过应用、在画布里打开过的轮次。每项给出代表问题、轮次链接、SQL 形状(展示走遮罩)、参数候选,以及是否已有相关口径(可以对账的那份)。动作「固化成口径草稿」是一种新的提取方式(ledger/certify.py,from-run 加 freeze 选项后转调它):Agent 实际执行过、报告引用了的查询,变成调用工具节点。SQL 用规范化后的文本,参数候选换成 '{{ input.p }}',入口字段的默认值取代表运行里的值。单行结果每个数值列建一项口径卡指标(cell(nodes.fetch,0,'列'))。报告节点保留,契约写 report_from + metrics_from(多行结果加 cells: true),然后过 validate_graph。新工作流的 origin 加 sources:[{run_id, conversation_id, turn_id, question}],这就是「口径 → 来源问题」的记录。之后由人在画布上审改,按受管级别发布。点「不是口径」的写一条审计(AuditEvent,action=ledger.certify.dismiss,subject 是形状哈希),之后不再进队。口径发布之后,对来源运行补比一次,标「之后发布的口径」,E1 也就有了回溯数据。 +- 复用:normalize_sql 的 literals;exploratory_clusters;extract_run_path;upgrade.py R3 里报告和契约的写法;Workflow.origin;AuditEvent;validate_graph;governance lint +- 文件:新增 backend/app/ledger/certify.py;改 backend/app/api/ledger.py、backend/app/api/governance.py(聚类只用问题)、backend/app/api/copilot.py(from-run 加 freeze 或转调 certify) +- 对上的理念:模板定骨架:沉淀出的口径是固定 SQL,以后才能被等价比对认出来。人审之后才发布:提取器是作者,不是发布者。留痕:来源问题和「不是口径」的决定都查得到 +- 否决的备选:沿用 from-run、保留 Agent:沉淀出的模板没有固定 SQL,以后永远比对不上,冷启动问题永远解决不了。只靠向量聚类:仍是看问题像不像,混着 history 更不准。自动发布:承诺要有人签字 +### 9. 补齐来源链:运行↔轮次、画布来源、导出留痕 +- 做法:① POST /api/runs 加 origin_ref {conversation_id, turn_id},只在 origin=chat 时收。服务端核对对话访问权和轮次归属,写进 Run.origin 和封存的 run.origin 事件;store/chat.ts 的 launch 带上它。② 「在画布里打开」:WorkflowIn 加 origin(run_id、conversation_id、turn_id,核对规则同 from-run),ChatPage 传上来。③ 报告导出和可验证报告下载,各写一条审计 AuditEvent(action=report.exported, subject=run, data={report_node, doc_artifact})。接口说明里的「不追加任何事件」改成「不追加运行事件,只记一条导出审计」。 +- 复用:engine/origin.origin_note;ensure_conversation_access;AuditEvent(有 subject 索引);from-run 的 origin 形状 +- 文件:改 backend/app/api/runs.py、frontend/src/store/chat.ts、backend/app/api/workflows.py、frontend/src/pages/ChatPage.tsx、backend/app/api/evidence.py、backend/app/apps/audit.py(或 auth/audit.py) +- 对上的理念:留痕:运行和轮次的对应、谁导出过,都进封存或审计。一种说法:来源一律写 origin +- 否决的备选:继续靠前端写的 meta.runId 反查:后端不解读它,前端也能改写,不能当账。把导出记成运行的封存后事件:运行还没结束时追加不了 +### 10. 账本自身给出的统计(替代实验 E1–E4) +- 做法:新增 GET /api/ledger/stats?since=&origin=。E1 等价命中:有候选口径的查询里,proven / composed / related / none / skipped 各自的个数和比率,绑定失败的原因,差异类型的分布(time / filter / aggregate / join / other / parse)。E2 对账:发起、完成、失败的次数,兜底说明的占比,面板上「说明有用 / 看不懂」的反馈(记成对账运行上的封存后事件)。E3 重复度:问数运行里,查询形状出现 ≥3 次(来自 ≥2 段对话)的占比、聚类的占比,认证队列的转化(固化、发布、驳回)。E4 召回:每个口径版本被用了多少次,每次发布召回了几组,其中数变了的比例。全部从工件、运行和审计现算,不另存;评测那边可以直接调同一个函数。 +- 复用:caliber_match 工件和 meta;runs.origin_kind;AuditEvent +- 文件:新增 backend/app/ledger/stats.py;改 backend/app/api/ledger.py +- 对上的理念:生效可查可对账。照实说:skipped 单独列,不混进 none +- 否决的备选:另建统计表或埋点:数据和账本会成两份 +### 11. 前端 +- 做法:证据正文:来自口径的数字加一个小标记,写明受管还是已发布;相关口径的数字不加标记,只在面板里说。面板:出处链最上面加「口径」一步,分三种情况。来自口径:「X」v3(受管)· 参数 · 怎么认的(等价已证明 / 助手选用)· 按口径正式出具。相关口径:差在哪 + 看差多少(打开对账:核对参数 → 发起 → 三个数各自点得开)。这次的推理:写「没有相关的已发布口径」。轮次底部加一行摘要(例如「3 个数来自口径「X」v3,1 个数是这次的推理」)和对应的动作;正式出具的结果挂在轮次下面。版本用量加上问数回答、导出、召回;发布弹窗预告召回规模。待办箱加 recall 种类的卡片。认证队列放在工作流列表页的「可沉淀的问题」抽屉里。文案全部进 lib/terms.ts,用「受管」、「完整出具 / 降档出具 / 不予出具」、「固定版本」,过 check-copy。 +- 复用:EvidencePanel 的指标步骤;apps/EvidenceDrawer 按指标打开出处链的做法;versionUsage;TIER_TEXT;check-copy 白名单 +- 文件:frontend/src/run/EvidenceDoc.tsx、frontend/src/run/EvidencePanel.tsx、frontend/src/run/AssistantStream.tsx、frontend/src/run/decode.ts、frontend/src/pages/ChatPage.tsx、frontend/src/store/chat.ts、frontend/src/api/apps.ts(ledger 客户端)、frontend/src/canvas/versionUsage.tsx、待办箱页面、新增 frontend/src/pages/ledger/CertifyQueue.tsx、frontend/src/lib/terms.ts、frontend/src/types.ts +- 对上的理念:界面层不放逻辑:标签、能不能出具、不能的原因全由接口给。一种说法 +- 否决的备选:前端按 meta 自己推断出处 + +## 待拍板(本块) +- L1 路径一(整题等价)什么时候自动发起正式运行?|选项:只在 Agent 自己写的 SQL 被证明与口径等价、整题都覆盖、版本是受管级别、图里没有人工节点和写入、当天没超上限时自动;「助手选用」拼出的整题只给一键 / 「助手选用」的整题也自动发起 / 全部只给一键,不自动|推荐:第一种 | 自动给的信任只能来自可证明的独立解读,这和双盲复算是同一个道理。「助手选用」的数确实来自口径,但「这份口径答的就是这个问题」是助手凭问法判断的,由人点一下来代替证明。代价:同一题会多跑一次正式运行(含报告撰写的模型费用),所以要每日上限 +- L2 问数据运行时,写 SQL 的 Agent 能不能看到已发布口径的定义?|选项:不看,保持独立解读;口径只给生成阶段的助手,供组合用 / 看口径说明,提高命中率|推荐:不看 | Agent 看过口径后写出的等价 SQL,只能证明它抄对了,证明不了问题和口径对得上,「等价已证明」就失去意义。代价是 E1 命中率偏低,靠 use_caliber 组合兜住常见问题 +- L3 组合式问数的骨架用哪种机制?|选项:服务端按固定版本确定性地内联实例化:取数节点照抄,口径卡用 caliber_from 并扩一个 nodes 改名映射 / 沿用子图节点:先补子图证据交回父图、放开使用者的子工作流白名单|推荐:第一种 | 子图证据现在不交回父图,使用者又不能用子工作流;内联实例化只用到 tool 和 metrics 两种已放行的节点,证据照常封存,比对也照常复证。代价:caliber_from 要加改名映射,回归面涉及运行时、校验、升版检查、证据、对比和推演。注意:这一条偏离设计文档的原文 +- L4 什么算「口径」,标签上怎么写?|选项:每个工作流最新的已发布、未退役版本,受管和已发布都算,标签写明级别;自动正式出具只限受管 / 只算受管级别|推荐:第一种 | 和 look 的 caliber、蓝图复用口径是同一种说法(latest_published)。已发布级别也有出具契约,照实标明级别比少认更有用;自动出具收紧到受管,因为它过了治理门禁 +- L5 使用者(user 角色)能不能从问数据发起「按口径正式出具」、对账和召回后的重新出具?|选项:对账(只读的探索运行)都可以;正式出具只限有权使用的应用固定着的那个口径版本,复用应用授权 / 正式出具只给搭建者以上 / 都可以|推荐:第一种 | 现在使用者只能经应用发起正式运行(api/runs.py:108-127),复用应用授权不另起一套权限。代价:没有被应用固定的口径,使用者只能看到「请搭建者出具」 +- L6 口径升版的召回怎么做、做多少?|选项:发布后自动排队重算:按参数去重成组,每次发布最多 50 组,发布弹窗预告规模;数真变了才开待办 recall,没变的只记账 / 发布后由人在账本页点「开始召回」 / 只在账本页列出受影响的答案,不重算、不通知|推荐:第一种 | 「口径升版是被迫处置的事件」延伸到已交付的答案,必须默认就有。重算只跑口径的数据部分,不调模型,成本可控;只在差值不为零时通知,避免太吵。50 是建议值,可以调 +- L7 目录事实被驳回、表结构变动之后,「这次的推理」那类答案怎么处理?|选项:只标「依据有变」;按新目录跑 SQL 检查,出现新的错误级问题才通知;不自动重问 / 自动重问一遍再比对|推荐:第一种 | Agent 重答的结果不确定,数变了也分不清是目录变了还是模型变了,自动重问只会花钱制造噪声。SQL 检查是确定性的,结论能落到具体问题上 +- L8 账本怎么存?|选项:内容寻址工件 kind=caliber_match,加一条封存后事件 ledger.matched,再给 artifacts.kind 补索引;不建新表 / 另建 caliber_uses 索引表|推荐:第一种 | 守住「建在证据链上、不另起存储」,比对依据可复核。代价:反查要扫 kind=caliber_match 的工件引用,补上索引后可以接受;规模真大了再加派生索引表,从工件重建即可 +- L9 沉淀时要不要把 Agent 写的查询固化成固定 SQL?|选项:新增「固化取数」:被引用的查询变成调用工具节点,跨轮变化的字面量自动成为参数,人审之后再发布 / 沿用 from-run,保留 Agent|推荐:第一种 | 保留 Agent 的模板没有固定 SQL,以后永远比对不上,冷启动问题永远解决不了;参数和常量按跨轮变化来区分,是确定性的,还要过人审 + +## 风险 +- 错给信任的唯一来源是 explain_sql_diff 判错了等价:绑定只当启发,等价只由现有的判等价和执行守门决定;绑定渲染后的 SQL 要进 test_sql_equivalence 的语料 +- 命中率可能很低(Agent 的写法和口径不同、参数绑不上),路由的价值就小:用账本统计 E1 盯住,靠 use_caliber 组合兜住常见问题,规范化的改进放进 sql_semantics 正常迭代 +- 「助手选用」的解读风险:助手可能把「含退款」的问题套到不含退款的口径上,数确实是口径的数,答的却不是问的。对策:标签写明「助手选用」并附上口径说明,提示里要求问法和口径有出入时不套,自动出具只给独立等价的情况 +- caliber_from.nodes 改名映射牵涉运行时、校验、升版检查、证据、对比、推演,回归面大:没写 nodes 的老图要一字不差,单独写测试 +- 比对耗 CPU:explain_sql_diff 一对可能要几百毫秒甚至更久。对策:按数据源和表预筛、每次运行限时、限并发、放在线程里跑,超时照实记 skipped +- 遮罩:绑定的值、差异条目、认证队列里的 SQL 样例都可能带出遮罩值。统一走 _Masks / _Shown;绑定是遮罩值的不代填出具 +- 召回的噪声和成本:按参数去重、设上限、只在差值不为零时通知;目录和表结构的触发只标记,只试算一次 +- 标签在封存之后才算出来,不在封存范围内:界面要写明这一点,并提供「复核比对」重算,同一算法版本的结果应当一字不差 +- 列映射依赖「选择列的顺序在规范形式里不变」:已实测换顺序判不等价,但规范化里「排好序」的范围要在 sql_semantics 的测试里锁住 +- 子图证据不交回父图这个缺口仍然存在:本方案绕开了它,没修。受管模板里用子工作流的报告同样引用不到子图的数,要进遗留清单 +- 老运行没有 origin_ref,召回找轮次只能退回 conversation_turns,可能漏掉;按现在的口径回看老运行时,要标明是事后比对 +- 使用者发起正式出具,和「正式运行只经应用入口」的现有边界有张力(见 L5) +- 设计文档原文里有本机计数和库名,按项目约定不进仓库;实施时的验收数据用虚构店铺库 + +## 测试 +- sql_semantics 绑定:槽位在引号里、裸写、在半截字面量里,带过滤器,同一槽位出现多处要取到同一个值;别名不同、条件换序、BETWEEN 改写这些写法差异要能认出;IN 列表、写死的码值不同('paid' 对 'refunded')必须判不等价 +- 绑定的健全性:在 test_sql_equivalence 的语料和随机数据上,凡是判了 proven 的,两条 SQL 在虚构库上执行结果必须一致;match_template 判等价、而 explain_sql_diff(E, render(T,B)) 判不等价的情况必须为零 +- 列映射:外层别名不同时按位置对应;SELECT * 按表结构展开后对应;列数不同时不给映射 +- 比对器:封存运行的夹具(含查询快照和口径版本)→ caliber_match 工件的内容和 meta;没封存、快照被改过、超时、解析失败各自记 skipped;绑定是遮罩值时不给值;同一次封存重复比对,工件哈希一致 +- 标签:单元格、表格里的格、指标(所有输入都要对上、指标式要相同)、一个答案里两种数并存、整题判断及缺字段;evidence_segment 返回 ledger 键,没比对过的运行返回一字不差;导出 HTML 带标签 +- 组合:use_caliber 实例化是确定的(同样的操作得到同样的节点);参数校验拒收后原因交回模型;caliber_from.nodes 改名后运行时取值、证据面板、升版检查、compare、recompute 都正确;chat_policy 放行;报告能引用 [[m:c1_…]];比对器把这些数标成 composed +- 正式出具:整题等价时用 ledger_issue 来源发起,origin 封存且带 expect_hash;以下情况拒绝并写明原因:缺入口字段、有人工节点或写入、版本已退役、权限不够、超过每日上限;同一绑定不重复发起 +- 对账:派生图是确定的、能通过 validate、能封存;对账卡的差值和相对差正确;evidence_metric 出处链正常;参数核对后才发起;多行结果按键对齐,遮罩维度、键不唯一时拒绝 +- 召回:发布 v2 后的计划分组、去重、上限、幂等(重启后重算计划);召回运行里两版之差的计算;只有变了的才开 recall 待办,没变的记 ledger.recalled;新版入口字段不兼容时照实记;有副作用的拒绝;通知消息里没有数字 +- 目录与表结构触发:驳回一项影响数字的事实后,冻结目录里看得到这一项的运行被标「依据有变」;按新目录出现新错误级问题的才通知;表结构同步后口径试算失败才通知发布人 +- 来源链:origin_ref 核对对话访问权和轮次归属,伪造的拒收;「在画布里打开」带上来源;每次导出都写审计 +- 认证队列:按 SQL 形状分组、阈值、参数和常量的推断;固化出的草稿能通过 validate_graph 和受管 lint;origin.sources 记了来源问题;点「不是口径」写审计后不再进队;聚类只用 question +- 统计:E1–E4 的口径和夹具上的手算一致;skipped 单独计 +- 前端:decode / stream / ui 三层检查(新增带标签的真实运行夹具);check-evidence(口径一步)、check-chat(轮次摘要、出具和对账按钮,写请求在浏览器层拦下)、check-report-export、check-versions(用量和召回预告)、check-inbox(recall 卡片)、check-copy;新增 check-ledger.mjs(认证队列、对账流程) + +## 验收 +- 真实后端,用虚构店铺库,建一个受管口径「周销售」:问一个和它等价的问题,答案里的数标「来自口径「周销售」v1(受管)· 等价已证明」,并按 L1 自动发起正式运行,轮次下显示出具档位 +- 问「含退款的周销售」:数标「这次的推理」,面板里有相关口径和差在哪(例如:口径只保留已支付的行,这次没有这个条件);点「看差多少」核对参数后发起对账运行,按你的问法、按口径、差值三个数都点得开出处 +- 问一个口径完全覆盖不了的问题:标「这次的推理」,没有任何相关口径的提示 +- 助手用 use_caliber 拼出的答案:数标「助手选用」,只给一键出具,不自动发起 +- 把「周销售」发布成 v2(改一个筛选条件):召回预告的规模和实际一致;只有数真变了的答案的主人收到 recall 待办,待办里旧口径值、新口径值、差值都点得开;没变的标「已核对,不影响」;推送消息里没有数字 +- 导出过的报告在召回里能找到导出人;运行和轮次之间有封存的链接 +- 同一类问题在不同对话里问 3 次以上,进认证队列;固化成草稿、按受管级别发布后,再问同样的问题,标签变成等价已证明 +- 健全性:语料和随机数据上没有错判的等价;比对超时、失败都不影响运行结束和答案展示 +- 遮罩:使用者账号下,标签、差异说明、认证队列、待办里都看不到遮罩值 +- 后端全量 pytest,加上与本方案相关的 check-*(由编排者统一跑完整关口) + +## 工作量 +后端约 5–6k 行,其中测试约 3k 行。各部分的量级:sql_semantics 绑定和证明约 400 行;比对器、工件和标签约 900 行;组合(use_caliber 加 caliber_from.nodes)约 600 行;正式出具和对账派生约 700 行;召回约 800 行;沉淀和固化约 600 行;来源链、统计、设置约 400 行。前端约 2k 行(证据标签、面板的口径一步、轮次摘要和动作、对账视图、版本用量与召回预告、认证队列、待办卡片、文案)。建议拆成 5 个工作包,每包交给一个完整代理。WP1 认口径内核:绑定与证明、比对器、工件和事件、标签接口、证据片段 ledger 键、运行↔轮次 origin_ref、统计。WP2 组合与出具:问数据的 caliber 查看、use_caliber、caliber_from.nodes、正式出具、对账派生运行;依赖 WP1。WP3 召回:发布钩子、反查、召回运行、recall 待办与通知、导出审计、目录和表结构触发;依赖 WP1 和 WP2(要用 nodes 映射和派生图)。WP4 沉淀:认证队列、固化提取、origin.sources、「在画布里打开」带来源;依赖 WP1。WP5 前端:全部界面和检查脚本;接口契约在 WP1 定稿后就能对着夹具先做,最后接真接口。依赖关系:WP1 → WP2 / WP4 并行 → WP3 → WP5 收口;完整关口由编排者最后跑一次。 + +## 和其他几块的接口 +- 评测:E1–E4 由 ledger/stats.py 给出;评测框架可以直接调 ledger.match 里的比对函数;双方共用 test_sql_equivalence 的语料;E2 的「说明有用 / 看不懂」反馈口径要和评测约定 +- 目录维护:审阅写入(data/catalog.py、data/catalog_review.py 的 review_item / review_batch)之后要挂召回钩子;本方案依赖表结构快照里冻结的目录(tools/datasource._store_schema),目录那块如果改冻结的格式要同步;影响面 catalog_impact.table_impact 两边共用 +- 对外集成:通知事件新增 ledger.recall,和渠道、订阅模型共用(notify/service.py EVENTS);API 令牌要覆盖 /api/ledger 的新接口;视图嵌入里的应用印章已经写了口径,召回的「应用」反查用 pinning_apps +- 遗留:子图证据不交回父图(engine/nodes/multi.py:723-731)、node_prefix 没人读(:680)、节点手册的子工作流缺 workflow_version(api/copilot.py:200),宜放进遗留清单;本方案绕开了,没修 +- 蓝图助手:look/calibers.py 两边共用(api/copilot_blueprint.py:289-292),问数据加的入口字段和「能不能组合」不能改变蓝图的行为;caliber_from.nodes 也会影响 blueprints/templates/_common.reuse_nodes。顺带一个机会:蓝图复用口径卡时模型新写的取数 SQL,可以用同一个比对器核对它和源口径是否等价,要和蓝图那块约定 +- 可能冲突的文件:api/copilot.py、api/copilot_context.py、api/copilot_gen.py、api/evidence.py、engine/runner.py、engine/nodes/metrics.py、engine/schema.py、api/workflows.py、api/runs.py、db/models_process.py、notify/service.py、api/settings.py、frontend/src/run/EvidencePanel.tsx、frontend/src/run/AssistantStream.tsx、frontend/src/lib/terms.ts、frontend/src/store/chat.ts +- 应用平台:apps/backfill._side_effect 要抽成共用函数;新的 origin_kind(ledger_issue、reconcile、recall)要加进 auth/access 的可见性规则(参照 _VIEW_ORIGINS)和记录页的来源筛选 +- 设计文档要改:第三部分 §3.4.1(子图做骨架改成内联实例化)、§3.4.2(字面量不算不同改成先绑定参数再证明)、§3.8(实验改成持续统计),用词按 lib/terms.ts(受管、完整出具 / 降档出具 / 不予出具、固定版本) + +## 附:现状(代码位置) + +- 问数据(intent=answer)的搭图约定写死在提示里:input → agent → report → output,Agent 运行时自己写 SQL(_user_message)。查看只开 table、capability 两类,没有 caliber。 [backend/app/api/copilot.py:360-427;_look_service 默认 kinds 在 backend/app/api/copilot.py:111-118 → backend/app/api/copilot_context.py:67-80] +- 问数据的运行由前端用裸图发起,是 origin=chat 的探索运行。运行上不记属于哪段对话、哪一轮:StartRunIn 没有这类字段。轮次和运行的对应只存在 conversation_turns.run_id 和前端写的 meta.runId 里,后端不解读 meta。 [frontend/src/store/chat.ts:1500-1508;backend/app/api/runs.py:36-48、89-140;backend/app/api/conversations.py:70-82、139-153] +- look 已经有 caliber 类型,但只给蓝图助手用。它只列每个工作流最新的「已发布且未退役」版本里源头口径卡上的指标。只有卡的每个输入都是 cell(nodes.X,0,'列')、X 又是查库的调用工具节点时才算能复用。工作流版本的详情故意不给 SQL(SQL 里可能写着遮罩值)。 [backend/app/look/base.py:20;backend/app/look/calibers.py:82-122(card_inputs)、124-145(views_of)、148-170(CaliberKind.load);backend/app/api/copilot_blueprint.py:289-292;backend/app/apps/describe.py:1-5] +- 子图节点在运行时会按 workflow_id + workflow_version 执行固定的快照,但交回父图的只有 output 和 vars,子图的证据台账 state["evidence"] 不交回。所以父图的报告引用不到子图里的查询格和口径卡。extra.node_prefix 设了值,全仓没有代码读它。使用者问数据的节点白名单里没有子工作流。助手节点手册里子工作流只写了 {workflow_id, input},没写 workflow_version。子图内部节点的自定义事件会不会进父运行的事件流:没核实。 [backend/app/engine/nodes/multi.py:613-731(updates 在 723-731,node_prefix 在 680);backend/app/engine/compiler.py:127;backend/app/auth/chat_policy.py:32-33;backend/app/api/copilot.py:200] +- caliber_from 把一张口径卡固定到别的工作流某一版的卡上:运行时按那一版取指标式,升版处置和子图走同一套规则。卡上的 cell(nodes.X…) 要求本图的取数节点同名。蓝图复用口径时,取数 SQL 是模型新写的生成关节,没有任何地方核对它和源口径的取数 SQL 是否等价。 [backend/app/engine/nodes/metrics.py:235-290;backend/app/engine/governance.py:679-731;backend/app/blueprints/templates/_common.py:518-528、548-560] +- sql_semantics 已经实现:normalize_sql 把字面量换成占位符;explain_sql_diff 只有规范形式一致才判等价,不等价时一定给出差异条目,另有执行比对守门。我把代码拷到 /tmp 实测:外层列别名不计(AS gmv 和 AS 销售额 判等价);选择列的顺序计(换顺序判不等价);日期字面量不同判不等价;status='paid' 和 'refunded' 两条的 normalize_sql 文本相同(same_shape 为真)。结论:设计文档 §3.4.2 的「字面量不算不同」不成立。explain_sql_diff 不忽略字面量;只比规范化文本,又会把写死的码值不同当成同一口径。 [backend/app/data/sql_semantics.py:319-370(normalize_sql)、5920-5960(SqlDiff)、7932-8008(explain_sql_diff)、8012-8034(diff_facets);backend/tests/test_sql_equivalence.py] +- 「先绑定、再证明」的做法已在 /tmp 实测可行:把口径模板里的 {{ input.x }} 换成哨兵值,在 optimizer 级规范形式上抹掉字面量后比指纹,按位置对齐字面量,取出参数;再用 render_template 渲染口径 SQL,交给 explain_sql_diff 复证。别名不同、条件换序、BETWEEN 改写成比较这几种情况都判等价。不等价时给出的说法可读,例如「口径只保留 status = 'paid' 的行,这次没有这个条件」「这次按 created_at 筛时间,口径按 paid_at」。 [backend/app/data/sql_semantics.py:2974-3030(_Form、_fingerprint)、3091(_canonical_forms);backend/app/engine/expressions.py:172-210(render_template)] +- 运行对比已经区分「只差参数」和「改了写死的值」:执行的 SQL 只差字面量时,再看图里的 SQL 有没有改过。整个模块只读、是纯函数。 [backend/app/engine/compare.py:1-60、844-866(_compare_sql)、916-944(_edited_sql)] +- 证据链:每次查询都存一份查询快照,里面有 SQL、遮罩列、data_version,还有表结构快照(内含当时冻结的目录 {表: version, notes})。工件引用的 meta 记了源和表名。封存范围只认 seq≤manifest_seq 的事件(_Sealed/_sealed)。指标链给出口径卡名、版本、caliber_from 的来源、升版处置和双盲复算。/evidence/metrics/{node}/{metric} 没有报告也能点开指标。封存之后追加批注已有现成做法(run_append 的 sealed 方式,evidence.judged 就是这么写的)。runner 封存之后已经有一个后台 spawn 的位置。 [backend/app/tools/datasource.py:640-700、729-760;backend/app/data/catalog.py:2390-2404;backend/app/api/evidence.py:180-299、820-848、878-1000;backend/app/engine/evidence.py:851-975;backend/app/engine/run_append.py:1-20;backend/app/engine/runner.py:1017-1031] +- 正式运行只能从已发布、未退役的版本发起,按 graph_hash 钉死;口径升版未处置时拒绝发起;目录漂移(CATALOG_DRIFT)只提醒、不拦。运行来源 origin_kind 记在封存的 run.origin 事件里,客户端只能声明 chat/canvas/launcher/api 四种。使用者(restricted)只能发起问数据的探索运行。 [backend/app/engine/launch.py:99-170、186-215;backend/app/engine/origin.py:15-31;backend/app/api/runs.py:108-127] +- 「账本」这个接口命名空间已经有了:/api/ledger 返回某个工作流版本被哪些应用固定、生效记录数、正式运行数。前端只在版本用量里用到它。 [backend/app/api/ledger.py:1-29;backend/app/apps/versions.py:188-233;frontend/src/api/apps.ts:95-100;frontend/src/canvas/versionUsage.tsx:15-24] +- 沉淀:/copilot/from-run 确定性地提取执行路径,保留 Agent,输入值变成入口字段的默认值,新工作流记 origin {run_id, conversation_id?, turn_id?}。「在画布里打开」用 workflows.create 新建工作流,不带来源(WorkflowIn 没有 origin 字段)。探索问题聚类拿整个 Run.input JSON(含 history)做向量贪心聚类,前端定义了接口但没有调用方。 [backend/app/api/copilot.py:2552-2690;backend/app/db/models.py:594;frontend/src/pages/ChatPage.tsx:306-320;backend/app/api/workflows.py:40-44、136-155;backend/app/api/governance.py:91-160;frontend/src/api/client.ts:770] +- 和召回相关的现状:发布只改版本行,不发事件、不写审计。应用有「按新口径补算」:只补没有副作用的工作流,按输入指纹去重。定时运行结束后会自动和上一次对比,分类里已有「口径与数据都变」。报告导出明写「只读:不追加任何事件」,谁导出过哪份报告没有记录。目录项有驳回状态,影响面 table_impact 只看已发布的模板。上传表格发起运行时按当前快照固定数据版本,不能指定历史快照。 [backend/app/api/workflows.py:504-546;backend/app/apps/backfill.py:1-30、108-139;backend/app/apps/resolve.py:125-127;backend/app/scheduler/auto_compare.py:1-30;backend/app/api/evidence.py:2787-2867;backend/app/data/catalog.py:69、748;backend/app/data/catalog_impact.py:1-20;backend/app/engine/runner.py:1168] +- 待办与通知:待办种类里有 review,是「没有审批、挂在一次运行上」的先例,Task.run_id 是非空外键。通知事件共五种,消息里不放数字。 [backend/app/db/models_process.py:21-22、36;backend/app/process/tasks.py:142-165;backend/app/notify/service.py:1-12、30-41] +- 全站文案的叫法和设计文档不一致:界面上叫「受管」,不叫「已治理」;出具三档是「完整出具 / 降档出具 / 不予出具」;版本写「固定版本」,不写「钉住」。 [frontend/src/lib/terms.ts:18-22、150-152;backend/app/apps/resolve.py:50] diff --git a/docs/design/next-round/03-数据接入与目录维护.md b/docs/design/next-round/03-数据接入与目录维护.md new file mode 100644 index 00000000..50632f12 --- /dev/null +++ b/docs/design/next-round/03-数据接入与目录维护.md @@ -0,0 +1,166 @@ +> 分块细案(2026-10-16 只读调研整理,代码基准 main d135a17)。和 [00-总方案](00-总方案.md) 冲突的地方以总方案为准;总方案「跨块统一的决定」一节列出了改动过的条目。决定编号在本文件内有效,总方案里引用时带前缀(如 评测-D4)。 + +# 数据接入与目录维护(不含新数据库类型) + +## 缺口 +- 几千张表的库用不了:不能按表名限定范围,超出上限的部分按不稳定的顺序被截掉。目录、look、剖析、起草、SQL 检查对窗口外的表要么看不到,要么说错(「表已不在」「请重新探查」) —— 违背「照实说」。企业 ERP、数仓单个 schema 上千张表很常见,开发环境里已经撞上了 [introspect.py:180-183;api/catalog.py:261;catalog_profile.py:1431] +- 探查是同步的、整份替换、不排队。MySQL 一张表一次往返,范围一大同步请求就会超时;和起草、剖析同时进行时,目录和结构会对不上 —— 大范围必须是后台任务。和起草、剖析同一个源要串行,才能保证「生效可查」 [api/datasources.py:822-865] +- 看不到表结构的变化:没有前后差异,也没有变化记录。删列以后,那一列上人工确认过的目录项、写死了这一列的已发布模板都收不到提醒,正式运行要到查询那一步才报错 —— 缺留痕,缺照实说。口径账本的召回也缺「表结构有变动」这个触发源 [catalog_impact.py:355;engine/launch.py:164-199] +- 列类型丢了长度和精度(Oracle 的 VARCHAR2 / NUMBER、MySQL 的 ENUM):类型变化检测不出来,MySQL 的 ENUM 状态列永远进不了码值候选 —— 结构比对的前提就是类型写全;ENUM 是最典型的状态码列 [introspect.py:272;catalog_profile.py:1081-1094] +- 取值漂移有四个缺口:确认过的码值列不再观察;大表只看最老的 10 万行;没有最新业务日期(新鲜度)、按日行数、总行数的观察和历史;「已列全」过期以后 SQL 检查还会照它提示 —— 码值出现新取值、比例剧变、日期断档都看不到;有效记录条件可能悄悄漏掉新状态 [catalog_profile.py:1115, :843-847, :1191;sqlcheck.py:925-930] +- 没有定时维护:只能手动探查、手动剖析 —— 目录只在点按钮那一刻是对的;用户要的「目录维护」离不开它 [scheduler/loop.py:36-51] +- 变化没有统一的记录:进不了审阅队列,发不了通知,口径账本的召回也没有可以订阅的接口 —— 留痕;一种说法(不能各处各记一份) [catalog_review.py;notify/service.py:30] +- 剖析 SQL 在真库上的语义风险:IN 列表的字面量只按子表列类型写;Oracle 小写带引号的对象;统计信息过期把大表当小表;ENUM 状态列被漏掉;MySQL 零日期、PG infinity 日期;CHAR 列的尾随空格;大表「抽样」其实是物理顺序的前 N 行 —— 剖析是全站唯一主动查业务库的地方,错了要么让关系结论站不住,要么把止损打满 [catalog_profile.py:1011-1060, :265, :285-311, :1184-1207] +- 方言没有真库验证的路径:只有守卫放行和字符串片段两类测试 —— MySQL / PG / Oracle 上的剖析从来没在真库上跑过(数据目录那一轮的遗留,见记忆) [tests/test_catalog_profile_units.py:124-166] +- Excel 期 5:列表类月报(每月一份明细导出)不能按期累积,夹在中间的小计会让规则起草失败,CSV 不能走配方,表格对象这么可靠的边界信号也没用上 —— 真实场景里最常见的就是每月一份明细导出 [recipe_types.py:1125-1180;recipe_suggest.py:770-815] +- 剖析结果(行数、日期范围、最新业务日期)还没进 look 的表详情(已知遗留) —— 助手写 SQL 时不知道表有多大、数据到哪天了 [data/schema_view.py:118-153] + +## 做法 +### A1 对象索引 + 探查范围 + 后台探查任务(大库的主方案) +- 做法:① 新表 source_objects,一个对象一行:source_id、name(schema_cache 的键)、kind(table / view)、comment、est_rows、in_scope、scope_reason(rule / explicit / pinned / on_demand)、meta(反射到的完整结构,只有范围内的有)、fingerprint、struct_version、first_seen_at、last_seen_at、removed_at、reflected_at。② 对象索引:每种方言一条查询拿到名字、类型、注释(MySQL 查 information_schema.TABLES 带 TABLE_COMMENT / TABLE_ROWS;PG 查 pg_class 加 obj_description;Oracle 查 ALL_TAB_COMMENTS 加 ALL_OBJECTS;SQLite 查 sqlite_master)。查失败就退回 Inspector 的 get_table_names / get_view_names,只是没有注释。索引上限 50,000 个对象。③ 范围放在 options.introspect_scope:{mode: all|rules, include: [通配], exclude: [通配], tables: [显式], views: bool},上限 max_tables 缺省 500、最大 2,000。规则按 name_key 不分大小写匹配,排除优先。有目录的表、已发布模板写死 SQL 用到的表(catalog_impact 里的 _published 加 graph_sql_tables)一律 pinned,规则排不掉,只有显式移出才行,移出时记一条变化。规则命中超过上限时拒绝开始,并说出命中了多少张,不悄悄截断。④ 探查改成后台任务(data/introspect_jobs.py),复用 catalog_job_runtime 的 JobRun / JobManager / SerialWriter / close_job / interrupt_on_startup,SourceLanes 新增一种 kind=introspect,和同源的起草、剖析串行。逐表反射,看得到进度,能停止,服务重启标中断、不续跑。POST /introspect 改为返回 202 和任务;dry_run 预览保留同步,但只刷新索引、返回命中数。⑤ schema_cache 由唯一的一个函数 rebuild_schema_cache 从范围内已反射的行物化出来,形状不变(tables、total、truncated、schema),另加一个 scope 摘要(in_scope、cap、out_of_scope)。220 处调用点不用改;total 改为索引里的对象总数,truncated 的意思变成「有范围外的对象」。⑥ 「表已不在」只在 removed_at 有值时才说;范围外、反射失败(skipped)、已删除,三种情况分开说。 +- 复用:introspect._collect(反射核心改成接受一个表名列表)、list_schemas 和 _SCHEMA_STATS_SQL 的写法、catalog_job_runtime 整套、catalog_impact._published 和 graph_sql_tables、names.name_key、health / explain 的报错翻译、db/base._migrate(只加不改) +- 文件:新增 data/source_objects.py、data/introspect_jobs.py;修改 data/introspect.py、api/datasources.py、db/models.py、data/catalog_job_runtime.py(新增 lane kind)、api/coded.py(introspect_busy、scope_too_large、introspect_finished)、api/catalog.py(in_schema 拆成三种原因);前端 pages/DataSourcesTab.tsx(探查范围面板)、api/client.ts、types.ts、lib/terms.ts(SCHEMA_PARTIAL_TEXT 改写) +- 对上的理念:照实说(范围外、反射失败、已删除分开说;超上限就拒绝,不截断)。留痕(范围规则改动前后、谁、什么时候)。只读(只发字典查询和反射)。一种说法(schema_cache 形状不变,所有消费者不改)。生效可查(每次探查是一条任务记录) +- 否决的备选:把 _MAX_TABLES 提到几千、全量反射:schema_cache 是整块 JSON,每次读数据源都整份加载;冻结的快照按张数线性增长;MySQL 每表一次往返;目录页和 look 要全量渲染。按 200 张一页分页探查:PG、Oracle 的反射查询没有 ORDER BY,每页的内容会漂,人也不知道要的表在哪一页。把 schema_cache 彻底拆成按表的行、改掉 220 处调用:代价大,和本轮目标无关,所以改成物化子集。按方言写列指纹 SQL 做增量探查:多出三套必须和 Inspector 结果一模一样的手写字典查询,稍有偏差就报假变化。范围上限内全量反射已经够用,增量留到实测证明慢的时候再做 +### A2 大库下 look / db_schema / 目录 / 剖析 / 起草 / SQL 检查的配套 +- 做法:① 新增 schema_view.search_tables:BM25 预筛,切词用 memory/embeddings._tokens 加 catalog.name_words,文档内容是表名、注释、目录里的中文名 / 粒度 / 说明、列名、列的中文名(遮罩列只用列名)。look 的 pick 在候选超过 PICK_LIMIT 时,只把前 120 个命中、工作流已用到的表(seeds)、命中表的一跳关联表交给挑选模型,挑选提示词从几万字降到 6,000 字左右。范围外的对象只按名字和注释参与检索,权重低一档。② 按需加入:look 点名或挑中范围外的对象、运行时 db_schema__ 点名范围外的表,都当场只读反射这一张(大约 5 条字典查询,受单条时限约束),落盘为 scope_reason=on_demand,记下谁、哪一轮或哪次运行、need 原文。每次查看最多 4 张,每次运行最多 8 张,超了照实说。look 记录增加 added_to_scope 字段。写 schema_cache 只走 rebuild_schema_cache,按源加进程内锁(和现有回收锁同一个前提:单进程部署)。③ db_schema__ 增加可选的 keyword 参数,和 look 用同一个 search_tables;不传 table 时只列范围内前 50 张,并提示用 keyword 或 table。④ 目录:清单只列范围内的表;搜索框查对象索引,范围外的结果给「加入范围」;前端分批渲染(每次 200 行加「显示更多」),不引入新依赖。⑤ 剖析和起草:从范围内挑表。_relation_ends 区分「被指向的表在探查范围外(可以加入范围)」和「表结构里没有」。⑥ SQL 检查:范围内的不变;查询碰到范围外但在索引里的表时,结果里照实列出「未按目录检查的表」,不新增检查规则。⑦ 已知遗留顺带做掉:schema_view.table_detail 附上剖析和观察的结论(估算行数和依据、各日期列的范围、最新业务日期),遮罩列不给。 +- 复用:memory/embeddings.BM25 和 _tokens、catalog.name_words、catalog.render_table_index、look.pick.Picker(提示词不变,只是喂给它的目录变短)、schema_view.complete(补中间表)、A1 的单表反射 +- 文件:修改 data/schema_view.py、look/tables.py、look/base.py(新增常量 PREFILTER_LIMIT、ON_DEMAND_PER_LOOK、ON_DEMAND_PER_RUN)、tools/datasource.py(_make_schema_tool、_query_description)、api/catalog.py(新增 GET /catalog/search 和 POST /scope/add)、data/catalog_profile.py:1420-1440;前端 pages/catalog/CatalogPage.tsx、TableIndex.tsx、run/decode.ts(look 记录里的 added_to_scope) +- 对上的理念:照实说(范围外的表照实标「未检查」「按需加入」)。留痕(按需加入记下 need 和发起方)。不挡生成(检索、按需探查失败只是少一点上下文)。遮罩(检索文档里遮罩列只有列名)。一种说法(look、db_schema、/schema 共用 schema_view) +- 否决的备选:look 遇到范围外的表只临时取结构、不落盘:SQL 检查和目录都看不到这张表,下一轮还得再反射一遍。挑表之前先调一次模型做粗筛:多一次模型调用,经 OpenAI 兼容接入的推理模型关不掉思考,45 秒的时限已经很紧。引入向量检索:表名和列名基本是字面匹配,BM25 够用,而且零配置 +### B1 表结构变化:前后差异、影响面、结构版本 +- 做法:① 纯函数模块 data/schema_diff.py:输入是同一张表前后两次的 meta,输出变化列表。表级:added(新进范围或新建)、removed(索引里也没了)、left_scope、reflect_failed,其中后两种不算删除。列级:added、removed、type(比较按方言编译后的类型,见 C1)、nullable、comment。键:主键、唯一约束、外键的增删。被删的列和新加的列类型相同、位置相同时,标「可能是改名(未确认)」,不下结论。② 影响面:目录方面,列出说到这些列的所有路径(columns.X.*、keys、business_date.column、relations 两端,以及 valid_filter 里引用的列,用 sqlcheck.parse_valid_filter 取列),带上各自的状态;人工确认过的项升为「要处理」。模板方面,用 catalog_impact.table_impact 算直接引用和可能涉及;直接引用的那几条写死的 SQL,再拿新结构跑一遍 sql_unknown_columns(从 api/copilot.py 挪到 data/,两边共用),能说出「这条 SQL 现在会报列不存在」。③ 每张表有 struct_version,fingerprint 变了才加 1。发布时多记一列 WorkflowVersion.schema_versions = {源名: {表名: 结构版本}}(只记直接引用的表,写法照 catalog_versions_for)。正式运行发起时比对,有变化就写一条新事件 schema.drift(只提醒、不拦),界面和 catalog.drift 共用同一块横幅(Banners.tsx、VersionsSheet.tsx)。发布前检查列出这一版用到的表上还没处理的结构变化。④ 升级以后第一次探查只建立基线,不报变化(类型写法从通用改成方言完整写法,不能当成类型变了)。 +- 复用:catalog_impact.table_impact、template_refs、catalog_versions_for 的记法;sqlcheck.parse_valid_filter;copilot.sql_unknown_columns;launch.py 写 notes 的位置(:196-199);EventType 的登记方式 +- 文件:新增 data/schema_diff.py;修改 data/catalog_impact.py、api/copilot.py(把 sql_unknown_columns 挪到 data/sqlcheck_columns.py)、engine/launch.py、core/events.py(SCHEMA_DRIFT="schema.drift")、db/models.py(WorkflowVersion.schema_versions)、api/workflows.py(发布时记、发布前检查);前端 run/decode.ts、pages/runs/Banners.tsx、canvas/VersionsSheet.tsx、lib/terms.ts +- 对上的理念:照实说(范围外 ≠ 删除,改名只说「可能」)。留痕(结构版本可钉、可对账)。口径升版是被迫处置的事件:结构变化同样钉版本、发起时提醒;但结构变化不是口径,所以只提醒、不拦,和目录漂移同档。正式运行只从已发布版本发起(不变) +- 否决的备选:拦下正式运行:结构变了不一定影响这条 SQL(比如只是加了一列),一刀切会卡住正式出具;真删了列,查询本身就会照实报错。把结构变化塞进 catalog.drift 同一个事件:老的渲染逻辑只认整数的目录版本,混进去会误读;所以分开记,界面上合到一块横幅里显示 +### B2 取值观察(列级取值漂移)与新鲜度 +- 做法:① 在剖析引擎里加四类检查,计入每张表的预算、止损和留痕:codes_observe——所有码值列都观察,包括人工确认过、标了已列全的列;只读,不改含义。freshness——业务日期列(已确认或已验证)的 MAX:小表直接查,大表只在该列是某个索引的第一列时才查(A1 把索引的首列存进 meta)。daily_volume——最近 35 天按天计数,条件同上。rows——统计信息估算或有上限的计数。② 每条观察都记口径 basis:full、first_rows N、window since D 或 sample p%,只和口径相同的历史比。③ 新表 table_observations:source_id、table、column、kind、basis、data、job_id、at,每张表每种观察保留最近 26 条。④ 纯函数比较器 data/value_drift.py,规则如下。新码值:已列全的码值表出现任何新值都算;没列全的,新值要占非空行的至少 0.5%。比例剧变:PSI ≥ 0.25 记 warning,0.1–0.25 记 info,两次都要至少 1,000 行,并列出变化最大的几个取值。断档:最新业务日期没前进,且距上次的时间超过 max(2×中位间隔, 3 天)。缺天:窗口内有日子没数据,而以前的窗口没有过。量跌:最近一个整天不到前 28 天中位数的 30%。行数:跌了 30% 以上;依据是估算值时,要连续两次都这样才报。⑤ 机器唯一能写进目录的一件事:观察到新取值时撤销码值项上的「已列全」标记。目录版本加 1,现有的 catalog_drift 和冻结快照自然会跟上,SQL 检查也不再按过期的码值表提示。新取值本身、含义、状态都不改,交给人在变化里处理。⑥ 大表的口径按 D7:有可用的业务日期索引就用窗口;PG / Oracle 没有就用块采样(TABLESAMPLE SYSTEM / SAMPLE BLOCK);MySQL 没有就只看前 N 行,照实写口径,不跨口径比较。⑦ 遮罩列不出现在任何一条观察查询里;变化里的码值只记非遮罩列的。 +- 复用:catalog_profile 的 TableBudget、SourceRunner、_TableCx.run(留痕、守卫、时限)、estimate_size、value_counts_sql;catalog._merge_codes;catalog.make_item;catalog_profile_jobs 的范围挑选(new_checks 再加一条「有观察项到期」) +- 文件:修改 data/catalog_profile.py(新增 check kind、SqlDialect 新增 date_literal / day_trunc / sample_from)、data/catalog_profile_jobs.py、db/models.py(TableObservation);新增 data/value_drift.py;修改 data/schema_view.py(观察结论进表详情);前端 pages/catalog/ProfileDialog.tsx、TableDetail.tsx(「结构与取值变化」一节) +- 对上的理念:照实说(口径不同不比;大表只看前 N 行就写明)。只读与遮罩。目录里只放事实:撤销「已列全」是数据给出的事实,含义和状态仍然只由人定。不挡生成(观察失败只记跳过) +- 否决的备选:确认过的码值列不观察:新状态永远发现不了,有效记录条件会悄悄漏掉新状态的行。机器把新码值直接写进确认过的码值表:等于替人改了已确认的事实。大表一律只看前 N 行(现状):物理顺序靠前的多半是老数据,正好看不到新取值。用全表 COUNT 做行数漂移:大表上太贵,统计信息加有上限的计数已经够判断大幅变化 +### B3 变化记录、审阅、通知,以及给召回的接口 +- 做法:① 新表 source_changes:seq(自增,当游标用)、id、source_id、source_name(当时的名字)、table_name、column_name、kind(结构类:table_added、table_removed、table_left_scope、column_added、column_removed、column_type_changed、column_nullable_changed、key_changed、fk_changed、comment_changed、scope_changed;取值类:codes_new、codes_shift、date_stalled、date_gap、volume_drop、rows_drop、rows_jump、coverage_drop)、severity(info / warning / action;action 表示现在大概率已经不对了:已确认的项或写死的 SQL 指向被删的列,或者已列全被撤销)、detected_at、detected_by({job_kind, job_id, task_seq, trigger: manual|schedule|on_demand, actor})、basis({method, query_refs})、before、after、affects({catalog: [{path, status, source}], templates: [{workflow_id, version, impact, nodes}], rules_touched: bool})、struct_version_before / after、fingerprint(去重用:同一个变化再次测到只更新 last_seen_at 和 seen_count)、status(open / acknowledged / resolved / dismissed)、resolution({by, at, action, note, catalog_version})、schema(固定为 agentlab-change/1)。② 审阅页加一个「变化」标签页,排序沿用审阅队列的思路:使用次数权重 × 严重程度。处理动作都落到已有的操作上:清理目录里的这几项(save_human_edit)、加入码值(apply_patch 配 _merge_codes,含义待填写,确认时的依据写这条变化的编号)、重新起草这张表(建起草任务)、重新剖析(建剖析任务)、知道了、误报(必须写理由)。每个动作都写 resolution,涉及目录的还要记下目录版本。③ 站内:数据源卡片上的角标「N 个变化要处理」、目录页头部的计数、表详情里的一节。④ 外部通知:notify 加一个事件 source.changed,只推 severity 为 action 的,按源汇总,消息里只有源名、各级别条数和链接(D9)。⑤ 给口径账本的召回:data/source_changes.py 提供 list_changes(session, *, since_seq, source_id, kinds, status, limit)、affecting(session, source_name, tables, since)、resolve(...)。触发召回的只有结构类、目录事实类(处理时改了目录)、以及 rules_touched=true 的 codes_new;纯取值漂移只算数据质量提醒,不触发召回。召回在调度循环的 process_due 里按 seq 游标拉取,不另建进程内事件总线。 +- 复用:catalog_review 的权重和分页;catalog.apply_patch、save_human_edit、_merge_codes;catalog_jobs / catalog_profile_jobs 的建任务入口;notify.service(spawn、渠道订阅、投递记录);scheduler.loop.process_due 的步骤列表;catalog.query_snapshot_tables(召回反查「谁用过这张表」时用) +- 文件:新增 data/source_changes.py、api/source_changes.py(GET /api/datasources/{id}/changes、POST /changes/{id}/resolve);修改 db/models.py(SourceChange)、data/catalog_review.py、notify/service.py(EVENTS、EVENT_LABEL、消息拼法)、api/coded.py;前端 pages/catalog/ReviewQueue.tsx(「变化」标签页)、pages/DataSourcesTab.tsx(角标)、pages/catalog/TableDetail.tsx、lib/terms.ts(统一叫「结构变化」「取值变化」,和已经在用的「目录有变化」分开) +- 对上的理念:留痕(谁测到、什么口径、前后各是什么、谁怎么处理的)。一种说法(全站只有 source_changes 这一份记录;处理动作复用目录已有的写入接口)。生效可查可对账(处理结果连到目录版本)。只读与遮罩(不记遮罩列的取值,通知里不带表名和取值) +- 否决的备选:把变化塞进审阅队列、和推断项混成一张表:两边的动作完全不同(确认 / 驳回 / 撤销,对比 清理 / 加码值 / 知道了),批量操作会混乱。复用 RunEvent 来记:变化不属于任何一次运行,而且需要「处理」这个状态。进程内事件总线推给召回:重启就丢,靠游标拉取更可靠 +### B4 定时维护 +- 做法:新表 source_maintenance,每个源一行:cron、timezone、enabled、next_run_at、misfire_grace_s、steps(introspect / profile_needed / observe,可多选)、last_fire_at、last_jobs。认领沿用 scheduler.loop 的做法(条件 UPDATE 推进 next_run_at、错过的只补最近一次、积压只记一条),作为一个新步骤放进 process_due。到点按顺序建探查任务 → 剖析任务(范围 needed,再加观察到期的表),任务上记 trigger=schedule 和 fire_at,靠 SourceLanes 串行。剖析没开、数据源停用、AGENTLAB_SCHEDULER 关着时不跑,照实记一条未执行。缺省关闭,开启时要在数据源设置里显式打开,并选一个低峰时段。界面把 ScheduleEditor 里的频率选择器抽成公共组件来复用。 +- 复用:scheduler/cron.py(parse、next_fire、last_due、preview、中文描述)、loop._claim 的条件 UPDATE 写法、catalog_job_runtime、ScheduleEditor 的频率选择 +- 文件:新增 data/source_maintenance.py;修改 scheduler/loop.py(process_due 加一步)、db/models.py、api/datasources.py(GET / PUT /maintenance)、data/catalog_profile_jobs.py 和 introspect_jobs.py(trigger 字段);前端 pages/DataSourcesTab.tsx、把 pages/schedules/ScheduleEditor.tsx 里的 FreqPicker 抽到 components/ +- 对上的理念:只读、不给业务库加压(缺省关、只在设定时段跑、预算和止损照旧)。照实说(没执行要写明原因)。留痕(每次触发对应几条任务记录)。一种说法(cron 和认领沿用调度那一套,不另写) +- 否决的备选:挂在工作流定时任务上:要先建一个工作流去调「剖析」,模型、审批、封存那一套全被卷进来,而且 Schedule.workflow_id 是必填的。把 Schedule 泛化成 kind + target:定时任务页、通知、自动对比都默认它是工作流,改动面太大 +### C1 剖析与探查的方言修正 +- 做法:① IN 列表的字面量按被指向列的类型族生成(catalog._type_family):父列是文字、子列是数字时写成 '123';父列是数字、子列是文字时,只收整数写法的值;类型族对不上又转不了的,跳过并说明「两端类型不同」。② 列类型改存按方言编译的完整写法:col_type.compile(dialect=conn.dialect),编译失败退回 str。ENUM / SET 另外记下取值列表,enum_values 进 meta。_code_type_ok 认 ENUM(取值列表有限,直接算码值候选)。③ Oracle 的 quoted_name(quote=True) 在 meta 里另记 quote_name: true;SqlDialect.quote 和 stats_sql 按它强制加引号、不做大小写转换。④ 统计信息估出的行数不超过 max_scan_rows 时,再跑一次有上限的计数确认(多 1 条查询),防止统计过期把大表当成小表,对它做整表统计。⑤ MIN/MAX 的结果过滤掉 0000-00-00 和 0001-01-01 / 9999-12-31 这类哨兵值,照实写「有 N 个无效日期被排除」(零日期、infinity 的驱动返回值没核实,按两种可能都防)。⑥ 新增取值观察用的方言方法:date_literal(三家都写 DATE 'YYYY-MM-DD',SQLite 写字符串)、day_trunc(PG 用 CAST(x AS DATE),MySQL 用 DATE(x),Oracle 必须用 TRUNC(x),因为 Oracle 的 CAST(ts AS DATE) 仍带时分秒;SQLite 用 substr(x,1,10))、sample_from(PG 用 TABLESAMPLE SYSTEM(p),Oracle 用 SAMPLE BLOCK(p),MySQL / SQLite 返回 None)。⑦ 说明文字照实写:「抽样」改成「按存储顺序取前 N 行」;MySQL 上覆盖率和唯一性按数据库的排序规则算(可能不分大小写)。 +- 复用:SqlDialect、sql_literal、catalog._type_family、introspect._collect、本机已验证过的 SQLAlchemy 编译行为 +- 文件:修改 data/catalog_profile.py:218-339, :1011-1060, :1081-1094, :1184-1207、data/introspect.py:264-286、data/catalog.py(_type_family 认 ENUM)、data/schema_diff.py(基线规则) +- 对上的理念:照实说(口径、无效日期、排序规则都写明)。只读(多出来的那条确认计数也有上限)。不挡生成(类型对不上就跳过并写原因,不报错停下) +- 否决的备选:改用 sqlglot 生成各方言的 SQL:它给 Oracle 生成的是 FETCH FIRST,11g 不认,而且会把现有已经对了的拼法全部推翻。类型保留通用写法、另加一个 native_type:同一列出现两种类型说法,下游各取各的 +### C2 没有真库时的方言验证,以及需要放行的真库只读验收 +- 做法:① 金样 SQL:每种方言(mysql、mariadb、postgresql、oracle、sqlite)× 每类查询(stats、bounded、distinct、match、unique、value_counts、min_max、date_window、day_volume、sample),把完整 SQL 钉住,人审一次,以后任何改动都有 diff。② 逐条用 sqlglot 按方言解析,挡语法回退。③ 类型一致性单测:用 sqlglot 的 qualify 和 annotate_types 配上 schema_cache 的类型,核对 IN 两边的类型族。④ 本机容器集成测试 scripts/dialect-it.sh:不进 CI,要用户同意才跑。镜像用 postgres:16、mysql:8.4、mariadb:11、gvenzl/oracle-free:23-slim(ROWNUM 路径在 23 上同样走得到)。夹具包括:保留字列(LEVEL、DATE、TYPE)、Oracle 小写加引号的表、CHAR(2) 码值、MySQL ENUM、关掉 strict 写入的零日期、手动做旧的统计信息、超过 max_scan_rows 的大表、带索引的业务日期列。全部剖析查询和观察查询跑一遍,断言没有报错、结论和手算的一致。⑤ 用户本机 MySQL / Oracle 开发源的只读验收(这次没有连):1)用户给只读账号,或者同意复制开发数据目录;推荐建一个全新的数据目录 /tmp/dialect-accept/data,只登记这两个源,不复制 agentlab.db,免得复制密钥。2)从快照工作区起验收服务 127.0.0.1:8796,AGENTLAB_SCHEDULER=0。3)剖析设置收紧:table_max_queries 15、query_timeout_s 5、sample_size 200、max_scan_rows 20000,在低峰时段跑。4)用户挑 5–8 张表:含保留字列的、有外键的、有 CHAR / ENUM 码值的、一张大表、一个视图(应该被跳过)、一列遮罩列;Oracle 再加一张小写名的表(如果有)。5)建 selected 范围的剖析任务,读 task_detail 的 query_log,期望没有 ORA- 或 MySQL 报错,遮罩列不出现在任何一条 SQL 里。6)用户在自己的客户端手算两三个数(某个码值的行数、某个日期列的 MAX),和结论对照。7)跑一次 introspect dry_run,核对类型写法。8)MySQL 核对会话是只读的;Oracle 没有会话级只读,由用户确认账号只有 SELECT 权限。9)收尾:停服务、删 /tmp 下的数据,真实表名只留在 /tmp,带进仓库的只有匿名后的概括。 +- 复用:tests/test_catalog_profile_units.py 的参数化写法;/tmp/dialect-probe 的探测脚本;tests/fixtures/acceptance/retail.py(虚构零售库);之前 cd-accept / cp-accept 的验收脚本模式 +- 文件:新增 backend/tests/test_profile_dialect_golden.py、tests/fixtures/dialect/*.sql、scripts/dialect-it.sh(只在本机跑);修改 tests/test_catalog_profile_units.py +- 对上的理念:只读(验收不写业务库,Oracle 的只读靠账号权限,必须用户确认)。真实数据不进仓库。照实说(能挡什么、挡不了什么写明白) +- 否决的备选:只靠 sqlglot:已实测 8 类 SQL 全部能解析,可类型错配、标识符大小写、统计过期、零日期这些它都看不出来。直接连用户开发库验收而不收紧预算:用户开发数据里的 Oracle 源本来就开着剖析,必须先把范围缩到选定的表 +### E Excel 期 5:本轮做 4 项,推后 2 项,不做 2 项 +- 做法:要做:① 列表按期累积。ListBlock 增加 period,二选一:{column: 日期列, check: within_context}(每行的日期都要落在统计期内),或者 {synthesize: "统计期"}(自动加统计期起、止两列)。accumulate_blockers 放行满足条件的列表;重叠拒收、替换某一期、回滚、物化并集都复用 recipe_accumulate 和 source_versions。② 穿插小计。ListRows 增加 subtotals: {label_column, pick, scope: since_previous|group_column}:执行器核对每一行小计等于它所管那组明细的和,小计行不进数据,记入 rows_excluded(reason=subtotal),可以另存成 reported_total 表;规则起草把现在记成失败的 stray_totals 改为提议成小计。③ 表格对象起草。xlsx_scan 已经读出表格对象的 ref、表头和汇总行,规则起草优先按它出列表块:锚点仍按表头文字,不记坐标,汇总行映射成 total_row。定义名称只当作统计期格子的提示,不单独做。④ CSV 走配方:把 CSV 适配成 Grid(编码识别复用 tabular 读 CSV 的那一段),格子坐标记成 R{行}C{列},后面的引擎、核对、溯源、按期累积全部复用。推后:⑤ 不兼容变化的历史重放(用新配方重跑保留着的原件、重建并集;原件被清除的期只能照实说回不去),等列表累积有了真实用例再做。⑥ 解析器升级后的重放比对,等 parsers/3 立项时再做。不做:⑦ 原件下载(D13 已定为不提供下载、可以清除)。⑧ 「表格文件」节点(原方案的核心决定:格子变成表必须经过配方和 SQL)。 +- 复用:recipe_accumulate.plan_accumulate、source_versions 物化、recipe_engine 两遍对账和格子账、recipe_fixes(加一个小计修复按钮)、xlsx_scan 的表格对象、tabular 的 CSV 读法、provenance 的格子回指 +- 文件:修改 data/recipe_types.py、recipe_engine.py、recipe_suggest.py、recipe_accumulate.py、recipe_checks.py、recipe_fixes.py、recipe_imports.py(CSV 的入口)、xlsx_cells.py(Grid 适配)、provenance.py(CSV 坐标);前端 pages/import 向导(统计期的绑定方式、小计的确认卡片) +- 对上的理念:模板定骨架:配方是骨架,起草只提议,人确认后才生效。照实说(小计核对不过就整份拒收)。叙述层无算术(小计只用来核对,不进叙述)。留痕(排除的行写明原因) +- 否决的备选:把穿插小计当成 IgnoreRow 忽略掉:等于丢了一道免费的核对。CSV 另写一套解析:会和配方语言分叉,违背「一种说法」 + +## 待拍板(本块) +- D1 几千张表的库,总体怎么做|选项:A 范围规则 + 全库对象索引 + 范围内后台完整探查 + 按需加入 / B 把上限提到几千,全量反射 / C 按 200 张一页分页探查 / D 不探查,全靠 agent 查数据字典|推荐:A | B 会让 schema_cache 和冻结的快照线性膨胀,MySQL 一表一往返;C 的页内容会漂(PG、Oracle 的反射查询没有 ORDER BY);D 等于目录、SQL 检查、look 全部失效。A 能让几千张表都查得到、找得到,真正反射的只有几百张 +- D2 一个数据源能不能跨多个 schema|选项:A 一个源一个 schema;要多个 schema,就用「同一连接再接一个 schema」快捷建源 / B 一个源跨多个 schema,表名的键改成 schema.表名|推荐:A | 目录的键、工具名 db_query__<源>、resolve_table_name、SQL 检查、使用次数全都按裸表名;B 要改 220 处 schema_cache 的用法,还要迁移已经存下的目录键,两个 schema 里同名的表还会在 resolve 里撞上 +- D3 探查范围和对象索引的上限|选项:A 范围缺省 500、最多 2,000;对象索引最多 50,000 / B 范围缺省 1,000、最多 5,000 / C 保持 200|推荐:A | 500 张 × 25 列大约 1 MB JSON,每次读数据源都整份加载,冻结快照也是这么大;2,000 张约 4–5 MB,还能接受;再大就得改存储,不在本轮 +- D4 助手查看或运行里的 db_schema__ 点到范围外的表时,要不要当场探查、落盘|选项:A 当场只读探查这一张,落盘为「按需加入」,留痕谁、哪次运行、为什么;每次查看最多 4 张,每次运行最多 8 张 / B 只临时给出结构,不落盘 / C 不按需探查,只提示人去加|推荐:A | B 下 SQL 检查、目录都看不到这张表,下一轮还要重新反射;C 用户点名的表答不了,体验上等于范围外的表不存在。A 有上限、有痕迹,范围的增长看得见 +- D5 要不要做定时维护(会改变现有「生产库不该被实验工具定时扫」的立场)|选项:A 按数据源开启、缺省关,只在设定时段跑:重新探查 → 剖析需要的表 → 取值观察 / B 挂在工作流的定时任务上 / C 不做定时,只能手动|推荐:A | 取值漂移和结构变化要靠定期观察才看得到;缺省关、显式开、限定时段、预算和止损照旧,守住「不给业务库加压」;B 会卷进模型、审批、封存那一套 +- D6 取值观察要不要也查人工确认过的码值列;新取值出现时机器能改什么|选项:A 照样观察,只读;数据证明不再列全时,机器只撤销「已列全」标记,其余进变化列表等人处理 / B 只观察、记变化,不撤销 / C 不观察确认过的列(现状)|推荐:A | C 下新状态永远发现不了;B 下 SQL 检查还会照着过期的码值表提示。撤销「已列全」是数据给出的事实,不碰取值的含义和项的状态,也不违背「确认只由人做」 +- D7 大表的取值观察用什么口径|选项:A 有确认的业务日期、且该列打头有索引:看最近 35 天的窗口;PG / Oracle 没有就用块采样;MySQL 没有就只看前 N 行,标明口径、不跨口径比较 / B 一律只看前 N 行(现状) / C 大表不观察|推荐:A | 前 N 行是物理顺序,多半是老数据,看不到新取值;窗口查询只在有索引时才发,成本可控;采样只有 PG、Oracle 有。每条观察都写明口径,不同口径不比 +- D8 表结构变化怎么作用到已发布模板和正式运行|选项:A 发布时记下表的结构版本;正式运行发起时提醒(不拦);发布前检查列出还没处理的结构变化;断档类变化也在发起时提醒 / B 拦下正式运行 / C 只在变化列表里列出|推荐:A | 和目录漂移同一档:只是加了一列不该卡住正式出具;真删了列,查询本身就会照实报错。C 下出具的人看不到 +- D9 外部通知带不带表名、列名|选项:A 新事件「数据源有变化要处理」,只推要处理的,消息只有源名、各级别条数和链接 / B 带表名和列名 / C 不发外部通知,只在站内显示|推荐:A | 沿用通知只放标题、状态、链接的规矩;表名、列名可能就是业务敏感信息(开发库的真实表名不进仓库也是同一个道理),点链接进站内看全。要不要更细由用户定 +- D10 列类型怎么存|选项:A type 改成按方言编译的完整写法(VARCHAR2(20 CHAR)、ENUM('a','b')),升级后第一次探查只建基线、不报变化 / B 保留 type,另加 native_type / C 不改|推荐:A | C 下类型变化检测不出来、ENUM 列漏掉;B 下同一列有两种类型说法,下游各取各的。A 的代价是新运行的快照哈希会变(旧运行不受影响),以及测试和提示词要跟着调整 +- D11 Excel 期 5 这一轮做哪些|选项:A 做列表按期累积 + 穿插小计 + 表格对象起草 + CSV 走配方;历史重放、解析器升级比对推后;原件下载、「表格文件」节点不做 / B 只做列表按期累积 / C 这一轮不做 Excel|推荐:A | 每月一份的明细导出最常见,这 4 项互相依赖、都复用现有引擎;历史重放要等有真实用例;原件下载在 D13 已经定了不提供;「表格文件」节点违背原方案的核心决定 +- D12 方言的真库验证放不放行、怎么放行|选项:A 同意在本机用 docker 起四种库的容器跑集成测试;用户再给本机 MySQL / Oracle 开发源的只读账号,在全新数据目录里登记,只剖析 5–8 张选定的表 / B 复制开发数据目录(要放行复制含密钥的 agentlab.db) / C 不做真库验证,只做金样和 sqlglot|推荐:A | C 挡不住类型、大小写、统计过期这类问题;B 要复制密钥,验收结束还得记着删。A 隔离最好,Oracle 的只读要靠账号权限,必须用户确认 + +## 风险 +- schema_cache 和冻结快照的体积:范围到 2,000 张时约 4–5 MB,每次读数据源都整份加载。要实测,必要时把冻结快照改成按表内容寻址(会动证据层的哈希链,本轮不做) +- 按需探查发生在运行当中:和后台探查任务争着写 schema_cache,只靠进程内锁兜,前提仍是单进程部署(和现有的回收锁、SourceLanes 一样);多进程部署要换成库里的锁 +- 漂移误报太吵:靠最小行数、PSI 阈值、同口径才比、按指纹去重、行数估算要连续两次才报;第一版阈值要用真实库跑一段时间再调 +- 定时维护给业务库加压:缺省关、限定时段、预算和止损照旧;Oracle 没有会话级只读,必须配只读账号 +- 类型改成方言完整写法:新运行的快照哈希会变、提示词里的类型变长、一批测试金样要更新;升级后第一次探查必须只建基线,否则所有 Oracle 列都会被报成「类型变了」 +- PG / Oracle 的块采样、MySQL 零日期、PG infinity 日期这几处驱动和服务器的行为没核实,要靠容器集成测试或用户的开发源来确认 +- 规则命中超过上限就拒绝:配置门槛变高,界面要实时显示命中数,并给出收窄的建议 +- 变化里存了非遮罩列的码值(目录里本来也有),存在 AgentLab 自己的库里;外部通知按 D9 不带。泄露扫描的名单要加上新表 +- 召回依赖 agentlab-change/1 的格式:字段以后改要升版本,召回那边按版本读 +- Excel 列表按期累积:明细行跨期重复(同一订单在两期都出现)这种语义要在配方里显式声明,靠 within_context 只能保证日期不重叠 + +## 测试 +- 范围规则:通配匹配、按 name_key 不分大小写、排除优先、有目录 / 有发布引用的表 pinned 排不掉、命中超过上限时 409 scope_too_large 并给出命中数、规则改动记一条 scope_changed +- 对象索引:五种方言的查询金样;查询失败退回 Inspector(没有注释);索引上限 +- 后台探查任务:用 SQLite 生成 3,000 张表的夹具,逐表进度、停止、只重试失败的表、kill -9 后启动标中断、和起草 / 剖析排队串行;schema_cache 重建后形状不变(拿现有的 schema_view、sqlcheck、look 测试回归) +- 反射失败、范围外、真删除三种情况在目录清单、剖析、look 里的说法各不相同;不再出现窗口外的表被标成「表已不在」 +- schema_diff 纯函数:加列 / 删列 / 类型 / 可空 / 注释 / 主键 / 唯一 / 外键、疑似改名、left_scope ≠ removed、升级后基线不报变化 +- 影响面:valid_filter 引用的列、业务日期、关联两端、人工确认的项升为要处理;已发布模板写死的 SQL 用新结构跑 sql_unknown_columns 报出会坏的列 +- 结构版本:发布时记 schema_versions;正式运行发起时写 schema.drift 事件、不拦;发布前检查列出未处理的变化;前端 decode / stream / ui 三层检查都覆盖新事件 +- 取值观察:确认过、已列全的码值列出现新取值 → 记一条 codes_new、撤销已列全、目录版本加 1、SQL 检查不再提示 unknown_code;PSI 刚好落在阈值两侧;断档和缺天;口径不同不比、照实说明;遮罩列不出现在任何 SQL 里(检查留痕) +- 大表口径:有索引的业务日期走窗口;PG / Oracle 生成采样 SQL;MySQL 退回前 N 行并写明口径 +- 方言金样:五种方言 × 全部查询类型的完整 SQL;sqlglot 逐条解析;IN 两边类型族一致(PG 上 varchar 父列配整数子列时生成带引号的字面量);Oracle 的 quote_name 强制加引号;统计估算 ≤ 上限时多数一次;ENUM 进码值候选 +- 容器集成测试(要用户同意,本机跑):四种库上剖析和观察全部查询零报错,结论和手算一致 +- 变化记录:同一变化重复测到只更新 last_seen_at;处理动作落到 apply_patch / save_human_edit 并记目录版本;误报必须写理由;按 seq 游标拉取接口 +- 定时维护:认领、错过的补最近一次、积压只记一条、AGENTLAB_SCHEDULER 关闭时不跑、剖析没开时照实记未执行 +- 通知:用 MockTransport 收到 source.changed,消息里只有源名、条数和链接 +- look 大库:3,000 张表的合成库配 10 条脚本化需求,预筛后挑选提示词 < 20,000 字,挑中率 ≥ 9/10;范围外的表被点名时按需加入并留痕;每次查看 / 每次运行的上限 +- 前端新检查脚本 check-catalog-changes.mjs(假接口加夹具);扩展 check-catalog(范围的说法、分批渲染)、check-catalog-profile(观察结果那一行);数据源页的范围面板和定时维护 +- Excel:三期月度明细的列表累积(重叠拒收、替换、回滚);穿插小计核对通过和不通过各一例;表格对象起草不调用 AI;CSV 走配方后格子溯源到 R{行}C{列};真实文件只在本机验收,测试夹具用假名 + +## 验收 +- 3,000 张表的合成库:对象索引列全 3,000 个;规则选出 400 张,后台探查有逐表进度并完成;目录页 2 秒内打开;没有一张范围外的表被说成「表已不在」 +- 助手在这个库上按 10 条需求挑表,至少 9 条挑对,挑选提示词不到 20,000 字;用户点名一张范围外的表,会按需加入,look 记录里看得到 +- 重新探查前对 SQLite 夹具做 ALTER(加列、删列、改类型、改名):变化恰好就是这几条,疑似改名只说「可能」;删掉的列上人工确认过的项、写死这一列的已发布模板都列为要处理;从这一版发起的正式运行出现结构变化提醒、运行照常发起 +- 确认过且已列全的码值列被插入新取值:下一次观察记一条 codes_new,已列全被撤销,SQL 检查不再按旧码值表提示;比例剧变、最新业务日期不前进、缺天各触发一条;口径不同的不比,并写明原因;所有查询的留痕里都没有遮罩列 +- 定时维护在设定时段触发,和起草、剖析排队串行;中途止损照常生效;服务停机期间错过的只补最近一次 +- 通知渠道收到「数据源有变化要处理」,消息里只有源名、条数和链接;处理一条变化后,目录版本和 resolution 都查得到 +- 方言金样和 sqlglot 解析全过;如果用户放行:四种库容器的集成测试零报错,本机 Oracle / MySQL 开发源上选定表的剖析 query_log 里没有 ORA- 或 MySQL 报错,两三个手算的数对得上 +- Excel:三期月度明细累积成一张表,重叠的那期被拒;带穿插小计的文件规则起草成功、小计核对通过、小计行不进数据;带表格对象的文件不调用 AI 也能起草出列表块;CSV 走配方后格子能回指 +- 后端全量测试通过、tsc、check-copy、新旧前端检查脚本、泄露扫描干净;按记忆里的分层规矩,代理只跑相关测试,完整关口由编排者跑 + +## 工作量 +估计,按以往节奏推算,没核实:剖析逐表任务那一轮约后端 1,500 行、前端 1,000 行,含测试。本轮拆成 5 个工作包。A 范围、索引与后台探查(含 A2 配套):后端约 2,500–3,000 行(测试约占四成)。C 方言修正与验证:后端约 800–1,200 行,加金样夹具和 dialect-it 脚本。B 变化(结构差异、取值观察、变化记录、审阅、通知、定时维护、结构版本):后端约 3,500–4,500 行。D 前端:范围面板、目录分批渲染与搜索、变化标签页、表详情一节、定时维护、运行横幅、文案,约 3,000–4,000 行,加检查脚本;建议拆成 D1(跟 A 的接口约定)和 D2(跟 B 的接口约定)。E Excel 期 5 四项:后端约 3,000–4,000 行,前端约 800–1,200 行。依赖关系:C 里「类型改成方言完整写法」要在 B 的结构差异建基线之前落地;B 依赖 A 的 source_objects 和结构版本;D1 依赖 A 的接口、D2 依赖 B 的接口;E 和其余各包互不相交(文件都在 recipe_*、xlsx_*、pages/import 下)。建议分三波:第一波 A、C、E 并行;第二波 B、D1;第三波 D2。每包一个完整代理,最后统一复核一次,避免限流。日历上大约 2–3 周,含真实验收;如果放行容器集成测试,另加约 1 天 + +## 和其他几块的接口 +- 口径账本(召回):消费 source_changes,接口是按 seq 游标拉取的 list_changes 和 affecting(source, tables, since);处理时改了目录的会带上目录版本,结构变化带前后的结构版本。只有结构类、目录事实类、触及规则的新码值触发召回,纯取值漂移只算数据质量提醒。反查「谁用过这张表」复用 catalog.query_snapshot_tables。格式是 agentlab-change/1,两边要对齐 +- 效果度量与评测(1):大库挑表的命中率可以做成一项评测(3,000 张表的合成库 + 需求集);look 记录里新增的 added_to_scope 和预筛命中数可以当指标 +- 对外集成(7):通知渠道新增事件 source.changed(notify/service.py:30 的 EVENTS、EVENT_LABEL,前端的订阅列表);如果做 API 令牌,变化列表和处理接口要纳入权限;做英文界面要覆盖本轮新增的文案 +- 已知遗留:「剖析结果进 look 的 schema_view」在本方案 A2 / B2 里一并做;check-catalog-loop 的假数据源设置还带着旧键、目录页头部和起草进度条重复显示进度,这两项都在 CatalogPage.tsx 和检查脚本里,和 D 包会撞文件,要先合或者同包做 +- 可能冲突的后端文件:data/introspect.py、api/datasources.py、data/catalog_profile.py、catalog_profile_jobs.py、catalog_job_runtime.py、catalog_review.py、catalog_impact.py、data/schema_view.py、look/tables.py、look/base.py、tools/datasource.py、api/copilot.py(sql_unknown_columns 搬家)、engine/launch.py、core/events.py、db/models.py、scheduler/loop.py、notify/service.py、api/coded.py、api/workflows.py +- 可能冲突的前端文件:lib/terms.ts、types.ts、api/client.ts、pages/DataSourcesTab.tsx、pages/catalog/*(CatalogPage、TableIndex、TableDetail、ReviewQueue、ProfileDialog)、run/decode.ts、pages/runs/Banners.tsx、canvas/VersionsSheet.tsx、pages/schedules/ScheduleEditor.tsx(抽公共组件) +- 部署:新表只加不改,WorkflowVersion 只加一列;升级后第一次启动要重建 schema_cache,第一次探查只建基线;类型写法一改,新运行的快照哈希会变。10.0.0.11 仍待用户自己重新部署,部署清单要加上「升级后对每个源手动点一次探查,建立基线」 +- 设计文档:方案落在 docs/design/ 下的 md(不做 artifact);真实库名、表名要匿名,提交前提醒用户 + +## 附:现状(代码位置) + +- 探查结构每个源最多取 200 个对象,先拼表名再拼视图后截断:表超过 200 张时视图一个都不剩。truncated、total 记了总数 [backend/app/data/introspect.py:28, :180-183, :287-288] +- 截断窗口不稳定。PG、Oracle 反射列表名的查询没有 ORDER BY(已核对本机 SQLAlchemy 2.0.52 源码:postgresql/base.py 的 _get_relnames_for_relkinds、oracle/base.py 的 get_table_names),MySQL 用的是 SHOW FULL TABLES。哪 200 张入选取决于数据库返回的顺序,库里加了表就可能换一批;界面文案也只写了「按数据库列出的顺序」 [introspect.py:180-183;frontend/src/lib/terms.ts:2754-2758] +- 能限定范围的只有 options.schema(一个 schema),没有按表名包含 / 排除的规则。datasource_scope_empty 是助手 / 问数据「本轮只查哪些数据源」的机读码,和表的范围无关 [introspect.py:114-119;api/coded.py:12;api/copilot.py:44-67] +- 探查是同步 HTTP 请求,结果整份替换 schema_cache:不比较前后差异,也不进 SourceLanes,起草、剖析进行中照样会被整份换掉。代码注释明确写着「显式动作,不做后台轮询——生产库不该被实验工具定时扫」 [api/datasources.py:822-865(:829、:857)] +- MySQL 方言没有批量反射,每张表一次 SHOW CREATE TABLE。索引读了,但只用来算唯一列组,索引本身不存下来 [introspect.py:185-196, :243-246, :284-286] +- 超出 200 的表在各处的表现:目录清单把还留着目录的表排在最后,标「表已不在」(in_schema=false),其实可能只是落在窗口外;剖析说「表结构里没有被指向的表,请先重新探查结构」,可重新探查也没用;起草记 skipped;SQL 检查的 table_meta 找不到表就静默不查;look 只在详情里附一句 UNEXPLORED_PROMPT;db_schema__ 让 agent 自己去查数据字典;证据层对不全的快照说「核对不了」(这一处是对的) [api/catalog.py:238-262;data/catalog_profile.py:1429-1431;data/catalog.py:2050-2061;data/sqlcheck.py:147-162;look/tables.py:41, :329-330;introspect.py:450-458;engine/evidence.py:22-25] +- look 挑表:候选超过 12 张,就把全部候选的「表名|中文名|粒度」整份交给挑选模型,事先不做任何筛选。db_schema__ 不传 table 时列出全部表名 [look/tables.py:245-257;look/base.py:25-26;tools/datasource.py:798-808] +- 运行冻结的表结构快照包含整个 schema_cache["tables"](按内容寻址),体积随探查张数线性增长。schema_cache 是 DataSource 上的一列 JSON,每次读数据源都整份加载 [tools/datasource.py:729-760;db/models.py:56-57] +- 已有可复用的 BM25 和中英混合切词(英文按词,中文按字再补二元组) [memory/embeddings.py:15-21, :268] +- CATALOG_DRIFT 只比目录版本:发布时记下 direct / possible 两组表的目录版本,正式运行发起时比对,只提醒、不拦,不看表结构。全仓没有任何表结构变化的检测或记录。WorkflowVersion 上同类的钉法已有 catalog_versions、capability_versions 两列 [data/catalog_impact.py:306-402;engine/launch.py:164-199;core/events.py:76-79;db/models.py:620, :629] +- 合并规则:已确认、已驳回的项起草永远不碰。所以表结构删列之后,那一列上人工确认过的项会一直留着。只有本轮完整用模型起草时才删 llm 项。审阅队列不列表结构里已经没有的列上的项 [data/catalog.py:611, :626-692;data/catalog_review.py:30-31] +- 定时调度只认工作流:Schedule.workflow_id 是必填,loop.fire_due 只会发起运行。process_due 是一个可以往里加步骤的列表(待办时限、写入查询、上传清理) [db/models.py:967-1001;scheduler/loop.py:36-51, :123-135] +- 剖析的留痕可以当基线:每张表有 findings(码值的各取值和行数)、date_ranges、row_estimate、query_log。保留最近 50 个任务,但每张表最近一次所在的任务不删 [db/models.py:482-526;data/catalog_profile_jobs.py:62-64] +- 码值漂移的盲区有三处:人工确认 / 驳回过码值的列不再取分布,新取值永远发现不了;大表的抽样和取值分布只看物理顺序的前 max(sample_size, max_scan_rows)=100,000 行,多半是最老的数据;日期的 MIN/MAX 只对不超过 10 万行的表做 [catalog_profile.py:1115, :843-847, :1188-1192] +- 标了「已列全」(complete)的码值表,SQL 检查会据此报 unknown_code(依据全有确证时是 warning)。出现新取值以后,这个标记不会自动撤销 [sqlcheck.py:109-113, :906-955] +- 通知渠道能订阅的事件只有 5 种(待办、实例完成 / 失败、写入结果未知、审核结果);消息只放标题、状态和链接 [notify/service.py:1-12, :30] +- 剖析的方言层 SqlDialect 是手拼 SQL:标识符交给 SQLAlchemy 的 identifier_preparer 按需加引号;Oracle 一律套一层 ROWNUM,不用 FETCH FIRST;行数先读统计信息(TABLE_ROWS / reltuples / NUM_ROWS),≤0 当作不知道,再数到上限为止;IN 列表每批 1000 个,恰好等于 Oracle ORA-01795 的上限。语句时限三家都有:PG 用 SET LOCAL statement_timeout,MySQL 用 max_execution_time(MariaDB 用 max_statement_time),Oracle 用 call_timeout [catalog_profile.py:244-339, :579-609, :617;data/engine.py:780-858] +- 现有的方言测试只核对守卫放行和字符串片段(ROWNUM、系统视图名),没有逐条的语法、类型或真库验证。我在 /tmp 用 agentlab 环境里的 sqlglot,按 mysql / postgres / oracle 解析了 8 类剖析 SQL,每类配 3 种列名(普通、保留字 LEVEL、混合大小写),全部能解析通过。也就是说 sqlglot 只能挡住语法回退,挡不住下面列出的语义问题 [backend/tests/test_catalog_profile_units.py:124-166;/tmp/dialect-probe/probe.py(只读复制了 SqlDialect 的源码,没有导入 app)] +- 列类型存的是 str(SQLAlchemy 类型),按通用方言编译。本机已验证:Oracle VARCHAR2(20) 存成「VARCHAR2」、NUMBER(12,2) 存成「NUMBER」,MySQL ENUM('a','bb') 存成「ENUM」,PG 的 ENUM 存成「VARCHAR(1)」。按方言编译则能拿到 VARCHAR2(20 CHAR)、NUMBER(12, 2)、ENUM('a','bb') [introspect.py:272] +- Oracle 里用小写加引号建的对象:SQLAlchemy 反射回来是 quoted_name(quote=True),存进 JSON 以后这个标记就丢了(本机验证)。剖析 SQL 于是写成不带引号的 visits,Oracle 转成大写 VISITS,报 ORA-00942;读统计信息时同样转成大写,也查不到 [introspect.py:264-281;catalog_profile.py:265-271, :303-309] +- Excel 导入现状:取数配方只走 xlsx,CSV 只能简单导入;列表块不能按期累积(accumulate_blockers 要求表只由交叉表写入);列表只认末尾一行合计,夹在中间的小计在规则起草时记为失败;扫描时已经读出表格对象和定义名称,但起草器不用(只用在公式的结构化引用上);配方有不兼容变化时 restart,此前各期丢弃;没有原件下载接口(D13 定为不提供);PARSER_VER=parsers/2,没有升级后的重放比对;没有「表格文件」节点(原方案明确不做) [data/recipe_imports.py:772-791;data/table_versions.py:86, :250;data/recipe_types.py:303-308, :1125-1180;data/recipe_suggest.py:770-815;data/xlsx_scan.py:183, :217, :606-619;data/tabular.py:1016;data/recipe_accumulate.py:296-308;data/recipe_parsers.py:21;engine/schema.py:21-49] +- 开发环境里已经有库被截断:设计文档里的库 H 是「共 205 张表,只探查了 200 张」 [docs/design/copilot-look-and-caliber-ledger.md:326] diff --git a/docs/design/next-round/04-对外集成与触达.md b/docs/design/next-round/04-对外集成与触达.md new file mode 100644 index 00000000..8c5c5ef1 --- /dev/null +++ b/docs/design/next-round/04-对外集成与触达.md @@ -0,0 +1,217 @@ +> 分块细案(2026-10-16 只读调研整理,代码基准 main d135a17)。和 [00-总方案](00-总方案.md) 冲突的地方以总方案为准;总方案「跨块统一的决定」一节列出了改动过的条目。决定编号在本文件内有效,总方案里引用时带前缀(如 评测-D4)。 + +# 对外集成与触达 + +## 缺口 +- 外部系统没有办法用一个身份调用接口:钩子发起实例后能拿到 run_id,但之后查不了实例状态、读不了视图、取不了待办、下载不了报告。现在只能靠出站 Webhook 被动收「实例完成」 —— 集成只走得了半程。外部门户、OA、BI 想拉封存的数,只能拿个人会话 cookie 去模拟,cookie 7 天过期,也说不清是谁在调 [backend/app/auth/middleware.py:115-134(只认 cookie);backend/app/process/hooks.py:250-260] +- 钩子的收件记录不出接口,验签失败的请求不留痕 —— 违背「留痕」:外部系统说「我发过了」,应用管理者在界面上查不到收到没有、为什么被拒 [backend/app/process/hooks.py:204-240;backend/app/db/models_apps.py:106] +- 开了登录,/docs、/openapi.json 仍然不用登录就能读(按代码推断,没实跑) —— 全部接口的结构对匿名的人敞开。而真正要给集成方的那份「哪些接口能用访问密钥调」的说明又不存在 [backend/app/main.py:178-183;backend/app/auth/middleware.py:131] +- 错误只有中文 detail、没有 code 的有 256 处,包括集成要用的应用、待办、工件接口(api/apps.py 13 处、api/tasks.py 3 处、api/artifacts.py 5 处) —— 外部系统只能按中文句子去认是哪种错,文案一改就悄悄失灵。api/coded.py 的说明里已经讲过这个教训 [backend/app/api/apps.py、backend/app/api/tasks.py、backend/app/api/artifacts.py] +- 个人渠道勾了「实例完成 / 实例失败」永远收不到:notify_run 传给 _channels 的 users 是空列表,个人渠道整段被跳过;而界面和接口都允许个人渠道勾这两项 —— 违背「照实说」:设置看着生效了,其实悄悄没生效 [backend/app/notify/service.py:209、122-129;backend/app/api/notify.py:64-69;frontend/src/pages/settings/NotifyTab.tsx:20、210] +- 个人触达只能填一个群机器人地址:群里人人都看得到,算不上个人;用户表里的 email 没有用上 —— 待办派给了人却找不到人,只能靠催办和升级兜底。没有邮件,也没有 IM 的应用消息 [backend/app/notify/core.py:39;backend/app/db/models_identity.py:29] +- 待办提醒是一条一条发的,没有每天的汇总 —— 一个人身上挂着多条待办时,消息又多又碎;不催又容易被晾着 [backend/app/process/tasks.py:785-806] +- 「对外地址」有两个设置:环境变量一个、设置页 notify 组里一个 —— 违背「一种说法」:只配了环境变量的部署,通知里不带链接。之后的分享链接、集成说明也都要用这个地址 [backend/app/core/config.py:65;backend/app/api/settings.py:613] +- 没有任何安全响应头,任何站点都能把 AgentLab 嵌进 iframe —— 同站的门户页面(SameSite=Lax 的 cookie 会随请求带上),以及关登录的部署,有点击劫持的风险(没实测利用)。也没有一条「允许谁嵌」的白名单可配 [backend/app/web.py:75-80;backend/app/main.py:187-194] +- 没有只读分享或嵌入视图的办法。会话 cookie 是 SameSite=Lax,跨站的 iframe 里带不上,跨站嵌入时靠登录态行不通 —— 想把一块看板放进别的门户,只能让每个看的人都开账号,并且门户和 AgentLab 必须同站 [backend/app/auth/sessions.py:97-99] +- 英文界面没有任何基础:语言写死、没有选语言的入口;一半文案集中在 terms*,另一半散在约 230 个文件里;服务端渲染视图文案,所以只翻前端翻不完使用者看到的界面 —— 只读英文的人用不了应用和待办。工作量大,而且大部分在搭建者界面上 [frontend/index.html:2;frontend/src/lib/terms.ts;backend/app/apps/resolve.py:52-62] +- 新功能缺一个不和现有说法打架的名字:「令牌」被规则禁了,「token」已经指模型用量,「快照」已经指查询快照 —— 违背「一种说法」:要先定叫法再动手,否则文案检查报错,或者让人把访问凭据和模型用量混为一谈 [scripts/check-copy.mjs:68;frontend/src/lib/terms.ts:14] + +## 做法 +### A1 服务账号与访问密钥(数据模型与签发) +- 做法:users 表加一列 kind(person | service,默认 person)。服务账号由管理员建:用户名统一 svc- 开头,没有口令、不能登录,角色固定 user,停用后它的密钥一起失效。新表 access_keys,字段:id、owner_user_id、name、key_hash(sha256,唯一)、prefix(明文前 8 位,用来认是哪把)、scopes(JSON)、app_ids(JSON,可空,表示不限应用)、expires_at(必填,最长 365 天,默认 90 天)、revoked_at、last_used_at、last_used_ip、created_by、created_at。密钥格式是 agl_ 加 32 字节随机串,只在创建和轮换时显示一次。轮换的做法:签一把新密钥,旧的在 24 小时后到期,换钥匙不用停机。个人访问密钥由本人在「设置 → 我的访问密钥」里建,这个入口要管理员开开关(auth.personal_keys,默认关) +- 复用:auth/sessions.py 的 token_hash、_TOUCH_EVERY 节流,以及「库里只存哈希」的做法;db/base.py 的 _COLUMN_MIGRATIONS(users.kind);auth/audit.record;api/identity.py 的用户管理;core/crypto 这里用不上(只存哈希) +- 文件:新增 backend/app/auth/keys.py、backend/app/api/access_keys.py;改 backend/app/db/models_identity.py、backend/app/db/base.py、backend/app/api/identity.py、backend/app/auth/directory.py(名录里标出服务账号,岗位绑定和派单选人时排除);前端新增 pages/settings/AccessKeysTab.tsx(管理员看服务账号和全部密钥,本人看自己的),改 pages/SettingsPage.tsx、lib/termsApp.ts +- 对上的理念:留痕:谁建的、谁在用、什么时候到期都能查。一种说法:服务账号就是一个用户,授权、组、名录全部沿用现有机制,不另起「API 客户端」对象 +- 否决的备选:只做个人密钥:集成依附在某个员工身上,人一离职、停用,集成就断了。只做服务账号:开发自己写脚本拉数时也得找管理员。不过期的密钥:泄露之后没有兜底。限定来源 IP:服务端认的客户端地址是 request.client.host(api/auth.py:66-67),在反向代理后面认不准(uvicorn 只信 127.0.0.1 发来的 X-Forwarded-For,没核实部署时的配置),所以这次不做,靠到期、撤销和请求记录兜底 +### A2 认人与门禁:中间件认密钥,按路由白名单放行 +- 做法:Principal 加四个有默认值的字段:via(off | session | key | hook | share,默认 session)、key_id、scopes(frozenset)、app_ids。中间件收到 Authorization: Bearer agl_… 时:开了登录就按哈希找密钥,找不到、到期、撤销、拥有者停用都回 401,并分别带 code(key_invalid / key_expired / key_revoked / key_owner_disabled),绝不退回成匿名;认出来的请求不看 cookie,也不查 CSRF(Authorization 头跨站发不出来,要过 CORS 预检,main.py:188-194 只放行登记的前端地址);WebSocket 带密钥一律拒绝。关登录时带密钥的请求回 409,code 是 keys_need_login,说明「未开启登录,访问密钥不生效」:免得调用方以为自己的权限被收窄了,其实服务端把他当成了管理员。门禁写在 auth/keys.py:KEY_ROUTES 是一张 (方法, 路由模板) → scope 的表,不在表里的一律 403(code key_route_forbidden)。在 FastAPI(dependencies=[Depends(key_gate)]) 上挂一次,在依赖里读 request.scope['route'].path,比对的是路由模板,不是正则。路径参数里的 app_id 不在密钥限定的应用里时 403;个人应用一律 403。默认限流每把密钥每分钟 120 次(进程内令牌桶),超了回 429、带 Retry-After +- 复用:api/deps.py 的 _CHAT_ROUTES 白名单模式、current_principal 和 contextvar;auth/sessions.groups_of、principal_of;中间件现有的 _reply;sessions.py 失败退避的进程内计数做法 +- 文件:改 backend/app/api/deps.py(Principal 字段)、backend/app/auth/middleware.py、backend/app/main.py(FastAPI 的 dependencies);新增 backend/app/auth/keys.py +- 对上的理念:照实说:密钥无效就明确拒绝并说明原因,不悄悄降级成匿名或管理员。只读与遮罩:默认拒绝,只放行表里的接口。界面层不放逻辑:门禁全在服务端 +- 否决的备选:在中间件里用正则匹配路径:会和路由定义重复写一遍,改路由时漏改。在每个接口里各自判断 scope:改动面大、容易漏,A7 当初否决逐个接口加鉴权也是这个理由。用 JWT 自包含的令牌:撤销要靠黑名单,而且不好做到「每次请求按拥有者当前的组和停用状态判」 +### A3 密钥的权限封顶在使用者边界 +- 做法:按密钥认出来的身份一律是 Principal(username=拥有者, role="user", groups=拥有者当前的组, verified=True, via="key"),不论拥有者是 admin 还是 builder。这样现有的使用者规则全部自动生效:只看得到授权给本人、所在组或所有人的应用,最高到 observe;restricted() 为真,工件按白名单给并做派生遮罩;报告导出和使用者看到的一样;运行事件、实时流、记录页都碰不到。界面上写明:「访问密钥按使用者的权限算:只看得到授权给你(或你所在的组、所有人)的应用,看到的数据一律遮罩」 +- 复用:apps/access.py 的 role_of/app_role;api/deps.py 的 restricted、run_events_readable;api/artifacts.py 的 _masked_for_user;auth/access.py 的 run_readable +- 文件:backend/app/auth/keys.py(构造 Principal);不改各接口 +- 对上的理念:只读与遮罩:拿着密钥看到的永远是遮过的值,和使用者在界面上看到的完全一样。一种说法:不另写一套「密钥能读什么」的规则 +- 否决的备选:继承拥有者的角色:搭建者的一把密钥就能读所有运行、拿到工件原文,一旦泄露,暴露面等于这个搭建者本人。按 scope 细分数据权限:要另写一套读权限,和应用授权两套并存 +### A4 可写操作:发起入口、刷新视图(带幂等),以及回复待办(只给个人密钥) +- 做法:scope 只有固定的六个:apps.read、apps.run、tasks.read、tasks.respond、reports.read,加上不用申请就有的 me(GET /api/auth/me 自省)。用密钥 POST /entries/{entry} 时必须带 Idempotency-Key,不带回 400,说明「同一件事重发时要带同一个键」。去重新建 api_receipts 表,按 (拥有者用户 id, 幂等键) 唯一,所以轮换密钥后同一个键照样去重。认领、完成、处理到一半服务停了之后的重新认领,这套状态机从 process/hooks.py 的 _claim/_finish 抽到 process/receipts.py,按表参数化,钩子和密钥共用。钩子的 hook_receipts.hook_id 是非空外键,SQLite 改不了可空性(同 F14),所以那张表不动。表单里有文件控件的入口,用密钥发起回 422,code key_files_unsupported,说明请在应用里发起。刷新视图沿用每个应用每天的次数上限。回复待办只给个人密钥(服务账号不能被派待办,A1),而且只限审批、补充输入、编辑稿件三种;确认写入、核实写入结果、双盲复算裁决、工具和代码审批一律 403,code key_task_kind,因为这几种要人在界面上看过确认卡再按。task.responded 事件和 run.origin 里记 via=key、key_id、密钥名 +- 复用:apps/entries.start_entry(入口只有一条发起路径);apps/resolve.run_view;process/tasks.respond;process/hooks 的 _claim/_finish 状态机(抽出来共用);engine/origin.origin_note(来源仍记 app_entry,不新增来源种类) +- 文件:新增 backend/app/process/receipts.py;改 backend/app/process/hooks.py(改用 receipts)、backend/app/apps/entries.py:121-128(origin 加 via、key)、backend/app/api/apps.py:824-840(密钥请求必须带 Idempotency-Key)、backend/app/process/tasks.py(待办种类的限制)、backend/app/db/models_apps.py(ApiReceipt) +- 对上的理念:正式运行只从已发布、未退役的版本发起:用密钥发起走的就是入口,入口只认当前发布版。留痕:在封存范围内记下是哪把密钥发起的。不挡生成:幂等保证外部系统重发时不会多发起一个 +- 否决的备选:再加一套 POST /api/integration/start 接口:和入口、钩子三条路并存,违背一种说法。发起时可以不带幂等键:外部系统超时重试一次,就会多出一个实例。允许服务账号被派待办:「等外部系统」本来就由 signal 钩子管,再开一条路是重复的 +### A5 视图数据接口 agentlab.viewdata/1 +- 做法:新增 GET /api/apps/{app_id}/views/{view_id}/data(scope apps.read,前端也可以用)。按应用当前发布版解析这个视图取的是哪一次封存运行,返回:format、app {id, version}、view {id, kind}、state、run_id、清单哈希、印章(截至、口径、出具档位、来源,就是 stamp_of 算出的那份),以及这次运行封存的所有口径卡指标,每条有卡片节点、口径卡名和版本、指标 id、名称、单位、原始值 value、渲染后的 text、state(ok / masked / missing)、证据指针 {run_id, eid}。遮罩判断和页面用的是同一个函数,遮住的指标 value 为 null、state 为 masked。按需视图用 ?params= 读这组参数缓存的那次结果,不会发起运行,没有就返回 empty 并说明要用 apps.run 刷新。查询表格不放进来,要的话按证据指针走证据接口或工件接口(都已经遮过)。只给正式运行:个人应用、试运行一律拒绝 +- 复用:apps/resolve.py 的 _source、_load、stamp_of、_metric_value、_card;api/evidence.py 的 _sealed_cards、_metric_masks、_Masks +- 文件:改 backend/app/apps/resolve.py(加一个 view_data 函数)、backend/app/api/apps.py(新路由);改 frontend/src/typesApp.ts(加类型,前端暂时不用) +- 对上的理念:叙述层无算术:数值直接取自口径卡封存的指标,不另算。数据目录只放事实:口径名和版本来自口径卡。生效可查可对账:带上清单哈希,外部系统拿去对照可验证报告。没发布的不外送 +- 否决的备选:让集成方解析页面视图模型:组件 id 和布局一改,集成就坏,而且里面只有渲染好的文字。按指标定义现查现算:那就是语义层编译 SQL,已经明确不做。把查询快照的整表也放进来:遮罩和行数上限要另判一遍,证据接口已经管着这些 +### A6 留痕:审计、请求记录、来源,以及钩子的收件记录 +- 做法:建、撤销、轮换密钥,建、停用服务账号,开关个人密钥,都写进 audit_events。密钥请求另写一张 access_key_requests 表(key_id、时间、方法、路由模板、状态码、run_id、ip),先在进程内缓冲,每 15 秒(调度循环的一拍)或攒满 200 条时批量写一次,保留 90 天,在一拍里清掉过期的;密钥上的 last_used_at 至少隔 1 分钟才写一次。钩子补一个收件记录接口 GET /api/apps/{id}/hooks/{name}/receipts(要 manage 权限),列最近 100 条:状态、时间、实例或待办、被拒的原因。验签失败只在进程内按钩子计数,界面上显示「近 1 小时验签失败 N 次」,不落库,免得被刷库。前端做一个共用的「最近的请求」组件,钩子面板和访问密钥面板都用它 +- 复用:auth/audit.record;hook_receipts 现有的字段;scheduler/loop.process_due 的步骤列表(scheduler/loop.py:130);sessions 的写节流 +- 文件:改 backend/app/db/models_identity.py(AccessKeyRequest)、backend/app/auth/keys.py、backend/app/api/apps.py(receipts 路由)、backend/app/process/hooks.py、backend/app/scheduler/loop.py;前端新增 apps/components 或 components 里的 RecentRequests.tsx,改 pages/apps/edit/AccessSections.tsx +- 对上的理念:留痕:谁、什么时候、用哪把密钥、读了哪次运行都查得到。一种说法:钩子和密钥用同一个「最近的请求」组件 +- 否决的备选:每个请求同步写一行:会和运行抢 SQLite 的写锁,sessions.py:23-24 已经为此节流过。验签失败也落库:匿名的人能拿它把库刷满 +### A7 集成接口说明、收起 /docs、补齐错误码 +- 做法:开登录时中间件把 /docs、/redoc、/openapi.json 当成 /api 一样拦:要搭建者登录,或者直接 404。接口说明从 KEY_ROUTES 这一张表生成:GET /api/integration/reference(要登录)给出方法、路径、scope、说明、可能的错误码;前端「设置 → 访问密钥」里放一页「接口说明」,README 里写一节用 curl 调的示例。KEY_ROUTES 涉及的接口的错误全部改成带 code(api/apps.py 13 处、api/tasks.py 3 处、api/artifacts.py 5 处,以及 deps 里的 _forbidden、ensure_role),detail 文字不变,老前端照常显示 +- 复用:api/coded.CodedHTTPException;中间件的 _PUBLIC 判断;KEY_ROUTES 一张表同时管门禁和说明 +- 文件:改 backend/app/auth/middleware.py、backend/app/api/apps.py、backend/app/api/tasks.py、backend/app/api/artifacts.py、backend/app/api/deps.py、README.md;新增 backend/app/api/integration.py(或者并进 access_keys.py) +- 对上的理念:照实说:说明和真实放行的接口出自同一张表,不会对不上。一种说法:门禁和文档只有一个来源 +- 否决的备选:保留 FastAPI 的全量 OpenAPI 当集成文档:会把只给搭建者的内部接口也暴露出去,集成方也分不清哪些接口能用密钥调。另写一份手工文档:迟早和代码对不上 +### B1 修复:个人渠道的「实例完成 / 失败」 +- 做法:notify_run 在运行的发起人是认证过的身份时(run.origin.actor_verified),把 users 传成 [run.started_by],个人渠道就按「我发起的实例」推送。termsApp 里个人渠道的事件名改成「我发起的实例完成 / 失败」。补一条测试:个人渠道订阅了 run.finished,发起人确实收到 +- 复用:notify/service._channels 现有的 users 分支 +- 文件:backend/app/notify/service.py:203-223;frontend/src/lib/termsApp.ts;backend/tests 里的通知测试 +- 对上的理念:照实说:设置了就要生效 +- 否决的备选:在界面上把这两项藏起来:设置能保存却不生效,这个问题仍然在,而且「我发起的实例结束了告诉我」本来就是个合理的需求 +### B2 邮件渠道(第五种格式) +- 做法:设置里新增一组 mail(只有管理员能改):enabled、host、port、security(starttls / ssl / none)、username、password(用 core/crypto 加密)、from_addr、from_name,再加一个「发测试邮件」(发到管理员自己的账号邮箱)。notify/core 的 FORMATS 加上 email,界面上叫「邮件」。新增 notify/mail.py:拼 MIME(纯文本和简单的 HTML 各一份,HTML 做转义),在 asyncio.to_thread 里用标准库 smtplib 发,超时 10 秒;SMTP 的 4xx 临时错误和连不上按 1/2/4 秒重试,5xx 不重试;报错里不出现口令;测试时可以替换成假的 SMTP 工厂(同 core._transport 的做法)。渠道表不改结构:个人邮件渠道不填地址,url 存一个占位(密文),发送时取拥有者在名录里的账号邮箱;没有邮箱就记一条失败的投递,原因写「你的账号没有邮箱地址,请联系管理员在「设置 → 身份」里填写」。全局和应用的邮件渠道(搭建者以上能建)填固定的收件地址,最多 20 个。投递记录还是 notify_deliveries,界面上成功写「已交给邮件服务器」(是否进收件箱以邮件服务器为准,退信不追踪,照实写出来)。邮件内容沿用 Message:只有标题、状态、链接,不放数字(P4);带 List-Unsubscribe 头,指向「设置 → 通知」。管理员开关 mail.default_for_assignees(默认关):开了以后,派到待办的人如果还没有个人邮件渠道,第一次推送时自动给他建一个,默认订阅 task.opened、action.unknown、review.done,本人之后可以改或关。这一项改写 Q6,要在设计文档里写明改写的理由:SMTP 只是可选配置,退信照实说不追踪 +- 复用:notify/core 的 Message、text_of、Outcome、RETRY_DELAYS 和重试骨架;notify/__init__.send 的分发;notify_deliveries;api/settings 的设置分组和校验(notify_problem 的写法);core/crypto;名录里的 users.email +- 文件:新增 backend/app/notify/mail.py;改 backend/app/notify/core.py、backend/app/notify/__init__.py、backend/app/notify/service.py、backend/app/api/notify.py(校验邮件地址、个人邮件渠道不收地址)、backend/app/api/settings.py(mail 组和测试接口);前端改 pages/settings/NotifyTab.tsx(格式选邮件,个人渠道显示「发到账号邮箱 x@…」)、SettingsPage 新增一节「邮件服务」、lib/termsApp.ts +- 对上的理念:一种说法:邮件只是渠道的一种格式,订阅、投递记录、测试都走同一套。照实说:只说已交给邮件服务器,不说已送达。只读与遮罩:通知里不放数 +- 否决的备选:个人渠道可以填任意邮箱:会被拿来当垃圾邮件中继,而且发到的地址不是认证过的身份。另建一张邮件表:违背一种说法。引入 aiosmtplib:多一个依赖,标准库加 to_thread 就够了。IM 应用消息(企业微信应用消息、飞书应用机器人、钉钉工作通知):每个平台都要企业应用的凭据、令牌刷新、AgentLab 用户到 IM 用户 id 的映射,还要过 IM 那边的应用审批。这些和 Q5 的 IM 登录适配器是同一套企业应用凭据和用户映射,应该放到一起做,这次不做。群机器人 @人:同样要 IM 的用户 id 或手机号(个人信息),一并推迟 +### B3 待办汇总和密钥到期提醒 +- 做法:可订阅的事件加两个:task.digest(待办汇总)和 key.expiring(访问密钥快到期)。在调度循环里加一步:每天到 notify.digest_at(默认 09:00,按服务端时区),给订阅了 task.digest 的个人渠道发一条,内容是还没处理的待办条数、最早的时限、最多 5 条待办(标题用节点在图上的名字,不用按运行数据渲染出来的那句)和去待办箱的链接;按日期在 settings 表里记一行认领,重启了也不会重发。key.expiring:到期前 7 天和 1 天各推一次。个人密钥推给本人的个人渠道;服务账号的密钥推给订阅了这个事件的全局渠道。同时记一条审计 +- 复用:scheduler/loop.process_due 的步骤列表;notify/service._push 和 _channels;process/tasks.list_tasks;Setting 表 +- 文件:改 backend/app/notify/service.py(EVENTS、两个 notify_*)、backend/app/scheduler/loop.py、backend/app/api/settings.py(digest_at);前端改 lib/termsApp.ts +- 对上的理念:不挡生成:汇总发不出去只记投递失败,不影响待办本身。留痕:每次推送都有投递记录 +- 否决的备选:把单条提醒全部换成汇总:会改掉人工节点上已经配好的 on_timeout 语义(属于图里的流程语义)。在浏览器端提醒:人不在页面上就收不到,lib/notify.ts 已经覆盖了在页面上的情形 +### B4 统一「对外访问地址」 +- 做法:新增 core/public_url.py,提供 public_base_url():环境变量 AGENTLAB_PUBLIC_URL 优先,没有再读设置里的那一项(沿用 notify.public_base_url 这个键,界面上叫「对外访问地址」,挪到「设置 → 通用」里)。值缓存在进程里,改设置时刷新,和登录设置的缓存一样,因为中间件里不查库。通知链接、OIDC 回调、Origin 核对、分享链接、接口说明全部改用它。环境变量设了的时候,设置页上显示「由环境变量指定」,不能改,和 AUTH_MODE 的做法一致 +- 复用:auth/__init__.py 的 env 优先加进程内缓存模式(_stored、remember_settings);api/settings 的 _base_url_problem 校验 +- 文件:新增 backend/app/core/public_url.py;改 backend/app/notify/service.py:93-96、backend/app/scheduler/webhook.py、backend/app/auth/middleware.py:52-61、backend/app/api/auth.py:115-125、backend/app/api/settings.py;前端改 SettingsPage +- 对上的理念:一种说法:同一件事只有一个设置 +- 否决的备选:维持两个设置、只在文档里说明:只配了一处的部署还会继续丢链接 +### C1 安全响应头 +- 做法:新增 web_security.py,做成纯 ASGI 中间件(同 AuthMiddleware,WebSocket 不经过它),在 http.response.start 上加头:X-Content-Type-Options: nosniff;Referrer-Policy: strict-origin-when-cross-origin(分享页 /s 用 no-referrer);Content-Security-Policy: frame-ancestors,默认 'self',可用 AGENTLAB_FRAME_ANCESTORS 列出允许嵌整站的同站门户,分享页另用 AGENTLAB_EMBED_ORIGINS;只有 frame-ancestors 是 'self' 时才另加 X-Frame-Options: SAMEORIGIN(XFO 写不了白名单,有白名单时以 CSP 为准)。script-src 先只发 Content-Security-Policy-Report-Only:mount_web 启动时给 index.html 里的内联主题脚本算 sha256 写进去,观察一个版本没有误报,再改成强制执行。HSTS 交给反向代理,不在这里加。导出的 HTML 文件已经自带 meta CSP,保持不动 +- 复用:main.py 的中间件挂法;web.py 的 mount_web(就在这里算内联脚本的哈希);core/config 的列表型环境变量(cors_origins 的写法) +- 文件:新增 backend/app/web_security.py;改 backend/app/main.py、backend/app/web.py、backend/app/core/config.py、deploy/agentlab.env(写明这两个变量)、README.md 的安全一节 +- 对上的理念:只读与遮罩:页面不能被别的站点套着诱导点击。照实说:严格的 script-src 先只上报不拦,确认没有误报再强制执行,不一刀切把页面弄坏 +- 否决的备选:一上来就强制严格 CSP:内联的主题脚本会被拦,首帧闪一下深色,Vite 构建会不会有别的内联产物也没核实。只用 X-Frame-Options:写不了白名单,门户嵌入的需求无法满足 +### C2 分享链接(只读、可嵌入) +- 做法:新表 view_shares,字段:id、key_hash、app_id、page_id、mode(latest:始终是最近一次封存;fixed:发链接那一刻每个视图取的运行 id,存一份 map)、embed_allowed、created_by、created_at、expires_at(必填,最长 90 天)、revoked_at、open_count、last_opened_at。接口:POST/GET/DELETE /api/apps/{id}/shares,要 manage 权限,而且管理员开了 apps.share_links(默认关)才能用。读的接口 GET /api/shares/page 是公开路由,只认请求头 X-AgentLab-Share;链接写成 /s#<分享 key>,key 放在 URL 的 # 后面,打开页面时不会发给服务器、不进访问日志,SPA 读出来再放进请求头。服务端用分享身份 Principal(username=None, role="user", via="share") 调 resolve_page,并加一个 share 旁路:只解析 latest_sealed 视图(fixed 模式用存下的运行 id);按需视图、实例、待办、发起按钮、记录列表这些流程组件,换成写明原因的占位,比如「分享链接只显示定时出数的视图」;从不发起运行;个人应用、试运行不能分享(本来就是 personal.not_shareable)。出处不走在线的证据接口,分享页上给「下载报告」:GET /api/shares/report.html?run=…,只认这次分享解析出来的那几次运行,内部复用 export_report_html,和使用者看到的一样遮过;报告文件自己能离线点开出处。前端加一个不进外壳的路由 /s(同 App.tsx:134 登录页的写法),用 ViewRenderer 渲染,所有操作禁用,印章一直显示,页脚写「只读 · 由 X 于 … 分享 · … 到期」。建分享、撤销分享写审计;打开次数只做计数。撤销或到期后打开,写明「链接已撤销 / 已到期」,回 410 +- 复用:apps/resolve.resolve_page 和页面缓存(缓存键另加 share);api/evidence.export_report_html;前端 apps/ViewRenderer、Stamp、context;auth/audit;Principal 的 via 字段(A2);public_base_url(B4) +- 文件:改 backend/app/db/models_apps.py(ViewShare)、backend/app/apps/resolve.py(share 旁路);新增 backend/app/api/shares.py;改 backend/app/auth/middleware.py(公开前缀 /api/shares/)、backend/app/main.py、backend/app/web_security.py(/s 用嵌入白名单);前端新增 pages/apps/SharePage.tsx,改 App.tsx、pages/apps/edit/AccessSections.tsx(管理分享)、lib/termsApp.ts +- 对上的理念:只读与遮罩:分享出去的就是使用者看到的遮罩视图。生效可查可对账:印章常显,fixed 模式钉在某次封存运行上,下载的报告带清单哈希。没发布的不外送。一种说法:前端和看应用用的是同一个 ViewRenderer,不另写服务端渲染器;界面上叫「分享链接」,不叫「快照链接」,因为「快照」已经指查询快照 +- 否决的备选:服务端把视图渲染成一份静态 HTML:等于第二个渲染器,组件一多就和前端对不上。分享页里打开在线证据:等于给匿名的人一个按运行读证据的身份,权限面变大;报告导出已经把出处离线带上了。把 key 放在路径或查询参数里:会进代理日志和 Referer。签发嵌入票据、用 SameSite=None 的 cookie 嵌整站:要重新评估 CSRF,Safari 和 Firefox 默认拦第三方 cookie,Chrome 也在收紧,跨站时不可靠 +### C3 同站门户嵌整站(只做配置和文档) +- 做法:门户和 AgentLab 在同一个可注册域名下时(比如 portal.corp.x 和 agentlab.corp.x),把门户的源写进 AGENTLAB_FRAME_ANCESTORS,现有的 Lax 会话 cookie 会随请求带上,单点登录照常。跨站嵌整站明确不支持:README 写清原因,并指向分享链接 +- 复用:C1 的头;现有会话 +- 文件:README.md、deploy/agentlab.env +- 对上的理念:照实说:做得到的范围写清楚,不承诺做不到的 +- 否决的备选:为了跨站嵌入把 cookie 改成 SameSite=None:会削弱整站防 CSRF 的那一层,换来的嵌入在 Safari 和 Firefox 上默认仍然不工作 +### D1 英文界面:分层方案和工作量 +- 做法:L0 基础设施(不翻译):前端加 lib/i18n.ts(locale 存在本机加用户偏好里,切换语言就重新加载页面),把 terms*.ts 改成每种语言一份、形状相同(en 用 satisfies typeof zh 让 TypeScript 逐键核对), 和系统通知的 lang 改成按语言设,format.ts 和日期用 Intl 按语言格式化;服务端在 users 表加 locale 列,按「用户偏好 → Accept-Language → 部署默认(zh-CN)」认出语言,coded 错误加一个 say(code, **参数) 的消息目录(plain.py 本来就是按编号查的结构,正好复用),视图页面的缓存键加上语言;check-copy 的中文规则只对 zh 的文件生效,en 的文件另加一条「不许出现中文」。L1 使用者面:应用、待办、登录、我的设置和通知、错误框架。前端 43 个文件约 1.0k 段,加上 terms.ts 里使用者面用到的约 300 段(估计)。服务端约 470 处,其中 apps/resolve.py 约 115 处视图文案,因为 P7 必须在服务端按语言出,前端没法替它翻。通知按收件人的语言出。L2 搭建者面:画布、记录、数据目录、证据、其余设置,前端约 10k 段,服务端约 8k 处(plain.py 404 条、engine/labels.py 205 条、发布门禁、自动修复、目录、配方……)。一律不翻(属于数据):封存运行事件里的整句、报告正文和模型的产出、数据目录的描述和剖析说明、证据说明、审计数据、口径卡名和公式、应用和工作流名、写给模型的提示词(提示词用什么语言是工作流和版本的事,改了会影响模型行为)。界面上对这些标「原文」,不假装已经翻译。测试:zh 仍是默认语言,现有约 10.3k 行检查脚本断言和约 6.6k 行后端中文断言不用动;en 只加覆盖检查和冒烟检查 +- 复用:terms*.ts 的集中结构;plain.py 编号查白话的结构;api/coded 的 code;check-copy 的扫描框架 +- 文件:L0:新增 frontend/src/lib/i18n.ts,改 lib/terms*.ts、lib/format.ts、index.html、backend/app/core(locale 解析)、backend/app/api/coded.py、db/models_identity.py、apps/resolve.py(缓存键)、scripts/check-copy.mjs;L1、L2 见工作量估计 +- 对上的理念:一种说法:语言只是 terms 多一个维度,叫法仍然只有一处。照实说:没翻译的数据标「原文」。界面层不放逻辑:视图文案仍由服务端出,只是按语言出 +- 否决的备选:只在前端翻译:使用者面上服务端渲染的印章和状态翻不到,等于翻了一半,还说翻完了。上 i18next 这类库再全量抽取:引入依赖、改动面极大,而 terms 已经是集中管理的结构。把运行事件和报告也翻译:它们在封存范围内,翻译等于改写记录。全站一次翻完:约 1.15 万段前端文案加约 8.9k 处服务端字符串,目前没有对应的使用者群体 + +## 待拍板(本块) +- I1 接口访问凭据在界面上叫什么?「令牌」被文案规则禁了,「token」已经指模型用量,同一个词会指两件事|选项:访问密钥(和钩子密钥一样「只显示一次」,叫法成体系) / API token(要在 check-copy 里放一条例外,界面上 token 一词会有两个意思) / 接口凭据(和出站的「服务凭据」容易混)|推荐:访问密钥 | 守住一种说法:「token」专指模型用量,「凭据」专指出站端点的凭据。「密钥」本来就是交给外部系统、只显示一次的东西,和钩子密钥是同一类 +- I2 谁可以持有访问密钥?|选项:服务账号(管理员建)+ 个人访问密钥(管理员开关,默认关) / 只做服务账号 / 只做个人访问密钥|推荐:服务账号 + 个人访问密钥(管理员开关,默认关) | 系统间集成不应依附在某个员工身上(离职、停用就断);个人自动化有实际需求,但暴露面更大,所以默认关、由管理员决定 +- I3 访问密钥能做哪些写操作?(不论哪种,密钥都封顶在使用者的权限和遮罩边界)|选项:只读:读视图数据、实例、待办、报告 / 读 + 发起入口、刷新视图(必须带幂等键) / 读 + 发起和刷新 + 个人密钥回复待办(只限审批、补充输入、编辑稿件;确认写入、核实、双盲裁决、工具和代码审批不行)|推荐:第三项,但新建密钥时默认只勾读的 scope | 门户、OA 嵌入审批是常见需求;确认写入这几种必须有人在界面上看过确认卡。服务账号不能被派待办,外部系统回调仍走 signal 钩子,保持一种说法 +- I4 通知渠道要不要扩?(这会改写 Q6「只用 Webhook」)|选项:加邮件(SMTP,第五种格式),IM 应用消息推迟到和 Q5 的 IM 登录适配器一起做 / 邮件 + IM 应用消息(企业微信、飞书、钉钉)这次都做 / 维持只用 Webhook,只修个人渠道不生效的问题|推荐:加邮件;IM 应用消息推迟 | 用户表里已经有 email(单点登录会自动填),邮件是真正能触达个人的最低成本办法;SMTP 是可选配置,投递照实写「已交给邮件服务器」。IM 应用消息要企业应用凭据、令牌刷新和用户 id 映射,这些和 IM 登录是同一套,分开做会做两遍 +- I5 邮件要不要默认发给待办的处理人?|选项:管理员开关,默认关;开了之后第一次派待办时自动给处理人建个人邮件渠道,本人可以关 / 只能由个人自己在「我的通知」里打开 / 配好邮件服务就一律默认发|推荐:管理员开关,默认关 | 「派了待办找不到人」是要触达的核心场景,只靠个人自己开,覆盖率会很低;一律默认发又会让刚配好 SMTP 的部署突然往外发邮件,不合「照实说、不意外」 +- I6 视图要不要支持分享链接(拿到链接、不用登录就能看,可以嵌进门户)?|选项:做:manage 权限的人建,管理员开关默认关,必须设到期(最长 90 天),只显示定时出数的视图,出处给「下载报告」 / 不做,只上安全头,门户嵌入只支持同站嵌整站 / 做,而且分享页里也能打开在线证据|推荐:做(第一项) | 嵌入的需求只有分享链接能跨站做到;拿到链接的人看到的就是使用者看到的遮罩视图,印章常显、可以撤销、建分享写审计。打开在线证据等于给匿名的人一个读运行的身份,下载的报告已经把出处带上了 +- I7 英文界面这轮做到哪一层?|选项:这轮不做,只把集成接口的错误补上 code(A7),下一轮视是否有明确的英文使用者再做 / 做 L0(基础设施)+ L1(使用者面:应用、待办、登录、我的设置;服务端按用户语言出视图文案和通知) / 全量(L0–L2)|推荐:这轮不做,只补错误码;如果已经有明确的英文使用者,做 L0 + L1 | L1 是使用者真正碰得到的部分,约 1.3k 段前端文案加约 470 处服务端字符串,因为 P7 必须动服务端;L2 是搭建者面,约 1 万段,8 到 12 周的量级。不做的代价只是只读英文的人用不了,集成方可以靠 code 分辨错误,不受影响 + +## 风险 +- 访问密钥是不记名凭证,一旦泄露就能被冒用:库里只存哈希,带 agl_ 前缀方便密钥扫描工具认出来,必须设到期,可以撤销,拥有者停用就失效,权限封顶在使用者的遮罩边界,有请求记录。不限定来源 IP(反向代理后面认不准客户端地址),这一点要在文档里写明 +- 不查 CSRF 的条件只能是「请求用 Authorization 头认身份」:同一个请求带了 cookie 也一律不看 cookie。要有测试守住:带 cookie、不带 X-AgentLab-Request 的请求仍然被拒 +- Principal 是全站共用的 frozen dataclass,加字段必须都带默认值;hooks._caller、tasks.principal_with_actor 这些用 dataclasses.replace 或直接构造的地方要逐个核对 +- SQLite 单写锁:last_used_at 的节流写、请求记录的批量写、api_receipts 都会和运行抢锁。按 sessions 的节流写法做,批量间隔是调度循环的一拍。限流和请求缓冲都在进程内,依赖「单进程」这个前提,和项目现状一致 +- 加安全头可能打断已经在用 iframe 嵌 AgentLab 的部署(有没有这样的部署没核实):发版说明要写清楚,并给出 AGENTLAB_FRAME_ANCESTORS 的配法 +- 严格的 script-src 可能误伤:index.html 里的内联主题脚本已确认存在,构建还会不会产出别的内联内容没核实。所以先只上报、不拦截 +- 分享链接被转发,等于数据出了授权范围:这正是分享的本意,所以靠管理员开关、必须设到期、可撤销、建分享写审计、打开计数、页脚写明是谁分享的来控制。key 放在 # 后面,减少进日志和 Referer 的机会 +- 邮件:SMTP 口令加密存放;个人邮件只发到名录里的账号邮箱,避免被当中继;是否送达无法确认,界面上照实写「已交给邮件服务器」;内网 SMTP 由管理员配置,不做 SSRF 检查(和全局 Webhook 同一个信任级别) +- 改写 Q6 是在推翻已拍板的决定:设计文档要写明理由(SMTP 只是可选配置,退信照实说不追踪),并在 app-platform-and-look.md 的待拍板表里标「已修订」 +- 英文界面只做一半时,最容易出的问题是「悄悄降级」:没翻译的数据必须标「原文」;而且 en 一旦上线,所有新文案都要两种语言一起写,以后每个功能都多一份维护 +- 数量是用 /tmp 下的启发式脚本数的(去注释、按字面量和 AST 分),误差约 ±10%;按人日估算的部分没有实际翻译样本校准过 + +## 测试 +- 密钥签发:只显示一次,库里只有哈希和前 8 位;到期、撤销、拥有者停用、拥有者被删,各自回 401 和对应的 code;轮换后旧密钥 24 小时内仍可用,过了就失效;关登录时带密钥回 409(keys_need_login) +- 门禁的合约测试:遍历 app.routes,每个不在 KEY_ROUTES 里的 /api 路由,用密钥请求都回 403(key_route_forbidden);表里的路由缺 scope 时回 403;限定了应用时,别的应用回 403;个人应用、试运行一律拒绝;带密钥连 WebSocket 被拒 +- CSRF 和身份来源:带 Bearer 的 POST 不需要 X-AgentLab-Request 也能过;带 cookie、不带来源标记的 POST 仍然 403;同时带 cookie 和 Bearer 时按密钥认,cookie 不起作用;X-Actor 被忽略 +- 封顶:管理员的个人密钥取 /api/artifacts 时,查询快照是遮过的,node_output 回 403;没有授权的应用在列表里看不到;有 observe 授权的服务账号能列全部实例、能下载 report.html,报告里遮罩列写「已遮罩」 +- 幂等:同一个 Idempotency-Key 发起两次只得到一个实例,第二次交回同一个 run_id;轮换密钥后同一个键照样去重;处理到一半杀进程,重启后同一个键能认领回已发起的实例;不带键回 400;有文件控件的入口回 422(key_files_unsupported);抽出 receipts 之后,钩子现有的测试全部原样通过 +- 留痕:run.origin 里有 via=key、key_id,而且在清单哈希的封存范围内;task.responded 记了 via;建、撤销、轮换密钥都有审计;请求记录在批量写之后能查到,超过保留期的被清掉;钩子收件记录接口只有 manage 权限能读 +- 待办回复:个人密钥能回复审批、补充输入、编辑稿件;确认写入、核实写入结果、双盲裁决回 403(key_task_kind) +- 视图数据:latest_sealed 视图返回印章、清单哈希、指标原始值;遮罩列算出来的指标 value 为 null、state 为 masked;按需视图没有缓存时返回 empty,而且没有发起运行;改了页面布局之后接口返回的形状不变 +- /docs、/openapi.json:开登录时匿名访问被拒,关登录时照旧 200(不打破 test_provenance_contract.py:376 的断言) +- 通知:个人渠道订阅了 run.finished,发起人收到(B1 的回归测试);用假 SMTP 工厂测邮件:发到名录里的账号邮箱,没有邮箱时记一条失败投递和原因,4xx 重试、5xx 不重试,报错里没有口令;全局邮件渠道的地址个数和格式校验;管理员开关开了之后,第一次派待办自动建个人邮件渠道;待办汇总同一天只发一次(重启后也不重发);key.expiring 在到期前 7 天和 1 天各推一次 +- 安全头:SPA 页面、/api 响应都带 nosniff 和 frame-ancestors 'self',白名单配置能生效,有白名单时不发 XFO,/s 用嵌入白名单和 no-referrer;导出文件的响应不受影响 +- 分享链接:没开开关、没有 manage 权限时建不了;到期时间必填,超过 90 天被拒;公开接口只认请求头里的 key;只解析定时出数的视图,流程组件换成占位,按需视图不发起运行;fixed 模式钉在建分享时的那几次运行上;report.html 只认这次分享解析出的运行;撤销或到期后回 410;写了审计,打开次数在增加 +- 前端检查:扩充 check-capabilities(服务账号、访问密钥只显示一次、撤销、最近的请求、邮件服务一节、个人邮件渠道显示账号邮箱);新增 check-share.mjs(/s 页不进外壳、印章常显、页脚、没有待办、撤销后的说明);check-copy 全部通过(没有「令牌」、没有「快照链接」);新脚本不能叫 check-tokens(这个名字已被设计令牌的检查占用) +- 如果做 L0/L1:terms 的 en 和 zh 逐键对齐(类型检查);en 的文件里没有中文;使用者面在 en 下冒烟检查;zh 下 check-all 全部仍然通过 + +## 验收 +- 真实后端开 local 登录,用虚构的店铺库建一个服务账号,授权某个已发布应用 observe,签一把 apps.read + apps.run + reports.read 的访问密钥,用 curl 依次走通:GET /api/auth/me 看到 scope 和到期时间;列应用只看到被授权的那个;GET 视图数据拿到带印章和清单哈希的指标,遮罩列算出的指标是 masked;用同一个 Idempotency-Key POST 入口两次,得到同一个 run_id;轮询实例直到结束;下载 report.html,里面的遮罩值写「已遮罩」;调 /api/runs、/api/workflows 回 403;撤销后回 401(key_revoked)。审计、请求记录、run.origin(via=key)都查得到 +- 开了登录,匿名打开 /docs、/openapi.json 被拒;关登录的部署行为不变 +- 邮件:本地起一个假 SMTP 收件(检查脚本自带,不连真实邮件服务),给 alice 派一条待办,alice 的账号邮箱收到,投递记录写「已交给邮件服务器」;把 alice 的邮箱清空后再派一条,投递记录失败并写明原因;alice 的个人渠道订阅「我发起的实例完成」,自己发起的实例跑完,她收到通知 +- 分享:管理员打开开关,manage 权限的人建一条 latest 分享;无痕窗口打开 /s#… 看到页面、印章、遮罩值,没有待办、没有发起按钮,能下载报告;从另一个源的测试页 iframe 它,源在白名单里能显示,不在白名单里被拒;主站页面被别的源 iframe 时被拒;撤销后打开写明「链接已撤销」 +- 全量回归:后端测试全部通过;check-all 全过(按 agentlab-verify-layering 的分层,完整关口由编排者跑);check-auth 另起开登录的服务单独跑并通过;check-copy 通过 +- 如果做了 L0/L1:切到 en,使用者面除了标「原文」的数据之外看不到中文,视图印章、状态、通知都是英文;切回 zh,所有既有检查全部通过 + +## 工作量 +建议拆成 3 个包,英文界面另算。每个包一个完整的代理,最后统一复核,避免拆得太细。 +- 包 A:访问密钥与服务账号,含门禁、幂等、视图数据、留痕、接口说明、收起 /docs、补错误码。后端约 1,400 行,测试约 900 行;前端约 800 行(设置里的服务账号和访问密钥、接口说明页、「最近的请求」组件)。 +- 包 B:触达,含修个人渠道、邮件、待办汇总、密钥到期提醒、统一对外地址。后端约 800 行,测试约 600 行;前端约 450 行。 +- 包 C:嵌入,含安全头和分享链接。后端约 650 行,测试约 450 行;前端约 450 行(/s 页面和分享管理)。 + +包之间的依赖: +- A 先做。Principal.via 字段、KEY_ROUTES 的门禁模式、process/receipts.py 都是后面要复用的。 +- B4(统一对外地址)最好最先合入,或者并进 A:分享链接和接口说明都要用这个地址。 +- B 里的密钥到期提醒依赖 A;B 其余部分和 A 互相独立,可以并行。 +- C1(安全头)没有依赖,可以第一个合入。C2 依赖 A 的 Principal 扩展和 B4。 + +英文界面(包 D,看拍板): +- L0 基础设施:前端约 400 行,后端约 250 行。 +- L1 使用者面:前端约 1.3k 段文案要搬进 terms 并翻译(约 2,000 行改动),后端约 470 处字符串进消息目录,其中 resolve.py 约 115 处、通知文案约 70 处。约 2 到 3 周。 +- L2 搭建者面:前端约 1 万段,后端约 8k 处,8 到 12 周的量级,不建议这轮做。 + +## 和其他几块的接口 +- 和「已知残留」那块的重叠:没有安全头、个人渠道订阅实例完成不生效、/openapi.json 匿名可读、两个对外地址,这几项也可能被残留调研列出来,要去重,归到一处;残留里的「使用者表单选人没有名录」会碰 auth/directory.py,A1 要在名录里标出并排除服务账号,改的是同一个文件 +- 和口径账本那块:视图数据接口(A5)对外交出口径卡名和版本,如果账本这一轮给口径定了新的标识(账本 id、口径版本号),A5 的字段要用同一套叫法;以后要开放「问数据接已治理口径」的接口,也走同一张 KEY_ROUTES 表,不另开入口 +- 和评测那块:如果评测要从外部批量发起运行或读结果,应当用服务账号加访问密钥,不再各写一套绕过认证的捷径 +- 几个包都会改的共享文件:backend/app/api/deps.py(Principal)、backend/app/auth/middleware.py、backend/app/main.py(路由清单和 FastAPI 依赖)、backend/app/db/base.py(_COLUMN_MIGRATIONS)、backend/app/db/models_identity.py、db/models_apps.py、backend/app/api/settings.py(设置分组)、backend/app/scheduler/loop.py(每一拍的步骤)、frontend/src/lib/terms.ts 和 termsApp.ts、frontend/src/App.tsx(路由)、pages/SettingsPage.tsx(分页)、scripts/check-all.mjs、scripts/copy-allow.json、README.md(身份与权限、通知、安全几节)。实施时这些文件要排先后,或者冻结后串行改 +- 设计文档:改写 docs/design/app-platform-and-look.md 第九部分的 Q6,§3.12 权限矩阵加上「访问密钥」「分享链接」两行,§3.10 接口表加上新接口;新的设计文档用 md 写在 docs/design/ 下,按约定不提交,真实名称匿名 +- 英文界面一旦做,后面每个包新加的文案都要两种语言一起写,check-copy 的规则也要分语言:最好在 A/B/C 开工之前就定下来做不做 + +## 附:现状(代码位置) + +- 对外唯一的入站通道是按应用声明的签名钩子:POST /api/hooks/{hook_id},HMAC-SHA256(ts.body)、5 分钟时间窗、Idempotency-Key 必填、一个签名只认一个幂等键;start 按入口发起实例,signal 按 correlation 解决「等外部系统」的待办;做什么以当前发布版的声明为准 [backend/app/process/hooks.py:85-151、180-230;backend/app/api/hooks.py:19-31] +- 钩子调用方的身份是 Principal(username=None, role="admin", display_name="外部系统(钩子名)", verified=True),实例的发起人一栏为空 [backend/app/process/hooks.py:242-247] +- 钩子的收件记录 hook_receipts(processing/accepted/rejected/signature)只在库里,没有任何接口或界面能看;验签失败的请求不留任何记录 [backend/app/db/models_apps.py:106-120;全仓 grep HookReceipt 只出现在 process/hooks.py 和模型里] +- auth/signing.py 管的是出站方向:替运行发起人签发 5 分钟的 EdDSA 身份断言(X-AgentLab-Assertion),外部系统用 /api/auth/jwks 核验。和入站认证无关 [backend/app/auth/signing.py:1-78] +- 认人只认会话 cookie。中间件开登录时只拦 /api/ 下的路径:没登录 401;非 GET 必须带 X-AgentLab-Request 头、Origin 必须是本站或登记的地址;/api/hooks/ 两样都豁免。不认 Authorization 头 [backend/app/auth/middleware.py:26-30、113-140] +- 关登录(off)时中间件写入的身份等同 admin,署名取自报的 X-Actor;所有路由级依赖都放行 [backend/app/auth/middleware.py:100-111;backend/app/api/deps.py:108-114] +- Principal 是 frozen dataclass,字段只有 username/role/display_name/user_id/groups/verified/mode,没有「经什么方式认出来的」这一项 [backend/app/api/deps.py:38-56] +- 权限挂在 main.py 的 include_router(dependencies=…):应用、待办、通知、工件是 _USER,证据走 evidence_scope(使用者只能 GET 自己能读的运行),可验证报告 bundle.html 只给搭建者,钩子不挂依赖 [backend/app/main.py:197-258] +- 按请求内容收窄已经有现成的模式:问数据开给使用者时,只放 _CHAT_ROUTES 里列出的 (方法, 路径) [backend/app/api/deps.py:319-336] +- 使用者角色的遮罩边界:restricted() 为真时工件只给白名单里的种类,并按派生遮罩处理;视图对所有人都遮(遮罩值不出服务端);运行事件和实时流对使用者只开放在工具边界遮过的问数据运行 [backend/app/api/deps.py:191-205、296-299;backend/app/api/artifacts.py:16-35;backend/app/apps/resolve.py:1-4] +- 应用级权限:admin 对所有应用都是 manage;builder 自动拿到 observe;user 按授权(本人/组/所有人)取最高的一档,最高到 observe;个人应用只有拥有者能用 [backend/app/apps/access.py:60-83] +- 外部系统可能想调的接口都已经有了:页面视图模型 GET /api/apps/{id}/pages/{page}(agentlab.view/1)、发起 POST /entries/{entry}、实例 GET /instances[/{run_id}]、按需刷新 POST /views/{view}/refresh、待办 /api/tasks、报告导出 GET /api/runs/{id}/report.html(经 evidence_scope) [backend/app/api/apps.py:801-916;backend/app/api/tasks.py:45-108;backend/app/api/evidence.py:2787] +- 视图模型里的指标只给渲染好的文字 text,加上证据指针,不给原始数值;数值要再调一次证据接口才拿得到 [backend/app/apps/resolve.py:933-941] +- 运行来源已经有 api 这一种(客户端直接调接口);应用入口发起的运行来源是 app_entry,来源里记 entry、spec_hash、actor_verified、auth_mode,这些都在封存范围内 [backend/app/engine/origin.py:15-31;backend/app/apps/entries.py:121-128] +- 会话 cookie 是 HttpOnly、SameSite=Lax、Path=/,Secure 按请求协议或 AGENTLAB_AUTH_COOKIE_SECURE 定;库里只存 sha256;last_seen_at 至少隔 1 分钟才写一次,免得和运行抢 SQLite 的写锁 [backend/app/auth/sessions.py:22-28、75-77、97-103] +- 服务端不设任何安全响应头:没有 X-Frame-Options,没有 CSP(含 frame-ancestors),没有 Referrer-Policy,页面托管只设了 Cache-Control。nosniff 只在文件下载上设了;导出的报告和可验证报告靠文件里的 meta CSP [backend/app/web.py:75-80;backend/app/main.py:178-194;backend/app/api/files.py:95;backend/app/bundle/template.html:6;backend/app/report_export/template.html:6] +- 构建出来的 index.html 带一段内联脚本(首帧之前定主题)和一段内联样式:要上严格的 script-src,得给它算哈希 [frontend/index.html:9-18(frontend/dist/index.html 同样有)] +- FastAPI 没关默认的 /docs、/openapi.json。中间件只拦 /api/ 开头的路径,所以开了登录这两个地址也不用登录就能读(按代码推断,没在开登录的情况下实跑;关登录时测试断言 /openapi.json 返回 200) [backend/app/main.py:178-183;backend/app/auth/middleware.py:131;backend/tests/test_provenance_contract.py:376] +- 通知发送核心有四种格式(通用 JSON / 飞书 / 企业微信 / 钉钉):配了密钥时通用格式签 HMAC 头,飞书、钉钉按各自平台的算法签;地址加密落库,日志里只留主机名;平台在 HTTP 200 里回的错误码会认出来;失败重试 1/2/4 秒 [backend/app/notify/core.py:39-51、230-268、296-381] +- 渠道表 notify_channels 有 scope global/app/user 三种,events 存订阅了哪些事件;投递记录在 notify_deliveries;个人渠道要开登录才能建,地址要过 SSRF 检查;可订阅的事件是 task.opened(改派/升级/催办也算)、run.finished、run.failed、action.unknown、review.done [backend/app/db/models_process.py:113-145;backend/app/notify/service.py:29-41、60-71;backend/app/api/notify.py:42-61] +- 待办的提醒、升级、超时驳回在调度循环每一拍里由 tasks.tick 处理,从不自动批准;通知只发给 principal_kind=user 的那几份派单(派给组的在开出那一刻已经展开成成员) [backend/app/process/tasks.py:257-295、764-830;backend/app/scheduler/loop.py:130-131] +- 定时任务的 Webhook 单独存(webhook_deliveries),和渠道共用同一个发送核心(Q7、F14) [backend/app/scheduler/webhook.py:1-60] +- 用户表有 email 列,单点登录时从 claims 里填,管理员也能在「身份」里改,但通知完全不用它 [backend/app/db/models_identity.py:29;backend/app/auth/oidc.py:192;backend/app/api/identity.py:52、119-120] +- 拍板记录 Q6:「待办通知只用 Webhook 的四种格式,不做邮件——SMTP 要另配服务器、还要处理退信」;Q5:飞书、企业微信、钉钉不是标准 OIDC,先不做 [docs/design/app-platform-and-look.md:1383-1384] +- 有两个「对外地址」:环境变量 AGENTLAB_PUBLIC_URL 管 OIDC 回调和 Origin 核对;设置里 notify.public_base_url 管通知里的链接 [backend/app/core/config.py:63-65;backend/app/auth/middleware.py:54-56;backend/app/api/settings.py:610-663] +- 文案规范:界面上禁写「令牌」,要写「token」;而「token」在界面上已经指模型用量(「2.8k token」)。另外「快照」指查询快照 [scripts/check-copy.mjs:10、68;frontend/src/lib/terms.ts:14] +- scripts/check-tokens.mjs 查的是设计令牌(CSS 变量),这个名字已经被占了 [scripts/check-tokens.mjs:1] +- 前端文案的集中程度:242 个 ts/tsx 文件,去掉注释后约 12.3k 段中文(约 11.4 万字)。其中 terms.ts、termsApp.ts、termsBlueprint.ts 占约 5.9k 段(48%),其余约 6.4k 段散在约 230 个文件里(run/decode.ts 1.0k、AssistantStream 0.3k、KnowledgePage 0.3k……)。去掉 __tests__ 和 dev 后约 11.6k 段。terms.ts 有 137 个常量导出、9 个函数导出,另有约 900 处按中文语法拼句的箭头函数。用 /tmp 里写的启发式脚本数的,误差约 ±10% [frontend/src/lib/terms.ts(5924 行)、termsApp.ts(668 行)、termsBlueprint.ts(478 行)] +- 使用者能看到的界面(pages/apps、apps/、登录、通知设置、errors、termsApp、validation、components、App 外壳)一共 43 个文件、约 1.0k 段中文 [frontend/src/pages/apps/*、frontend/src/apps/*、frontend/src/pages/LoginPage.tsx、frontend/src/pages/settings/NotifyTab.tsx] +- 语言写死在两处:、系统通知 lang: 'zh-CN';数字格式固定用 en-US,日期自己拼中文 [frontend/index.html:2;frontend/src/lib/notify.ts:116;frontend/src/lib/format.ts:102;frontend/src/pages/schedules/model.ts:212] +- 服务端给人看的中文:用 AST 数,去掉 docstring 后约 8.9k 处。按位置分:调用参数 3.4k、大写常量 2.0k、return 1.0k、赋值 1.0k、异常消息 0.7k、直接写在 HTTPException 里 244、日志约 300。白话层 plain.py 有 404 条,已经是「编号 → 一句话」的结构。抛 HTTP 错误的地方 131 处带 code、256 处只有中文 detail(api/notify.py 14 处、api/auth.py 16 处全无 code)。前端把带中文的 detail 原样显示 [backend/app/api/coded.py:1-6;backend/app/plain.py:1-30;frontend/src/lib/errors.ts:40-75] +- 按「界面层不放逻辑」(P7),视图模型里的文字是服务端渲染好的:状态、出具档位、印章、空态说明(STATUS_TEXT、TIER_TEXT、Source.message 等)。页面缓存的键里有角色、用户名,没有语言 [backend/app/apps/resolve.py:52-62、533-541、613-650] +- 运行事件里的整句(LOG message、TOOL_ERROR error)写进 run_events,在封存范围内,是数据,事后不能改写 [backend/app/engine/judge.py:1415;backend/app/engine/nodes/human.py:280-294;backend/app/process/actions.py:372-385] +- 测试里断言中文原文的数量:检查脚本里约 14.8k 行带中文字面量,其中约 10.3k 行像断言或选择器;后端测试 23.1k 行 assert 里约 6.6k 行带中文 [scripts/check-*.mjs(check-manage 1378 行、check-decode 1243 行、check-evidence 1031 行……);backend/tests/] diff --git a/docs/design/next-round/05-已知遗留.md b/docs/design/next-round/05-已知遗留.md new file mode 100644 index 00000000..7319a71d --- /dev/null +++ b/docs/design/next-round/05-已知遗留.md @@ -0,0 +1,163 @@ +> 分块细案(2026-10-16 只读调研整理,代码基准 main d135a17)。和 [00-总方案](00-总方案.md) 冲突的地方以总方案为准;总方案「跨块统一的决定」一节列出了改动过的条目。决定编号在本文件内有效,总方案里引用时带前缀(如 评测-D4)。 + +# 已知零散遗留 + +## 缺口 +- 驳回在引擎里被记成失败。下游五处按失败处理或靠正则自己改说法:通知、定时 webhook、发布单的试运行判断、记录页的「继续运行」、问数据的终态。 —— 违背照实说(人的决定被说成故障,还给出注定再失败的出口)、一种说法(每个消费方各写一份映射,已经漏了四处)和界面层不放逻辑(前端用正则解析报错原文) [engine/nodes/tools.py:130/304/383、human.py:55/68、dual_check.py:1399;runner.py:1027-1031;webhook.py:336;publish_plan.py:363;explain.ts:299/489] +- 使用者选人没有候选,服务端也不核对;填错的名字会把待办派给一个不存在的人 —— 违背照实说(静默派错,流程卡死没人知道)。名录又不能整份放开给使用者(只读与最小可见) [PrincipalPicker.tsx:52-53;forms.py:164-197;entries.py:38-60;tasks.py:237-240] +- 表格导入清单、快照清单、作废记录、清除原件记录里的「是否认证」写死 False —— 违背留痕:封存证据里的事实记错了;和审计表、待办派单、run.origin 的 verified 口径不一致 [recipe_imports.py:2386;source_versions.py:425/688;table_versions.py:2163] +- 自由形蓝图没有「谁来做」和核实人的绑定处;第一次发布挡在 app.slot_unbound / app.reconciler_unbound 上,白话指向的卡片不存在 —— 模板定骨架、agent 填关节:骨架是导入的图,关节「谁」却没处填;照实说:「怎么改」指错了地方 [freeform.py:104-107;_common.py:337-361/431-440;lint.py:667-682;plain.py:156-161] +- 剖析得到的行数和日期范围到不了模型(look、db_schema、/schema、表助手);存下的剖析结果在之后加遮罩时不会重滤;剖析不记快照,上传表格源的结果分不清属于哪个版本 —— 问数据和助手都不知道数据到哪天、表有多大。另外要守住 D6 的遮罩,以及叙述层无算术 [schema_view.py:118-153;catalog_profile_jobs.py:278-366;models.py:455-512] +- tick 按全库条数断言,-n 8 下必然受别的测试残留影响 —— 关口偶发失败会盖住真回归。tick 只交回条数,也说不清办了哪几条 [tasks.py:764-782;test_process_tasks.py:283-320] +- 起草进度两处显示,剖析一处,两者不对称;窄屏和触屏看不到排队原因与超时提醒;检查脚本带着已删除的设置键 —— 违背一种说法;照实说(提醒只在悬停里) [CatalogPage.tsx:440-467/592/660-690;check-catalog-loop.mjs:219] +- 排队中的运行在重启时改成 failed,但不发 run.failed 事件 —— 留痕:时间线里没有失败原因这一行(界面能收尾,属于小口) [engine/runner.py:101-106] +- 浏览器关口依赖会变的开发数据,靠逐项打合成补丁维持;缺数据和真坏了混在一起报 —— 照实说:数据类失败名单 10-05、10-12、10-14 变了三次,会盖住真回归;真实数据也不该进公开仓库的检查 [check-runs.mjs:207-213/683-720/1600-1625 等;check-ui.mjs:121-124] + +## 做法 +### (a) 驳回 ≠ 失败:引擎层统一成「因人的决定而结束」 +- 做法:1) engine/context.py 新增 NodeRejected(NodeError),字段:node_id、kind(action_confirm/approve/input/edit/code/tool/dual_check)、decision(reject|expired_reject)、actor、note。六处驳回改抛它,原话不变。2) runner._drive 在 except Exception 之前单独接住 NodeRejected:status='cancelled',error 写原话,error_node 写节点,terminal=(RUN_CANCELLED, {reason:'rejected', node_id, label, kind, decision, actor, note})。Run 新增可空列 end_reason(rejected|stopped),走 db/base._COLUMN_MIGRATIONS 加法迁移;手动停止写 stopped;RunOut 带上这一列。_close_approvals、沙箱回收、bus.close、封存对 cancelled 已经生效,不用改。3) 继续运行:cancelled 已经被 _cannot_continue 拒;按 end_reason 改文案为「被驳回的运行不能接着跑:改好内容后重新发起」。存量的 failed+驳回(用 resolve._decision 同一份判断)也拒,避免重跑出同一个驳回。4) 下游统一读 end_reason。instance_state 的 failed+reject 分支只留给存量;notify_run 改用 instance_state 的 code/text(驳回和「已退回」都走「实例结束」,ok=False);webhook._EVENT_OF 让 cancelled+rejected 推「未出具(被驳回)」,手动停止仍然不推;publish_plan 遇到 cancelled+rejected 的试运行不算 trial_failed,记 warning「最近一次试运行在「付款」被驳回、没有走完」。5) 前端:decode.ts、chat.ts 遇到 reason=rejected 时标题写「被驳回」,副标题写谁、备注;lib/status 的标签按 end_reason 区分;explain.ts 的正则只留给存量,并补上三种原话、continuable=false。顺手把 (h) 的小口一起做:重启时被改成 failed 的排队运行补发 run.failed。 +- 复用:NodeError;RUN_CANCELLED 和 cancelled 终态的全部收尾路径;apps/resolve._decision/_REJECTIONS/instance_state;plain 层;_COLUMN_MIGRATIONS;check-decode 的事件夹具 +- 文件:后端:engine/context.py、engine/runner.py、engine/nodes/{tools,human,dual_check}.py、db/models.py、db/base.py、api/runs.py、apps/resolve.py、notify/service.py、scheduler/webhook.py、blueprints/publish_plan.py。前端:run/decode.ts、store/chat.ts、lib/status.ts、lib/explain.ts、types.ts。测试:test_process_actions.py、test_process_tasks.py、test_app_instance_page.py、webhook 与 publish_plan 的测试。检查脚本:check-decode、check-apps、check-inbox 的伪造数据 +- 对上的理念:照实说:驳回是人的决定,不给注定再失败的出口。一种说法:六处驳回一种异常、一种终态,消费方读同一个字段,界面不再正则解析报错原文(界面层不放逻辑)。留痕:谁驳回、备注写进 run.cancelled,进封存。不可回退:已封存的老运行不改写 +- 否决的备选:① 维持 failed,让各消费方各自映射:已经漏了四处,以后每加一个消费方都要再记一次。② 新增运行状态 rejected:_TERMINAL/_SETTLED 至少 6 处、前端 112 处 cancelled 引用、部分索引条件都要改,漏一处就是一直等终态的死循环;收益和 end_reason 一样。③ 给动作确认节点加「驳回」出口:是有价值的补充(作者可以把驳回接到「未发送」成果),但解决不了没接出口时的语义,留作后续 +### (b) 使用者选人:按用途给的候选池 + 服务端核对 +- 做法:1) 新接口 GET /api/directory/candidates,任何登录的人都能调。参数 purpose=form|review;form 用途带 app_id、entry 或 task_id、field;q 前缀搜索,limit≤50。只返回 {users:[{username, display_name}], groups?, truncated},不给是否停用、角色、邮箱、组成员;停用的人直接不列。2) auth/directory.py 新增 candidates() 在服务端算候选池。form 字段默认是「能用这个应用的人」:AppGrant use 及以上,展开组成员,加上管理者;应用授权给所有人时,必须带 q 才返回。字段可以声明 x-principal: {pool: 'app'|'group:<组>'|'slot:<岗位>'},照 x-file 的写法进 forms._FIELD_KEYS 和 validate_form_schema。review 用途是启用的搭建者和管理员,规则同 _review_targets。调用 form 用途时要求这个人能用这个应用(app_role≥use),否则 403。3) 开登录时,start_entry 和待办表单提交按同一个池核对 principal 控件的值;不在池里返回 422,code=principal(FORM_ERROR_CODES 加一项),话写成「「x」不在可选的人里」。关登录照旧手填、照实提示。4) 前端 PrincipalPicker 加 source 属性:搭建者照旧用全量名录;使用者边输入边按 q 取候选(防抖),列表里显示显示名,悬停给用户名。SchemaForm 把表单上下文传下去,personal.tsx 用 purpose=review。/api/directory 仍然只给搭建者 +- 复用:auth/directory.load_directory/mismatch;apps/access.app_role;AppGrant;process/tasks._expand 的组展开;forms 的 x-file 扩展写法;personal._review_targets 的角色规则;PrincipalPicker 现有的核对与提示 +- 文件:后端:auth/directory.py、api/identity.py、main.py、apps/forms.py、apps/spec.py、apps/entries.py、process/tasks.py(表单提交核对)、blueprints/personal.py。前端:components/PrincipalPicker.tsx、components/SchemaForm.tsx、pages/apps/personal.tsx、api/client.ts、typesApp.ts、lib/termsApp.ts。检查脚本:check-apps、check-inbox、check-personal +- 对上的理念:只读与最小可见:使用者只看到这件事能选的人,而且只给名字。照实说:填错的名字当场拒,不再静默派给不存在的人。一种说法:候选池和核对在同一处算,前端不自己判 +- 否决的备选:① 把 /api/directory 放开给所有角色:会暴露全员、停用状态、所有组。② 只做前端候选、不做服务端核对:绕过前端照样会派错。③ 放在发布检查里核:使用者填的是运行时值,发布时还不知道 +### (c) 导入清单如实记录「是否认证」 +- 做法:api/deps 新增 signer_verified()(开了登录且 principal.verified 时为真),table_imports、source_versions、datasources 的写接口把它和 signed_by 一起往下传。recipe_imports._manifest、累积快照清单、_revoked_record、purge_import_raw 改为写真实值。已封存的旧清单不改写。界面 ManifestBody 按记下的值说「登录身份 / 未认证署名」,用 VersionsDialog.signerLine 的同一套说法 +- 复用:api/deps.signer_of;SIGNER_TEXT 的现成说法 +- 文件:后端:api/deps.py、api/table_imports.py、api/source_versions.py、api/datasources.py:1107、data/recipe_imports.py:2361-2390、data/source_versions.py:395-427、687-688、data/table_versions.py:2163。前端:pages/import/ManifestBody.tsx:82、VersionsDialog.tsx:453/537/657。测试:test_table_imports_api.py:232、test_source_versions_api.py:256、test_datasources_upload.py:768、test_provenance_* +- 对上的理念:留痕:是谁、是否认证都如实记,和审计表、task_assignments、run.origin 同一个口径。已封存的证据不改写 +- 否决的备选:读清单时按审计表补算:证据要在生成那一刻定下,事后推断不可信。把旧清单批量改成 True:会破坏哈希封存 +### (d) 自由形蓝图:按导入的图列出「谁来做」 +- 做法:1) freeform 的 cards 加 people,备注写「只定谁来做;会签、时限、超时在画布上改」。2) 新增 freeform.slots(bp):对 tree.slot_refs(graph, _writes(graph)) 去重成岗位,每个岗位列出用到它的节点和用途;写入能力没登记查询时,再加一行「写入结果不明时谁去核实」。blueprint_out 给出 derived_people。表达式、发起人、外部系统派单的步骤列成只读行。3) 存储复用现有路径:岗位绑定存在 spec.people[],Step.id 就是岗位名、只写 who,_common.slot_of/bindings_of 原样生效;核实人存在 /writeback/reconciler。needs_reconciler 对 freeform 按图里的写入算(同 freeform._writes 加 write_lookup)。bp_set 对 freeform 的 people 只收 who,其余字段拒收,报 bp.template_mismatch「在画布上改」。4) 检查:blueprint_issues 拿得到图(_context.graph,或新增 graph 参数)。派生岗位没绑时报 bp.people_unbound(试运行 warning、发布 error),路径指向 /people//who;图里已经没有的岗位,残留的绑定报 warning。publish_plan 照 reconciler 的办法,去掉重复的 app.slot_unbound(slots.)。5) 前端 BlueprintCards 的 people 卡片对 freeform 按 derived_people 渲染:行名只读,「谁来做」用 PrincipalPicker;核实人一行复用 writeback 的 ItemRow +- 复用:tree.slot_refs;freeform._writes;capabilities.registry.write_lookup;_common.bindings_of/slot_of/needs_reconciler;bp.people_unbound、bp.reconciler_unbound 及其白话;publish_plan 现成的去重办法;BlueprintCards 的行组件 +- 文件:后端:blueprints/templates/freeform.py、templates/_common.py、blueprints/ops.py、blueprints/store.py(blueprint_out)、blueprints/publish_plan.py、plain.py。前端:pages/blueprints/BlueprintCards.tsx、editors.tsx、typesBlueprint.ts、lib/termsBlueprint.ts。测试:test_publish_plan.py、test_blueprint_templates.py、test_blueprint_starts.py。检查脚本:check-blueprint.mjs +- 对上的理念:模板定骨架、agent 填关节:自由形的骨架就是导入的图,卡片只填「谁」这个关节,不改图结构。一种说法:同一个岗位名、同一条 bindings_of、同一个核实岗位。照实说:「怎么改」指向真实存在的卡片 +- 否决的备选:① 卡片上也能改会签和时限:会改导入的图,图哈希和已发布版本对不上,发布单就要另存一版,违背「改图回画布」。② 不做,让人去应用编辑器绑:第一次发布时应用还不存在,绑不了。③ 导入时把岗位一次性写进 spec.people:图换版本后行就过时了;派生出行、只存绑定更稳 +### (e) 剖析事实进 look 的表详情(守 D6 的遮罩) +- 做法:1) catalog_profile_jobs 新增 last_facts(session, source_id, tables=None),复用 _ranked/_SHOWN,一条查询批量取 {at, job_id, row_estimate, date_ranges, snapshot_id}。2) CatalogProfileTask 加可空列 snapshot_id:剖析上传表格源时记当前快照,数据库源为空。只加列,不改已有的。3) schema_view 新增 profile_lines(facts, masked, snapshot_id),table_detail 加可选参数 profile=。渲染成一节:「数据剖析(YYYY-MM-DD):约 N 行(数据库统计估算 / 逐行数出 / 至少 N 行);下单日从 … 到 …」,后面加一句固定提示:「只供写查询参考(定时间范围、估表大小),不是查询结果,不能作为回答里的数」。遮罩按渲染这一刻的 masked_set 重滤;上传表格源只在 snapshot_id 和这次解析到的快照一致时给,不一致时写「剖析早于当前版本,不给」。4) 四处都接:look/tables 的 _Plan 一并取 facts;tools/datasource._make_schema_tool 在建工具时读(和目录同一时机);/schema?table= 接口;表助手的 _catalog_lines 用同一个 profile_lines +- 复用:schema_view.table_detail/masked_set;catalog_profile_jobs._ranked/_SHOWN;TableSize 的 method 叫法(和界面「准数 / 数到上限」一致,fa9739d);look D6 的遮罩规矩 +- 文件:后端:data/schema_view.py、data/catalog_profile_jobs.py、data/catalog_profile.py(记 snapshot_id)、db/models.py、db/base.py、look/tables.py、tools/datasource.py、api/datasources.py、data/catalog_assistant.py。测试:schema_view、look tables、datasource tools、catalog_profile_jobs 各自的测试 +- 对上的理念:一种说法:D6 规定同一张表一份说法,四处共用。遮罩:按当前遮罩重滤,模型看不到遮罩列的取值。叙述层无算术:明说剖析事实不可引用,裁判照旧标出没有出处的数。照实说:写明剖析日期和怎么数的,过期版本不给。不挡生成:取不到剖析结果就不出这一节 +- 否决的备选:① 写进数据目录 notes:目录只放人审过的事实,剖析数字是会过期的观测,还会随目录导出带走。② 只给助手 look、不给运行时 db_schema:两边说法分叉,违背 D6,而问数据最需要知道数据到哪天。③ 在 schema_view 里直接读库:它是同步纯函数,保持由调用方读好再传入的分层 +### (f) tick 测试不再受别的测试残留影响 +- 做法:tasks.tick 和 actions.tick 改为返回这一拍处理过的 id 列表。调度循环 loop.process_due 不用返回值,不受影响;日志可以带上条数。测试只断言自己的待办:用 task.id in tick(...)、只升一次用 not in,保留对 escalation_level、事件数的断言。先写失败测试:加一个 fixture,预先开一条无关的、到期可升级的待办(模拟别的测试的残留),现有断言立刻失败;改完通过。同步修 test_app_instance_page.py:261,以及 test_process_actions、test_records 里按条数断言 actions.tick 的地方 +- 复用:现有测试辅助 _app_run/_task/_events;/tmp 里验证过的两条测试的复现组合 +- 文件:backend/app/process/tasks.py:764-782、process/actions.py(tick);backend/tests/test_process_tasks.py、test_app_instance_page.py、test_process_actions.py、test_records.py +- 对上的理念:生效可查可对账:tick 交回办了哪几条。测试只断言自己造的数据 +- 否决的备选:① 给 tick 加只给测试用的过滤参数:测试需求漏进生产接口。② 每个测试一个新库:run_manager 和 checkpointer 绑在进程级引擎上,代价大。③ xdist 改成 --dist loadfile:同一进程里不同文件照样会串,只是换了个概率 +### (g) 目录页的后台任务进度只放一处;检查脚本去掉旧键 +- 做法:页头的「起草」「数据剖析」改回纯入口:进行中换成转圈图标,排队中换成时钟,读屏名称写状态,不写数字。DraftStrip 换成一条 JobStrip,按排队顺序列出这个源进行中和排队的起草、剖析(SourceLane 串行,最多两项)。每项一行:「起草 3/20」或「剖析 排队中,起草结束后开始」,加进度条和提醒,剖析的「最近查询多数超时」也挪到这里;右侧「查看」打开对应对话框。排队顺序取服务端的 waiting_for。check-catalog-loop.mjs:219 换成 table_max_queries: 40、table_max_s: 60,与 check-catalog-profile.mjs:231 的 DEFAULTS 一致 +- 复用:draftJob.ts/profileJob.ts 的状态;waitsForProfile;DraftStrip 的样式;data-* 约定改为 data-catalog-job-strip,每项带 data-catalog-job=draft|profile +- 文件:frontend/src/pages/catalog/CatalogPage.tsx、lib/terms*.ts;scripts/check-catalog.mjs、check-catalog-profile.mjs、check-catalog-loop.mjs:219 +- 对上的理念:一种说法:起草和剖析用同一套后台任务显示,进度只在一处。照实说:排队原因、超时提醒在窄屏和触屏上也看得见。界面层不放逻辑:排队关系取服务端给的 +- 否决的备选:① 保留两处:同一组数字两处维护,而且起草两处、剖析一处,已经不对称。② 只留页头(照设计文档 §66):窄屏页头只剩图标,提醒只在 title 里,触屏看不到 +### (h) 早期审查的三项:只核实,不再改 +- 做法:h1、h2、h3 已在 main 上解决,并且有测试守着。唯一建议补的是 runner.startup:把排队运行改成 failed 时补发一条 run.failed,并进 (a) 的工作包,约 10 行代码加 1 个测试 +- 复用:runner._emit;startup 里已有的补发 server_shutdown 写法 +- 文件:backend/app/engine/runner.py:101-115;对应的 startup 测试 +- 对上的理念:照实说、留痕:失败要有原因事件 +- 否决的备选:不补:界面能收尾,只是时间线里少一行原因。补的代价很小,所以建议补 +### (i) 浏览器关口改用自带的种子数据 +- 做法:1) 新增确定性的检查数据生成器 scripts/seed-check-data.py(核心逻辑放 backend/app/devseed/)。AGENTLAB_DATA_DIR 指到输出目录,用应用自己的代码真跑:seed.py 的模板、run_manager 加演示模型 mock-fast、tests/fixtures/acceptance/retail.py 的虚构零售库。要造出的形态:≥130 条运行(含排在第 100 名之后的待办)、不属于应用的待处理审批 2 条、等过审批(wait_ms>0)的已完成探索运行、没保存就跑的运行、工作流删掉后留下的运行、有工作流的 401 失败(脚本里起一个回 401 的本地桩,照 fake-erp.py 的写法)、表达式写错的失败、取消的、驳回的(接 (a))、发布过两版以上的工作流、问数据对话、画布对话为空的工作流、数据源与目录。时间戳用固定偏移,随机种子固定。生成完自检:每种形态都造出来才算成功。2) check-all --stacks 支持 CHECK_DATA_SRC=seed:先生成一份到 /tmp,再按现有逻辑每套拷一份;不带 --stacks 时仍连开发服务,作为冒烟。3) 去掉 c6a29bf 的合成补丁,只留一种机制。形态缺失时:seed 模式记 ✗(是生成器的错);真数据模式记「– 跳过:数据里没有 …」,在 check-all 汇总里单列「没覆盖」。4) 「提取模板」一节自己 goto 到运行,不再依赖上一节停在哪 +- 复用:check-all 的 --stacks/CHECK_DATA_SRC 隔离;seed.py;providers/mock_model.py;retail.py 生成器;fake-erp.py 的桩写法;各检查现有的写闸门 +- 文件:新增 scripts/seed-check-data.py、backend/app/devseed/*;改 scripts/check-all.mjs、check-runs.mjs(207-213、521、618、641、683-720、1600-1625、2422)、check-ui.mjs(121-124);README 的检查一节 +- 对上的理念:照实说:分清「坏了」和「没有数据可测」。真实数据不进公开仓库:种子全是虚构的、现场生成,不提交。一种说法:一套数据来源规则,不在各检查里各自合成 +- 否决的备选:① 继续逐项打合成补丁:开发数据一变就有新失败,已经发生过三轮(10-05、10-12、10-14)。② 把种子库提交进仓库:是二进制,随代码变化要重新生成,还容易混进真数据。③ 全部改成伪造接口(像 check-blueprint 那样):check-ui 的价值就在于检查真后端接起来对不对 + +## 待拍板(本块) +- Q1 人工驳回(动作确认、审批驳回即终止、填写驳回、代码和工具审批驳回、双盲驳回)在引擎里记成什么终态?|选项:A 终态 cancelled + 新列 end_reason=rejected,run.cancelled 带上谁、备注、节点 / B 新增运行状态 rejected / C 维持 failed,只修通知、定时、发布单、记录页四处映射|推荐:A | cancelled 的收尾路径(关闭并行审批、回收沙箱、关流、不许继续运行)正好是驳回要的;全站的终态集合已经包含它,不会漏出一直等终态的死循环。B 改动面大、风险高,收益和 A 一样。C 等于每多一个消费方就要多记一次,已经漏了四处 +- Q2 被驳回的应用实例、定时任务那一期,要不要推送?推到哪个事件?|选项:A 走「实例结束」(run.finished),文字按 instance_state 写「已退回」或「「付款」未确认(被驳回)」;定时任务 webhook 推「未出具(被驳回)」 / B 新增可订阅事件 run.rejected(订阅设置多一项) / C 不推|推荐:A | 发起人需要知道结果;订阅「实例失败」的人是在盯故障,驳回不该进去。走结束事件不用增加订阅项,还顺带修好「已退回」被说成「已完成」的现有不一致 +- Q3 使用者在表单里选人时能看到谁?|选项:A 字段可以声明候选池,默认「能用这个应用的人」;应用授权给所有人时必须输入前缀才出候选;只给用户名和显示名 / B 全部启用的用户 / C 不给候选,只在服务端核对|推荐:A | 选的人通常要在这个应用里处理事情,「能用这个应用的人」最贴近这件事,暴露面也最小。B 等于给全员发通讯录。C 能防派错,但使用者仍要凭记忆填用户名 +- Q4 服务端开始核对 principal 控件后,已上线的应用里手填过、不在候选池的名字会被 422 拒收。直接生效,还是先只警告一个版本?|选项:A 直接生效(拒收并说清) / B 先记警告、下个版本再拒|推荐:A | 不在池里的名字派出去本来就没人能处理,拖一个版本只会多出几条卡死的待办;拒收的时候说清楚即可 +- Q5 自由形蓝图的「谁来做」卡片上能改什么?|选项:A 只定每个岗位由谁做(含核实人);会签、时限、超时在画布上改 / B 卡片上全都能改|推荐:A | 自由形的骨架就是导入的图。改会签、时限会改图,图哈希和已发布的那一版对不上,发布单就得另存一版,违背「改图回画布」 +- Q6 剖析事实(行数、日期范围)给到哪些地方?|选项:A 助手 look、运行时 db_schema、/schema 接口、表助手四处都给(同一段文字) / B 只给助手 look|推荐:A | D6 定的是同一张表一份说法;问数据最需要知道数据到哪天。叙述层无算术靠一句固定提示加裁判守住 +- Q7 目录页起草、剖析的进度放在哪?|选项:A 页头只做入口(只换图标),页头下方一条任务条按排队顺序列出两类任务 / B 只留页头(按设计文档 §66),删掉 DraftStrip / C 维持现状|推荐:A | 进度只在一处,排队原因和超时提醒在窄屏、触屏上也看得见;B 会把提醒藏进悬停提示里 +- Q8 浏览器关口(check-runs、check-ui)用什么数据?|选项:A check-all 默认用现场生成的种子数据,开发数据只做冒烟(缺数据的项列成「没覆盖」) / B 继续用开发数据拷贝,逐项打补丁|推荐:A | 数据类失败的名单已经变了三轮,会盖住真回归;种子是虚构的、现场生成,也不会把真实数据带进公开仓库 + +## 风险 +- (a) 终态从 failed 改成 cancelled:凡是按 status=='failed' 判断的地方都要逐一核对(webhook、publish_plan、记录页「失败」页签、评测和对比里按状态筛的地方);check-apps、check-inbox、check-decode 的伪造数据要跟上;可验证报告和 HTML 导出按 meta.status 画 FAILED(bundle/build.py:1359),驳回的运行怎么显示要核对 +- (a) 定时任务里被驳回的那一期:auto_compare 的终态集合已包含 cancelled,会不会对它做自动对比、webhook 里「下一次照常」怎么写,要在实施时定清楚(没核实现有行为) +- (a) 存量 failed+驳回的老运行保持原状(封存不改写),界面上会同时存在新老两种形状,resolve 和 explain 的存量分支要保留并且有测试 +- (b) 名单可见面:使用者能枚举应用授权范围内的人;授权给所有人时靠「必须输入前缀」和条数上限压住;单点登录的人第一次登录前不在名录里,选不到,要照实提示请对方先登录一次 +- (b) 新增的 422 会让以前靠手填跑通的入口开始拒收,发布说明里要写明 +- (d) 导入图里的岗位名是画布上随手写的 id,行名要靠节点标题拼出来;同一个岗位用在多处时说明要写全;图换版本后残留的绑定只报警告,不静默删除 +- (e) prompt 预算:每张表多 1–3 行。模型把剖析数字抄进正文时,裁判会不会标成「无出处」,这一点没核实,实施时要加一条用例。存量剖析记录没有 snapshot_id,上传表格源在重新剖析之前一律不给(照实说) +- (f) 改 tick 的返回类型:已 grep,调用方只有调度循环和测试,风险低 +- (g) 改页面结构要同步改三个检查脚本的断言(check-catalog、check-catalog-profile、check-catalog-loop),并且都要跑过 +- (i) 生成种子要真跑引擎,模拟模型输出一变就可能让检查不稳;生成器要固定时间和随机种子,自带形态自检;生成耗时会算进 check-all +- 和本轮其他四块改同一批文件(见 cross),合并顺序要排好 + +## 测试 +- (a) 先写会失败的测试:动作确认驳回后,断言 run.status=='cancelled'、end_reason=='rejected'、run.cancelled 带 reason/actor/note/node_id,且 Run.error 原话不变;对六种驳回(工具审批、动作确认、代码审批、审批驳回即终止、填写/编辑驳回、双盲驳回)做参数化 +- (a) 驳回的运行 POST /continue 返回 409,话按 end_reason 写;存量 failed+驳回的运行也被拒(直接造一条 failed 运行加驳回的待办) +- (a) 通知:monkeypatch spawn,断言推的是实例结束、文字取自 instance_state,没有 run.failed;走到「已退回」成果的运行推「已退回」,不再写「已完成」 +- (a) 定时任务 webhook:驳回的一期推「未出具(被驳回)」,手动停止的不推 +- (a) 发布单:试运行被驳回时没有 plan.trial_failed,有 warning;真正失败的试运行照旧挡住 +- (a) resolve.instance_state、_timeline 对新形状和存量 failed 形状都给出同样的说法 +- (a) 前端:check-decode 加 run.cancelled reason=rejected 的用例(标题「被驳回」、副标题谁和备注);explain 的存量原话三种都是 continuable=false +- (b) 使用者调 /api/directory/candidates:只返回池里的人、只有 username/display_name,停用的不列;对没有使用权的应用 403;授权给所有人时不带 q 不出候选;review 用途只出搭建者和管理员;/api/directory 对使用者仍是 403 +- (b) 开登录:start_entry 和待办表单的 principal 不在池里时 422 code=principal;关登录不核对;x-principal 声明非法时 validate_form_schema 报问题 +- (c) 开登录导入一期:清单 signed_by.verified 为 True;关登录为 False;作废、清除原件的记录同理;旧清单读取时哈希复验照过 +- (d) 从带派给岗位 finance 的人工节点、加上没登记查询的写入的已发布工作流建自由形蓝图:blueprint_out.derived_people 有 finance 和核实人两行;bp_set /people/0/who 之后 bindings_of 绑到 finance;发布单没有 app.slot_unbound;不选人时发布报 bp.people_unbound,路径指向 /people/0/who;改 policy 被拒 +- (e) table_detail 带 profile 时渲染行数和日期范围,并带固定提示;剖析后才给某一列加遮罩,那一列的范围不出现;上传表格源快照不一致时不给;没有剖析结果时输出和现在逐字一致(快照断言);look、db_schema、/schema 三处文本一致 +- (f) 新 fixture 预先放一条无关的、到期的待办,原来的条数断言立刻失败;改成按 id 断言后通过;在 -n 8 下连跑 test_process_tasks、test_app_instance_page、test_blueprint_trial、test_process_actions、test_records 五遍 +- (g) check-catalog:只有一处显示进度;起草排在剖析后、剖析排在起草后,两种顺序的文字都对;窄屏下看得见;check-catalog-loop 用新键 +- (i) seed-check-data 生成后自检各形态都在;CHECK_DATA_SRC=seed 时 check-runs、check-ui 全部 ✓、零跳过;连生成两次,结果一致(运行数、待办数、关键字段) + +## 验收 +- (a) 用真实后端加 fake-erp:在应用里驳回一次动作确认。实例页、记录页、待办箱、站内和 Webhook 通知、定时任务消息、蓝图发布摘要都写「被驳回」,不出现「失败」和「继续运行」。手动停止仍写「已取消」,定时任务不推。老的 failed 驳回运行显示不变,也不能继续运行 +- (b) 开本地登录,以使用者身份在入口表单输两个字就出候选(只有名字),选中后发起,待办派给对的人;手填一个不在池里的名字,当场拒收并说清;关登录照旧手填;使用者访问 /api/directory 仍是 403 +- (c) 开登录导入一期,清单里 signed_by.verified 为 true,界面写「登录身份」;关登录为 false;旧清单不变、复验通过 +- (d) 用已发布的「财务会签(岗位)+ 写回(没登记查询)」工作流建自由形蓝图:卡片上列出两行 → 选好人 → 试运行 → 发布单没有 slot_unbound,一次发布成功;不选人时,发布摘要「你来定」指向这张卡片 +- (e) 剖析过的表在助手 look、问数据 db_schema、/schema 里看到同一段剖析事实;给日期列加遮罩后,那一列的范围消失;上传表格换版本后不再给 +- (f) pytest -n 8 连跑 5 次相关文件全过;原来能复现的那两条测试组合也通过 +- (g) 目录页只有一处显示后台任务进度,窄屏可读;check-catalog、check-catalog-profile、check-catalog-loop 都过 +- (i) CHECK_DATA_SRC=seed node scripts/check-all.mjs --stacks 2:check-runs、check-ui 全过、零「没覆盖」;对开发数据跑时,缺数据的项列成「没覆盖」,不算失败 +- 整体关口:后端全量 pytest -n auto、tsc、check-copy、相关单项检查由各包自己跑;整套 check-all 由编排者在合并后只跑一次(按 dont-rerun-full-checks);推送前跑泄露扫描 + +## 工作量 +按项估:(a) 后端约 250 行 + 测试约 200,前端约 80 + 检查约 60;(b) 后端约 200 + 测试约 150,前端约 120 + 检查约 60;(c) 后端约 40 + 测试约 40,前端约 15;(d) 后端约 220 + 测试约 180,前端约 150 + check-blueprint 约 60;(e) 后端约 180 + 测试约 150,无前端;(f) 后端约 15 + 测试改约 40;(g) 前端约 120(净减)+ 三个检查脚本约 80;(h) 约 10 + 测试约 20(并进 a);(i) Python 生成器约 500–700 + check-all 约 60 + check-runs/check-ui 约 150。合计:后端约 1100 行(含测试约 800),前端约 500 行,检查脚本和生成器约 1100 行。 + +按 workflow-granularity,建议拆 4 个工作包,每包一个完整代理,最后一次统一复核: +- L1 运行语义与留痕:(a)、(c)、(f)、(h),纯后端为主加少量前端; +- L2 选人与自由形:(b)、(d),同改 PrincipalPicker 和蓝图,放一包避免冲突; +- L3 目录与 look:(e)、(g); +- L4 检查数据:(i)。 + +依赖关系: +- L4 的种子要覆盖 L1 的驳回形态,所以 L1 先合,L4 最后做,做完再跑一次完整关口; +- L3 和「数据接入与目录维护」块改同一批文件,建议并进那一块或排在它之后; +- L1、L2、L3 之间没有代码依赖,可以并行。 + +## 和其他几块的接口 +- 和「对外集成(API 令牌、通知渠道、视图嵌入、英文界面)」块的冲突:(a) 改 notify/service.py(notify_run 的事件和文字)和 scheduler/webhook.py(_EVENT_OF、headline),通知渠道那块大概率也改这两处,要排序或合到一个包;(b) 改 api/identity.py、auth/directory.py、main.py 路由权限,API 令牌那块会碰身份和权限依赖(api/deps.py),(c) 也改 api/deps.py;英文界面:(a)(b)(d)(g) 新增的文案都进 lib/terms*.ts,如果同时做 i18n 迁移,要先定新键怎么写 +- 和「口径账本:问数据接已治理口径」块的冲突:(e) 改 data/schema_view.py、look/tables.py、tools/datasource.py,口径账本把 look 接进问数据时会改同一批文件;(a) 改问数据的终态处理(store/chat.ts、decode.ts)。建议 (e) 先合,账本基于它做 +- 和「数据接入与目录维护(不含新库类型)」块的冲突:(e) 改 catalog_profile_jobs.py、catalog_profile.py、db/models.py(新列 snapshot_id),(g) 改 CatalogPage.tsx 和 check-catalog* 三个脚本,目录维护那块几乎一定会动这些;建议 L3 并进那一块,或者等它合入后再做 +- 和「效果度量与回归评测」块的接口:评测要按运行终态统计时,要认 (a) 新增的 end_reason(驳回不算失败);(i) 的种子数据生成器可以和评测的固定数据集共用生成框架(mock 模型、retail.py),避免造两套 +- (a) 新增 Run.end_reason 列、(e) 新增 CatalogProfileTask.snapshot_id 列,都走 db/base._COLUMN_MIGRATIONS,只加不改;和其他块同时加迁移时,注意合并顺序和列表冲突 +- (f) 只改测试和 tick 的返回值,对其他块唯一的影响是:并行跑的测试会更稳 + +## 附:现状(代码位置) + +- (a) 结论:仍存在。显示层改了,引擎、通知、定时任务、发布单、记录页都没改。动作确认被驳回时,引擎抛普通的 NodeError,运行走 _failure 发 RUN_FAILED,status 记成 failed。同样的写法一共 6 处:工具节点审批驳回、动作确认驳回、代码审批驳回、人工审批「驳回即终止」、填写/编辑模式驳回、双盲复算驳回。 [backend/app/engine/nodes/tools.py:130、:304、:383;human.py:55、:68;dual_check.py:1399;engine/runner.py:833-837(except Exception)、:1271-1291(_failure)] +- (a) 显示层:apps/resolve.instance_state 和 _timeline 按待办结论把 failed+reject 改说成 code=cancelled「「付款」未确认(被驳回)」。docstring 原话是「运行本身的状态不改(驳回动作确认在引擎里就是节点失败、运行结束)」。测试也照这个断言 final.status == 'failed'。 [backend/app/apps/resolve.py:1328-1380、1494-1498、_REJECTIONS 1302;backend/tests/test_process_actions.py:456-467] +- (a) 其他消费方仍按「失败」处理,共四处。① 应用实例结束时推 run.failed,消息写「实例失败」+「失败节点」。② 定时任务 webhook 把 failed 推成「运行失败」。③ 蓝图发布单里,最近一次试运行 status==failed 就挡发布(plan.trial_failed):搭建者在试运行里驳回写入,会被说成「试运行失败了」并挡住发布(按代码推断,没实跑)。④ 记录页 explain.ts 只用正则 /人工(驳回|拒绝)/ 认驳回,「未确认(被驳回)」「工具 X 的调用已被驳回」「代码执行已被驳回」三种原话都会落到兜底 continuable:true,于是给出「继续运行」。runner.continue 允许 failed,按 runner.py:536-541 的注释,从断点重跑会读到同一个驳回答复、再失败一次(推断,没实跑)。另外,界面靠正则解析报错原文本身就违背「界面层不放逻辑」。 [engine/runner.py:1027-1031;notify/service.py:203-221;scheduler/webhook.py:166-175、:336;blueprints/publish_plan.py:363-365;frontend/src/lib/explain.ts:299-306、:489;engine/runner.py:508] +- (a) 附带发现:走「驳回」出口到达「已退回」成果的运行(succeeded),实例页写「已退回」(code=cancelled),推送却写「实例已完成」。两边说法不一致。 [apps/resolve.py:1356-1361 对 notify/service.py:214-219] +- (b) 结论:仍存在。名录接口 /api/directory 只挂给搭建者(_BUILDER),返回用户名、显示名、是否停用和全部组。PrincipalPicker 只有搭建者且开登录时才取名录,使用者只能手填。 [backend/app/main.py:240;api/identity.py:215-224;frontend/src/components/PrincipalPicker.tsx:52-53] +- (b) 入口表单和待办表单的 principal 控件(SchemaForm)在服务端不核对。validate_form 没有 principal 规则,map_inputs 原样传值。用表达式派单时,resolve_assignee 只在算不出名字时记警告,tasks._targets 也不核名录。所以手填错的名字会把待办派给一个不存在的人:没人能处理,也不报错。 [frontend/src/components/SchemaForm.tsx:139-148;backend/app/apps/forms.py:164-197;apps/entries.py:38-60;engine/nodes/human.py:138-155;process/tasks.py:237-240] +- (b) 个人应用「提交审核」也是使用者在选人。服务端 _review_targets 会按名录核对,并要求对方是搭建者,但前端没有候选,使用者要凭记忆填搭建者的用户名。 [frontend/src/pages/apps/personal.tsx:174;backend/app/blueprints/personal.py:473-496] +- (c) 结论:仍存在。位置在表格导入的清单(Excel 导入),不在能力登记、端点或目录导入。导入清单和累积快照清单写死 "signed_by": {"name": …, "verified": False},作废记录、清除原件记录写死 signed_by_verified: False。可署名来自 signer_of:开了登录时就是认证过的用户名。清单是封存证据(取回时 load_manifest 复验哈希),里面却写着「未认证」。import_manifest 返回的 verified: True 指的是哈希复验结果,那个是对的,不是这次的问题。 [backend/app/data/recipe_imports.py:2386;data/source_versions.py:425、:688;data/table_versions.py:2163;api/deps.py:97-104;data/source_versions.py:100-110、:598、:612] +- (d) 结论:仍存在。自由形模板只有目的、多久更新、谁能看三张卡片。但它派生出的应用骨架会按图声明岗位:slot_refs 收人工节点派给谁、超时升级、动作确认,写入能力没登记查询时还会加 RECONCILER。bindings_of 只从 people 和 writeback.reconciler 取绑定,needs_reconciler 只在模板有 writeback 卡片时为真,所以自由形永远是 False。于是应用检查报 app.slot_unbound / app.reconciler_unbound(error),白话的「怎么改」写「在「谁来做」里给这一步选人或组」,可自由形蓝图上没有这张卡片。publish_plan 只对 bp.reconciler_unbound 去了重。 [blueprints/templates/freeform.py:104-107、app_skeleton 45-68;apps/derive.py:99-104;apps/tree.py:90-121;blueprints/templates/_common.py:337-361、:431-440;apps/lint.py:640-682;plain.py:156-161;blueprints/publish_plan.py:348-354] +- (d) 后果:从已发布工作流导入、图里有派给岗位的人工节点或没登记查询的写入的自由形蓝图,第一次发布会被挡死。这时应用还不存在,没法去应用编辑器绑岗位,只能绕开蓝图直接建应用。测试只覆盖了没有人工节点的 example_graph,这条路径没有测试。 [backend/tests/test_publish_plan.py:79-120;frontend/src/pages/blueprints/BlueprintCards.tsx:121(按模板 cards 渲染)] +- (e) 结论:仍存在(设计文档明确留作遗留)。table_detail 只用表结构缓存和数据目录,三处调用(look、运行时 db_schema、/schema)都没传剖析结果。行数估算和日期范围只存在 catalog_profile_tasks 里:last_profiles 带 row_estimate、不带 date_ranges,last_detail 只给界面的表详情。剖析时遮罩列不进查询,但已存下的 date_ranges/findings 在之后加遮罩时不会重滤(task_detail 原样给)。剖析任务没有记快照。现在唯一进到模型的是「只有一个日期列」时业务日期提议的 note,里面带着 min/max。表助手另有一套目录渲染。 [data/schema_view.py:118-153;look/tables.py:318;tools/datasource.py:798;api/datasources.py:887;db/models.py:455-482、:507、:510;data/catalog_profile_jobs.py:278-290、:320-366、:461;data/catalog_profile.py:1185-1208;data/catalog_assistant.py:377;docs/design/catalog-profiling-jobs.md:196] +- (f) 结论:仍存在,根因已确定,是测试之间互相污染,不是产品缺陷。tasks.tick 扫全库所有待处理的待办,返回处理条数,测试却按条数断言。污染源是 test_app_instance_page::test_tasks_with_the_same_title_carry_the_instance_and_when:它留下两条 due=PT1H、超时升级的待办(expense() 图)。串行跑时,字母序在后的 test_blueprint_trial.py:160 那次 tick(+2h) 恰好把它们升级掉了;-n 8 下两者不在同一个进程,残留就漏进 test_process_tasks。我在 /tmp 拷贝里只跑这两条测试,必现 `assert 3 == 1`(test_process_tasks.py:284)。actions.tick 的按条数断言是同类隐患。 [backend/app/process/tasks.py:764-782;backend/tests/test_process_tasks.py:283-285、302、318-320;test_app_instance_page.py:41-50、278-290、261(已写成 >=1);test_blueprint_trial.py:160;test_process_actions.py:176-177、413、484;test_records.py:464] +- (g) 结论:两条都仍存在。目录页上,起草进度同时出现在页头按钮(「起草 3/20」)和页头下方的 DraftStrip(进度条+提醒);剖析只有页头入口,超时提醒只在悬停 title 里。窄屏下页头文字是 hidden sm:inline,只剩图标。设计文档 §66 定的是页头各一个进度入口,DraftStrip 是之前留下的。检查脚本断言了两处。check-catalog-loop 的 PROFILE_ON 还是旧键 max_queries / max_total_s,没有 table_max_*,这里不是有意的旧数据场景;check-catalog-profile.mjs:222 那处才是有意的。 [frontend/src/pages/catalog/CatalogPage.tsx:440-467、:592、:660-690;docs/design/catalog-profiling-jobs.md:66;scripts/check-catalog.mjs:1152-1205、1385-1397、2387-2392;scripts/check-catalog-profile.mjs:1345、1376-1379、222、231;scripts/check-catalog-loop.mjs:219] +- (h1) 结论:已解决(e10c4f2 起)。问数据页的 watch() 认 server_shutdown 日志,收到 stream.end interrupted/suspended 时先查有没有待审批再收尾。streamRun 握手连续 2 次失败后改成轮询,轮询看到落定状态(含 interrupted)就收尾。服务启动时把强杀遗留的 running 改成 interrupted,并补发 server_shutdown。check-chat 有「服务重启」一节。还有一个小口:排队中的运行在重启时直接改成 failed,不发 run.failed 事件。前端靠轮询状态能收尾,但时间线里没有原因这一行。 [frontend/src/store/chat.ts:1601-1735(1674、1713);frontend/src/api/client.ts:1082-1180;backend/app/engine/runner.py:93-115、101-106;scripts/check-chat.mjs:1296-1337] +- (h2) 结论:已解决(62aac18)。MCP 和自定义工具走信任三档,默认 ask,dangerous 模式下问人。工具节点、agent、协作节点都经过 call_policy。能力登记的 confirm/exempt 和三档进同一份发起时快照。 [backend/app/tools/trust.py:1-80;engine/nodes/tools.py:104;llm.py:400;multi.py:381;backend/tests/test_tool_trust.py:270-424] +- (h3) 结论:已解决。GET 时丢掉库里存的 limits,PUT 时跳过 limits;设置页按环境变量只读展示,有测试守着。 [backend/app/api/settings.py:808-810、:920-923;frontend/src/pages/SettingsPage.tsx:1480-1483;backend/tests/test_agent_guards.py:410-421] +- (i) 结论:部分缓解,结构性问题仍在。check-runs、check-ui 连真后端、读真数据。c6a29bf(10-14)打了两处合成补丁:没有待办时合成 2 条(对应记忆里的「沙箱待审批在页签里」「页签数一致」),画布助手的对话 GET 回成 [](对应「空态给出明确邀请」「输入框自动聚焦」)。仍要靠数据形态的有三项:「提取模板」两项(要有 wait_ms>0 的已完成探索运行,前一节没找到时页面不在运行详情上,两项一起挂)、「未保存的工作流」筛选(要有没保存就跑的运行)。另有几项数据缺了就直接记 ✗:有工作流的 401 失败、发布过两版以上、表达式写错、成功的运行。当前 main 上这几项过不过:没核实,要起开发后端、读开发数据,超出只读范围。 [scripts/check-runs.mjs:1-15、207-213、521、618、641、683-720、1600-1625、2422;scripts/check-ui.mjs:1-13、121-145;scripts/check-all.mjs:1-50(CHECK_DATA_SRC 与 --stacks)] diff --git a/docs/design/normal-user-apps.md b/docs/design/normal-user-apps.md new file mode 100644 index 00000000..139b1461 --- /dev/null +++ b/docs/design/normal-user-apps.md @@ -0,0 +1,927 @@ +# 普通用户建应用:方案 + +> 设计文档 · 草案,待拍板 · 2026-10-15 +> +> - 本文档只定方案,不含代码改动。第五部分「实施组织」在本文签字后另行审批,再开工。 +> - 代码位置按 **main@69c5c74**(应用平台合入后)标注,三路只读调研逐条核过。 +> - 承接《应用平台与助手按需查看:总方案》(`docs/design/app-platform-and-look.md`,下称「总方案」)。本文不改它,引用写作「总方案 §x.y」。 +> - 文中出现的数据源名、表名、应用名、人名都是虚构的。本文不含本机开发环境的计数。 + +## 目录 + +- 摘要 +- 第一部分 现状与问题 +- 第二部分 关键决定(N1–N11) +- 第三部分 规格 +- 第四部分 界面 +- 第五部分 实施组织 +- 第六部分 验证与验收 +- 第七部分 风险与对策 +- 第八部分 待拍板 +- 附录 A 代码位置索引 · B 术语 + +--- + +## 摘要 + +**问题。** 应用平台已经能做相当复杂的应用:多级审批、会签、时限与升级、写回外部系统、看板、按新口径补算。但能做出来的只有懂编排的人。普通人卡在这几处: + +- 必须先在画布上搭好并发布工作流,应用只能从已发布版本生成; +- 从问数据出发要跳四次:做成应用 → 提取工作流 → 去画布发布 → 回来做成应用; +- 满屏专家词:固定版本、处置、岗位绑定、视图来源、出具契约、口径卡、`cell(nodes.fetch, 0, 'gmv')`、`PT48H`; +- 发布前看不到真数,判断不了「这是不是我要的」; +- 两类最常见的需求做不了:上传文件,以及「填报登记」(每次提交存一行,再汇总着看)。 + +**做法。** 把「搭应用」改成「说需求 → 看蓝图 → 用真数试 → 确认」,用的就是项目的路线「模板定骨架、助手填关节」: + +1. **一条链路,两种终点**(N1)。说需求、蓝图、模板填关节、试运行,两类人走同一条链路,只在最后一步分开: + - 搭建者:一次确认,发布成正式应用; + - 使用者:存成**个人应用**,只自己看得见、只读、跑探索运行,想给别人用就提交给搭建者审核发布。 +2. **应用蓝图**(N2):用户的话和工作流、应用之间的中间件,用白话卡片呈现。卡片写的是看哪些数、谁发起谁审批、多久更新一次、要不要写回系统,用户逐张确认或修改。卡片的改动是确定性的操作,不是让模型重写。 +3. **场景模板**(N3):首批五个——周期看板、审批流程、异常处置、填报登记、核对写回。骨架锁住,助手只能填模板声明的「关节」:取数的 SQL、口径卡的指标式;其余关节由蓝图确定性地填。 +4. **用真数试运行**(N4):跑一次探索运行,草稿应用直接对着这次运行解析,印章写「试运行」。流程类的审批步骤派给试运行的人自己点,写入一律走测试环境或演练。 +5. **一次确认发布**(N5):服务端按「发布单」编排——保存版本、发布工作流、建应用、绑岗位、建定时任务、授权、跑第一次——每步可续做。确认前给一页白话摘要;查出的问题分成「你来定」「助手可以修」「要找管理员」三类。 +6. **在页面上直接改**(N6):应用页进入编辑状态后点哪改哪;改取数逻辑就回到蓝图,重走试运行和发布单,口径变不变用白话说清。 +7. **补两个能力缺口**: + - 记录表(N8):平台托管、只许追加,用序号钉版本,按授权分行可见; + - 文件(N9):按哈希存,以引用进运行输入,可以交给 agent 读,也可以作为一期数据导入。 +8. **白话层**(N10)和**生成的耗时与稳定性**(N11):所有检查问题有一份白话说法;补上问数据和画布两端「助手反问」的收尾。 + +**已拍板**(2026-10-14、10-15): + +- 两类人都做,共用一条链路(N1); +- 填报登记要做行级可见(N8); +- 表单只许加字段,改字段或删字段就出新的表版本,旧记录照原样保留(N8); +- 试运行只给有管理权限的人看(N4); +- 先写设计文档,签字后实施;验收结束后实施(已结束)。 + +**待拍板**:见第八部分,共 8 项,每项附推荐。 + +--- + +## 第一部分 现状与问题 + +### 1.1 现在建一个应用要走的路 + +一个从零开始的看板应用,最少要按顺序做这些事,每步各自提交: + +1. 在画布上搭工作流并保存,形成版本快照; +2. 发布工作流(受管级只有管理员能发); +3. 建定时任务——必须在第 4 步之前,否则派生出来的视图不绑定时任务(`apps/derive.py:85-90`); +4. 派生应用草稿(`POST /api/apps/derive`,不落库); +5. 建应用(`POST /api/apps`); +6. 绑岗位(`PATCH draft_bindings`); +7. 授权(`PUT grants`,立即生效); +8. 发布应用。 + +中途失败会留下半截状态:已发布但没有应用的工作流、按 cron 在跑的定时任务、停在草稿的应用。草稿应用的授权已经生效,使用者打开会得到 409「还没有发布」(`api/apps.py:83`)。这些步骤分散在画布、定时任务、应用编辑器三处。 + +### 1.2 普通人卡在哪 + +| 卡点 | 现象 | 出处 | +|---|---|---| +| 要会搭图 | 应用只能钉已发布版本 | 总方案 A1 | +| 术语 | 口径卡表达式、ISO 8601 时长、出具契约 JSON、视图来源、处置 | 端到端验收「普通用户视角」13 条 | +| 看不到真数 | 草稿预览只读钉住版本已有的封存运行,新应用没有 | `apps/resolve.py:454-476` | +| 路径长 | 问数据做成应用要跳四次 | `ChatPage.tsx:484-492` | +| 做不了 | 没有文件控件;没有平台托管的可写表 | `apps/spec.py:35-38`;`data/table_versions.py:1-33` | + +### 1.3 现有零件(直接复用) + +- **生成管线**:`Generation` 加 `Applier`/`Checker` 协议,新的生成目标插进来就能用(`api/copilot_gen.py:567`,`look/base.py:87-110`)。 +- **按需查看**:表、能力、工作流版本三类对象(`look/service.py`)。 +- **护栏**: + - 润色边界 `polish_problem`(`apps/ops.py:264`); + - 自动修复的 `forbidden_changes`(`engine/autofix.py:1240`)。 + + 两者都是「只许改哪些」的确定性护栏,模板关节照这个写法。 +- **确定性派生**:`derive_app`(`apps/derive.py`)。 +- **从运行提取工作流**:`/copilot/from-run`,带来源记录 `workflows.origin`(`api/copilot.py:2468-2550`)。 +- **数据目录**:表的中文名、粒度、业务日期、列的含义和度量类型(`data/catalog.py:12-21`),是把白话对到表的依据。 +- **按运行解析**:视图来源 `instance` 已经能按给定的那次运行解析,不查类别和哈希(`apps/resolve.py:541-546`)。 +- **所有运行都封存**,探索运行也一样(`engine/runner.py:1013-1045`);探索运行的写入走测试环境或演练(`capabilities/endpoints.py:101`)。 +- **问数据对使用者的约束**(`auth/chat_policy.py`): + - 节点和工具的白名单; + - 强加 `MASK_RESULTS`、`READ_ONLY`(`api/runs.py:108-123`); + - 记忆隔离。 +- **文件相关**: + - 上传限额 `read_upload`(`api/datasources.py:905-918`); + - 按哈希存原件 `raw_store`(`data/raw_store.py:33-43`); + - 按配方导入「上传新一期」(`api/table_imports.py:160-486`)。 +- **表版本链**:快照的版本链,以及「改列不兼容」的判定 `INCOMPATIBLE_KINDS`(`data/recipe_accumulate.py:38-41`)。 +- **数据版本**:data_versions 进封存范围和印章(`engine/runner.py:242-262`,`apps/resolve.py:238-240`)。 +- **名录**:`auth/directory.py`,按登录方式核对人和组。 +- **白话分类的依据**: + - 检查问题已经有 `code`; + - 自动修复有 `kind: auto|choice|assist`(`engine/autofix.py:125-141`); + - 只能由人处理的有 `APP_HUMAN_ONLY`(`api/copilot_gen.py:416-421`)。 + +### 1.4 核实过的缺口 + +| # | 缺口 | 出处 | +|---|---|---| +| G1 | 没有带参数的模板,也没有「模板里哪些地方能改」的机制;GraphSpec 只有 nodes/edges/viewport/defaults;「以此模板新建」只是复制 | `engine/schema.py:115`;`WorkflowPicker.tsx:354`;`api/workflows.py:249` | +| G2 | 应用视图只认正式运行;印章没有「探索」「试运行」 | `apps/resolve.py:95,149,203-221` | +| G3 | 探索运行没有版本号和哈希,三种视图来源都取不到它 | `engine/launch.py:166-174` | +| G4 | 跨步没有事务:工作流发布撤不回(只能退役),应用只能归档、不能删 | `api/apps.py:273` | +| G5 | 检查问题只有技术说法,没有白话 | `engine/governance.py:217-271` | +| G6 | 只问不改的一轮:问数据的 store 没有 ask 分支,可能一直停在「进行中」、问题不进历史;画布也不记这一轮 | `store/chat.ts:1433`;`store/studio.ts:2149` | +| G7 | 使用者不能建应用;应用视图、印章、读权限都只认正式运行 | `api/apps.py:191`;`auth/access.py:74-96` | +| G8 | 工件库只存 JSON;运行输入不能引用文件;入口提交是 JSON | `core/artifact_store.py:45-115`;`engine/nodes/io.py:14-33` | +| G9 | 平台托管的表都是不可变快照,只能整期替换或按期累积;没有逐行追加,也没有行级可见 | `data/table_versions.py:1-33`;`engine/masking.py` | +| G10 | 没有按名字检索的口径库;复用口径只能逐级下拉选 caliber_from | `engine/schema.py:1460`;`Inspector.tsx:1065` | +| G11 | 组件没有「隐藏」字段,只能删掉或写可见性表达式;改动要先保存才看得到 | `apps/spec.py:97-107`;`apps/resolve.py:632` | +| G12 | 生成没有并行、没有提示缓存;问数据校验不过时前端整轮重来 | `look/service.py:192`;`store/chat.ts:1241` | + +### 1.5 理念对照 + +本方案不加新理念,把总方案的八条用到「创建」上: + +- **P1 照实说**:试运行、探索、个人应用的数都在印章上写明来历;检查问题用白话说清是什么、为什么、谁来改。 +- **P2 留痕**: + - 蓝图每一次改动都留痕; + - 发布单每一步的结果都留痕; + - 生成的工作流记下出自哪个蓝图、哪个模板。 +- **P3 不挡生成**:模型失败时,模板照样能按蓝图确定性地实例化,缺的关节标「待填」交给人。 +- **P4 只读与遮罩**: + - 个人应用只读、结果遮罩; + - 记录表按授权分行可见; + - 文件只给读得到那次运行或那一行的人。 +- **P5 一种说法**:白话层只有一份,按问题编号映射。 +- **P6 模板定骨架**:模板的节点和连线锁死,助手只填声明过的关节。 +- **P7 界面层不放逻辑**:卡片的修改是对蓝图的确定性操作;页面上直接改走的仍是应用操作。 +- **P8 生效可查可对账**:记录表的写入走动作(生效记录、幂等键);文件的哈希进封存。 + +不可回退决定的对照见 §2.12。 + +--- + +## 第二部分 关键决定 + +每个决定写四样:决定、理由、否决的做法、代价。 + +### 2.1 N1 一条链路,两种终点 + +**决定** + +链路是:说需求 → 蓝图 → 选模板并实例化 → 试运行 → 终点。 + +- **起点三个**,都落到同一个蓝图: + 1. **说说你要什么**:开一段蓝图对话,助手先问清楚,再整理成卡片; + 2. **从场景模板开始**:选一个模板,卡片按模板预置; + 3. **从已有的开始**:从问数据的一轮回答(运行)开始,或者从一个已发布的工作流版本开始。前者用 from-run 提取,后者直接导入图,模板记为「自由形」。 +- **终点两种**: + - **搭建者**:发布单(N5)一次确认,产出已发布的工作流、正式应用、定时任务、授权; + - **使用者**:存成个人应用(N7),自己用。想给别人用就「提交审核」,搭建者打开同一个蓝图,走试运行和发布单。 + +**理由**:一条链路只维护一份,「问 → 建」的沉淀路径是现成的(P2)。使用者沉淀出来的东西不绕过发布门禁:个人应用永远是探索,正式应用只能由搭建者发布(不可回退决定:正式运行只从已发布版本发起)。 + +**否决的做法** + +- 给使用者一个简化版画布:等于让使用者搭图,权限和门禁都要另开口子; +- 使用者的应用也走正式发布:让 user 角色产出承诺,违背角色分层。 + +**代价**:个人应用和正式应用是两种对象状态,界面要说清区别。 + +### 2.2 N2 应用蓝图 `agentlab.blueprint/1` + +**决定** + +- **定位**:蓝图是用户的话和工作流、应用之间的结构化中间件,存成一个可变对象 `Blueprint`。它有: + - 拥有者、可见范围(个人 / 共享); + - 状态:起草中 / 试运行过 / 已发布 / 个人应用 / 审核中; + - 来源:对话、模板、运行、工作流版本。 +- **一张蓝图由几张「卡片」组成**,每张卡片对应用户能理解的一件事: + +| 卡片 | 内容 | 落到哪里 | +|---|---|---| +| 目的 | 一句话:这个应用解决什么问题、给谁用 | 应用名称、说明 | +| 场景 | 选定的模板(N3) | 骨架 | +| 看哪些数 | 每一项:白话名、对到哪个库哪张表(或复用哪张已发布的口径卡)、粒度、业务日期、筛选条件、单位 | SQL 关节、口径卡关节 | +| 多久更新 | 每天、每周一、每月 1 日……时区;统计哪一期(本周、上周、上月) | 定时任务、入口字段的模板值 | +| 谁来做 | 发起人、各步处理人(人、组、岗位)、几个人同意算通过、时限、超时给谁 | 人工节点配置、岗位绑定 | +| 填什么 | 表单字段:名称、类型、必填、上下限、选项、文件 | 入口表单、待办表单、记录表的列 | +| 写回哪里 | 目标能力(从能力登记里选)、字段对应、要不要人确认;能力没登记查询的,再定结果不明时谁去核实(只由人定,同「谁能看」) | 调用工具节点、应用的核实岗位 | +| 谁能看 | 使用、查看全部、管理三档的人和组 | 授权 | + +- **每一项都带状态**,照数据目录项的写法 `{value, source, status}`: + - `source`:用户填的 / 助手提的 / 模板默认 / 从目录或口径卡对到的; + - `status`:已确认 / 待确认 / 缺。 + + 试运行前,所有「缺」都要补齐;「待确认」可以先试运行,但发布时必须确认。 +- **助手只提议**:输出 `bp_*` 操作(`bp_set`、`bp_add`、`bp_remove`、`bp_ask`),由 `BlueprintApplier` 确定性落地。用户在卡片上改,走的也是同一组操作,只是 `source` 记成用户。 +- **对到数据靠 look**:助手看「看哪些数」时,先查数据目录(表的中文名、粒度、业务日期、列的度量类型),再查已发布工作流里的口径卡(N2 新增 look 类别 `caliber`,补 G10)。对到已有口径卡时优先复用(caliber_from),不重写指标式。 +- **反问**:缺的信息由助手用 `bp_ask` 问,一次问一件事、给出候选(例如「销售额按下单日还是支付日算?」并附两个选项)。这一轮就此结束,回答是下一轮(G6 一并修好,N11)。 + +**理由** + +- 卡片是用户能判断对错的颗粒度(P1); +- 模型产出的是提议,落地是确定性的(P7); +- 每一项都有来源和状态,不会把模型猜的当成用户说的(P2)。 + +**否决的做法** + +- 让模型直接生成工作流再给用户看图:普通人看不懂图; +- 一问一答的长表单向导:问题顺序固定,答不上来就卡住; +- 蓝图只存在前端:刷新就丢,也没法交给搭建者审核。 + +**代价**:多了一种对象和一套操作;模板变了,蓝图的卡片要跟着兼容(蓝图记下模板版本)。 + +### 2.3 N3 场景模板 `agentlab.template/1`:骨架锁住,关节可填 + +**决定** + +- **模板是一份登记**,和组件目录同一种做法(总方案 A2): + - 后端 `app/apps/templates/`,每个模板一个模块; + - 开发者扩展时,模板定义、实例化函数、测试三样都要给,缺一样启动即报错。 +- **一个模板包含**: + - **骨架**:带占位的图(节点和连线固定); + - **关节**:骨架里允许填的位置,每个都有类型、来源、校验; + - **应用骨架**:页面布局,用派生规则加模板自带的组件排布; + - **卡片映射**:蓝图的哪张卡片填哪些关节; + - **说明和适用条件**:给用户和助手看。 +- **关节分两种**: + - **确定性关节**:由蓝图直接填,不经模型。包括: + - 表单字段 → 入口节点 fields; + - 处理人和策略 → 人工节点 assignee/policy/due/on_timeout; + - 金额线这类规则 → 分支节点的比较条件,只许「字段 比较 常量」; + - 更新频率 → 定时任务; + - 写回目标 → 调用工具节点的 tool 和参数映射。 + - **生成关节**:只有两类交给模型—— + - 取数的 SQL(`sql`); + - 口径卡的指标式(`metric_expr`,只许 `cell()` 取格加口径卡的算术)。 + + 模型在 `TemplateApplier` 下工作,只收 `fill_joint` 操作,而且只收这个模板声明过的关节 id,别的一律拒绝并照实交回(照 `polish_problem` 的写法)。 +- **护栏**:实例化后的图和骨架逐节点比对,只许关节位置不同,用 `forbidden_changes` 的同一套比对。之后照常走 validate、GraphChecker(SQL 猜列、数据源范围)和发布前检查。 +- **来源记录**:生成的工作流写 `workflows.origin = {blueprint_id, template: "@", run_id?}`,补上「只写不读」的现状:WorkflowOut 和版本历史显示「出自蓝图 X、模板 Y」。 +- **首批五个**(详见 §3.3):周期看板、审批流程、异常处置、填报登记、核对写回。外加「自由形」:从已有的运行或工作流导入,没有关节,改图只能回画布。 +- **模型失败时**(P3):生成关节留空、标「待填」,蓝图卡片上提示「这一项助手没填上,请补充或换个说法」;确定性关节照样落地。 + +**理由** + +- 模板定骨架、助手填关节,正是项目路线; +- 关节越少,生成越快、越稳(N11),检查也越有效; +- 锁骨架就锁住了治理关注的结构:出具契约、人工节点、写入位置。 + +**否决的做法** + +- 模板只是一张样例图,复制了让助手随便改:等于自由生成,护栏全靠事后检查; +- 用模板引擎在 JSON 上做字符串替换:类型和校验都丢了; +- 一个模板覆盖所有场景、用大量开关:组合爆炸,测不过来。 + +**代价**:每个模板要写实例化和测试;用户的需求落在模板之外时,只能走「自由形」,或者由搭建者新增模板。 + +### 2.4 N4 用真数试运行 + +**决定** + +- **试运行**是一次探索运行,跑的是蓝图实例化出来的、还没发布的图,来源记为 `origin_kind=trial`、`origin={blueprint_id}`。 +- **草稿应用对着这次运行解析**: + - 新增解析模式 `resolve_page(draft=True, trial=run)`; + - 草稿里所有视图来源(最近封存、按需、本实例)都指向这次运行; + - 钉住项记成 `{workflow_id, version: null, graph_hash: <这次运行的图哈希>}`。 + + 沿用 `instance` 分支「按给定运行解析」的路径(`apps/resolve.py:541-546`)。不改 `agentlab.app/1` 的视图来源种类,试运行只是解析时的旁路参数。 +- **印章**: + - 来源写「试运行」; + - 注明「数据是真的;这不是正式结果,发布后才会按定时任务出数」; + - 出具档位照实算;没封存的照实写。 + + 趋势只有一个点时写「试运行只有一次,没有趋势」,不报错(补 `_series` 拿不到钉住项就报错的问题,`apps/resolve.py:813`)。 +- **输入**: + - 看板类:按「多久更新」卡片算出最近一期(例如上一 ISO 周),用户可以改; + - 流程类:由用户填一份示例表单。 +- **流程类的试运行**: + - 人工步骤派给试运行的人自己(来源记为 `trial`),按钮写「以试运行身份同意/驳回」; + - 写入一律走测试环境;没有测试环境的演练,不发送; + - 时限按真实设置显示,但试运行不触发升级——调度跳过 `origin_kind=trial` 的待办。 +- **谁能看**:只有对这个蓝图有管理权的人(拍板项):搭建者看自己的、有 manage 的看共享蓝图的;个人应用的试运行只有拥有者看。试运行是探索运行,遮罩按看的人的角色照常处理。 +- **发布时对齐**:发布单按图哈希判断「发布的版本就是试运行过的那张图」: + - 一致:钉住项直接换成版本,节点 id 一一对上; + - 不一致(试运行之后又改过):摘要里写明「发布的内容和最近一次试运行不同」,建议再试一次,不强拦。 + +**理由**:先看真数再决定(P1);不发布就不产生正式运行(不可回退决定);写入不出测试环境(总方案 A5)。 + +**否决的做法** + +- 先发布一个隐藏版本再试:版本表里满是试验版本,退役和口径升版的判断都被污染; +- 用假数据预览:普通人恰恰要看真数才判断得了。 + +**代价**:试运行要花一次模型或查询的钱;流程类的试运行要人点一遍。 + +### 2.5 N5 一次确认发布:发布单 + +**决定** + +- **发布单**(`PublishPlan`)是服务端的编排对象:先**算出**要做的每一步和白话摘要,用户确认后**按序执行**,每步的结果记下来;失败了停在那一步,修好后从那一步**接着做**。 +- **步骤**(顺序就是为了让中途失败时留下的状态无害): + 1. **保存版本**:按图哈希判断,已有相同的版本就复用; + 2. **全量预检**(只读):工作流发布前检查 + 按将要生成的版本跑应用检查 + 定时任务预览 + 名录核对。任何 error 都在这一步停下,什么都没写; + 3. **发布工作流**(受管级只有管理员); + 4. **建定时任务**(停用状态),这样派生时视图能绑上它; + 5. **建应用、派生、写岗位绑定**; + 6. **发布应用**:钉住项升版时按蓝图里的处置;第一次发布没有处置; + 7. **启用定时任务**; + 8. **授权**:最后一步,避免使用者先看到一个打不开的应用; + 9. **跑第一次**(看板类默认勾选):立即运行一次,应用一打开就有数。 +- **白话摘要**:每一步一句「会发生什么」,例如: + - 「发布『门店周报』:此后每周一 09:00 按这一版自动出数」; + - 「张三、李四可以使用;财务组的人负责审批」; + - 「口径变了:以前的周报按新口径重算(recompute),旧数据留底」。 +- **问题分三类**,按问题编号走白话层(N10): + - **你来定**:岗位没绑人、处置没选; + - **助手可以修**:autofix 的 auto/assist、应用检查里不在 `APP_HUMAN_ONLY` 的; + - **要找管理员**:受管级、降级、内网端点、能力治理。 +- **补偿**: + - 第 3 步之后的失败不撤回已发布的工作流(发布本来就撤不回),摘要照实说「工作流已发布,应用还没有」; + - 停用的定时任务在应用发布前不会跑,不产生无主的正式运行; + - 应用停在草稿时不授权,使用者看不到。 +- **权限**:执行发布单的人要有其中每一步的权限,预检时就按角色算好(例如搭建者发不了受管级,摘要里直接说)。 + +**理由**:一次确认、照实摘要(P1);每一步留痕(P2);中途失败的状态无害、可续做。 + +**否决的做法** + +- 一个大事务把八步包起来:跨定时任务、应用、工作流的事务在 SQLite 单进程下做得到,但出错后整体回滚会吞掉已经通过的检查结果,用户得从头来;而且发布动作本身就带审计,不应该回滚; +- 前端按顺序调现有接口:刷新页面就丢进度,权限和补偿都在客户端,不可靠。 + +**代价**:多一个对象和一张表;现有各接口的校验要能被发布单按「只检查不提交」的方式调用。 + +### 2.6 N6 在页面上直接改 + +**决定** + +- **改呈现**: + - 有 manage 的人在应用页点「编辑」进入编辑状态,ViewRenderer 叠一层编辑层:按 `data-node` 选中组件(`apps/ViewRenderer.tsx:75-84`),就地改文字、隐藏、上下挪、删除。 + - 落地走现有的应用操作 `app_update`、`app_move`、`app_remove`,自动存草稿,然后按草稿重新解析。 +- **加「隐藏」**:组件加 `hidden: true` 字段(补 G11),解析时跳过、编辑状态下照常画成半透明。这和可见性表达式不是一回事:可见性是按运行时条件显示,隐藏是作者不要它。 +- **加一张数据卡片**:候选从钉住版本里列,用同一份派生规则 `_results`(口径卡指标、报告、取数节点、趋势),新接口 `GET /api/apps/{id}/candidates`。用户挑一个,就落成一个 `app_add`。 +- **改取数逻辑**(「销售额要去掉退款」): + - 不在页面上改,回到蓝图:改「看哪些数」卡片 → 重新实例化 → 试运行 → 发布单; + - 发布单在摘要里用白话说清口径变了、以前的数怎么处理(处置),沿用总方案的口径升版规则。 +- **改完要发布**:呈现的改动只进草稿,用户点「发布」走应用发布(不动工作流),沿用现有规则。 + +**理由**:普通人改看得见的东西最直接(P1);改取数会动口径,必须经过试运行和处置(不可回退决定:口径升版须处置)。 + +**否决的做法** + +- 页面编辑器允许拖拽任意布局:像素级设计不是目标(总方案 §1.3); +- 页面上直接改 SQL:绕过试运行和处置。 + +**代价**:编辑状态和查看状态要共用一套渲染,交互细节要打磨。 + +### 2.7 N7 个人应用 + +**决定** + +- **对象**: + - 个人应用是 `scope=personal` 的 App,拥有者是创建它的使用者; + - 它的图不进工作流表(使用者没有工作流权限),存在蓝图上,带图哈希; + - 没有应用版本,改了就是改了,每次刷新按当前的图跑。 +- **能用的东西**,与问数据同一份白名单(`auth/chat_policy.py`): + - 节点:input、llm、agent、report、output、retrieve、transform、branch、metrics、merge; + - 工具:只读内置工具和只读数据源查询; + - 运行时强加 `MASK_RESULTS`、`READ_ONLY`; + - 不能有人工节点、不能写入、不能调写入能力。 + + 能用的模板因此只有:周期看板、异常处置的「只看」部分、填报登记的「只看自己的」汇总。 +- **刷新**: + - 打开时用最近一次成功的刷新; + - 点「刷新」发起一次探索运行(`origin_kind=personal_app`),身份是拥有者; + - 受每日上限约束,和按需视图共用设置项 `apps.on_demand_daily_limit`,按人计。 + - 是否允许每天自动刷新,见待拍板 Q4。 +- **视图来源**:新增 `exploratory`,只在个人应用里合法——取这个应用、这个拥有者、这张图哈希下最近一次成功封存的探索运行。印章来源写「探索 · 未经发布审核」,档位照实写。 +- **谁看得见**:只有拥有者。管理员在设置里看得到「谁有几个个人应用」,可以归档,但不看内容(P4)。 +- **提交审核**: + - 拥有者点「提交审核」,选一位搭建者,或者交给搭建者组; + - 开一条待办(种类 `review`,派给对方); + - 搭建者打开时得到这个蓝图的一份**共享副本**(原个人应用不动),在副本上走试运行和发布单; + - 发布后通知提交人,提交人自己决定个人应用留不留。 +- **开关**:管理员设置 `apps.user_personal_apps`,默认关(同问数据的开关,待拍板 Q3)。 + +**理由**:使用者能自助(产品目标);个人应用永远是探索、只读、遮罩(P4,不可回退决定:没发布的图只能跑探索);共享必须经搭建者发布(承诺由搭建者签)。 + +**否决的做法** + +- 给使用者开「个人工作流」:工作流表的权限、门禁、定时任务全要另开一套; +- 个人应用允许分享给别人:等于绕过发布门禁。 + +**代价**:视图来源多一种、印章多一种;个人应用的图和工作流的图是两种存法。 + +### 2.8 N8 记录表(填报登记) + +**决定** + +- **存储**:每个记录表一个平台托管的 SQLite 文件(`data/records/<表 id>.db`),只许追加。 + - 每行带系统列:`_seq`(自增序号)、`_id`、`_submitted_by`、`_submitted_at`、`_run_id`(哪次运行写的)、`_supersedes`(更正时指向被更正的行)、`_schema_version`。 + - 业务列由「填什么」卡片生成。 +- **作为数据源**:登记成 `origin=records` 的只读数据源,看板照常用 `db_query__<名字>` 查,数据目录照常起草。 +- **写入只经动作**: + - 新增平台内置能力 `records.append`(效果:写入;允许无人值守,因为平台自己的表、幂等键就是动作键); + - 填报登记模板的骨架是:入口 → [可选的审批] → 调用工具节点 `records.append` → 成果; + - 生效记录、幂等、审计全部沿用总方案 A5,一次提交只落一行,重放不会重复写。 +- **更正不改原行**:更正是一次新的提交,带 `_supersedes`。默认查询看「当前有效」的行(没被更正过的),更正历史可查。 +- **钉版本**: + - 运行开始时,在 data_versions 里记下这张表的高水位 `_seq`; + - 查询时连接上建同名的 **TEMP VIEW**(SQLite 解析不带库名的表名时,先找 temp 库),只露出 `_seq ≤ 高水位` 的行。 + + 于是同一次运行查几遍都是同一份数,印章写「数据截至第 N 条」,按需缓存键也带上它(P8)。 +- **行级可见**: + - 同一套临时视图再加一条按人过滤:`use` 只看 `_submitted_by = 自己`,`observe` 和 `manage` 看全部(拍板项); + - 过滤在查询工具的连接上做,模型和 SQL 都绕不过去。 + - **看板的汇总**:看板读的是正式运行的封存结果,正式运行以服务身份跑、看到全部行,所以汇总是全量的。这是作者的选择,发布单摘要里写明「使用者能看到全部提交的汇总数」。 + - **逐行列表**:用新组件 `records_list`,每次打开按看的人实时过滤(同 `instance_list`),不走封存。 +- **表单变了**: + - 只加可选字段:`ALTER TABLE ADD COLUMN`,同一表版本; + - 改类型、删字段、加必填字段:出新的表版本(新文件 `<表 id>.v2.db`),旧记录照原样保留。不兼容的判定沿用 `INCOMPATIBLE_KINDS`。 + - 应用版本钉住表版本;看板要跨版本汇总时,由模板生成 `UNION ALL` 的视图,列对不上的补空。 +- **遮罩**:业务列可以在「填什么」卡片里标「敏感」,进数据源的遮罩列表(`mask_columns`)。 + +**理由**:只追加加序号,在单进程 SQLite 下就能做到可复现(P8,不可回退决定:不上 Postgres、不加节点锁);写入走动作,审批和对账都是现成的;行级可见在连接层做,模型和 SQL 都绕不过去(P4)。 + +**否决的做法** + +- 每次提交出一个新快照(复用 table_versions):一次提交一个文件,量一大就扛不住; +- 允许原地改行:数就不可复现了; +- 行级可见交给模型在 SQL 里加 where:靠不住。 + +**代价**: +- 多一种数据源和一个写入服务;写入走单进程锁,和导入的锁分开; +- 高频填报(每秒多次)不是目标场景,写在 README 里。 + +### 2.9 N9 文件 + +**决定** + +- **存储**: + - 按 sha256 内容寻址存,复用 `raw_store` 的做法:`data/files/<前 2 位>/`,文件只读(0444); + - 新表 `files` 记 id、sha256、原名、大小、类型、上传人、上传时间、绑定的运行或记录(可空)、用途; + - 大小限额沿用 `max_upload_mb`;类型白名单见待拍板 Q5。 +- **上传两步走**: + 1. `POST /api/files` 带上下文(应用 id 加入口,或者待办 id),先上传,拿回引用 `{file_id, name, size, sha256, type}`; + 2. 再提交表单。表单字段 `type: object`、`x-widget: file`,值就是引用。 +- **提交时绑定**:服务端核对引用属于提交人、还没被别的提交用过,然后把它绑到这次运行(一次性),引用连同 sha256 进运行输入,随封存一起固定(P8)。没绑定的上传 24 小时后清理。 +- **怎么用文件**: + - **交给 agent 或报告读**:新增模板函数 `file_text(input.附件)`,运行时按类型抽文本(PDF、Word、表格、纯文本),沿用知识库上传的抽取,有长度上限,超出照实截断并标出; + - **作为一期数据导入**:平台内置能力 `table_import.new_period(source, file)`,按这个上传源已有的配方导入,效果是写入(数据变了),走动作和确认; + - **附在记录上**:记录表的文件列存引用。 +- **下载**:`GET /api/files/{id}`,只给读得到绑定的那次运行、或那一行记录的人(复用 `can_read_run` 和记录的行级可见)。文件不做遮罩,界面在上传处写明「能看到这条记录的人都能下载附件」。 +- **使用者**:个人应用里可以上传文件交给 agent 读(只读),不能导入。 + +**理由**:内容寻址加封存,证据站得住(P8);上传和提交分开,表单仍是 JSON,人工节点的表单也能用;读权限跟运行走,没有新的权限面(P4)。 + +**否决的做法** + +- 文件放进工件库:工件库只存 JSON,按运行归属,上传时还没有运行; +- base64 塞进表单值:撑爆输入和事件流。 + +**代价**:多一张表、一个清理任务;抽文本的质量受文件格式影响,照实标「抽取不完整」。 + +### 2.10 N10 白话层 + +**决定** + +- 新增 `app/plain.py`:问题编号 → `{title 一句话, why 为什么要紧, who: you|assistant|admin, action 怎么办}`。覆盖: + - 工作流发布门禁(`contract.*`、`governed.*`、`action.*`、`process.*`); + - 应用检查(`app.*`); + - 发布单的步骤失败。 +- 没登记白话的问题照旧显示技术说法,并在检查脚本里报「有问题编号没有白话」,防止遗漏。 +- 前端的发布摘要、蓝图卡片、试运行页都用白话;画布和应用编辑器保留技术说法,另加一行白话。 +- `who` 的判定照这三样:自动修复的 `kind`、`APP_HUMAN_ONLY`、需要管理员的问题编号。 + +**理由**:P5 一种说法;P1 照实说给听得懂的人。 + +**否决的做法**:让模型临场解释——每次说法不同,还可能说错。 + +**代价**:每加一个问题编号都要写白话,检查脚本会提醒。 + +### 2.11 N11 生成:耗时与稳定性 + +**决定** + +- **调用次数的预算**: + - 起草蓝图:1 次主调用,加最多 2 次查看; + - 填关节:每一组独立的关节(例如两组互不相干的指标)各 1 次调用,**并行**执行(新增,补 G12),各自最多 1 次查看; + - 其余全部是确定性的。 +- **模型**:蓝图和关节都用设置里的助手模型;首次使用时提示「推荐用快速模型」。验收按真实模型记录每一步的耗时(第六部分 W4)。 +- **反问收尾**(补 G6): + - 问数据:store 加 ask 分支——这一轮记成「助手反问」,问题进历史;输入框提示「回答助手的问题」; + - 画布:这一轮照样记下,问题进历史; + - 蓝图对话:问题和候选画成可点的选项。 +- **问数据校验不过时**:不再整轮重来,改为把问题交回同一轮的自查修正(Checker 已有),省一次完整生成。 + +**理由**:普通人等不了几分钟(验收里有模型在画布写 SQL 首个操作要 486 秒);反问是蓝图的核心交互,不能丢。 + +**否决的做法**:一次调用生成整张图加应用——慢、不稳、难修。 + +**代价**:并行调用增加瞬时并发,受模型服务的限流约束,失败时退回串行。 + +### 2.12 和不可回退决定的对照 + +| 不可回退决定 | 本方案 | +|---|---| +| 正式运行只从已发布的不可变版本、按 graph_hash 发起 | 试运行和个人应用都是探索运行;发布单发布后才有正式运行;按图哈希对齐试运行和版本 | +| 口径升版必须处置 | 改取数回到蓝图,发布单要求处置,摘要用白话说明 | +| 叙述层不做算术 | 关节里的指标式只许 `cell()` 加口径卡算术;卡片上的数来自绑定 | +| 没发布的图只能跑探索 | 个人应用永远是探索;印章写明 | +| 不上 Postgres、不加节点锁 | 记录表单文件 SQLite、只追加、单进程锁;临时视图钉版本 | + +--- +## 第三部分 规格 + +### 3.1 数据模型 + +**新表** + +| 表 | 主要字段 | +|---|---| +| `blueprints` | id、name、owner、scope(personal / shared)、status(drafting / trialed / published / personal / in_review)、spec(`agentlab.blueprint/1`)、template_id、template_version、source(conversation / template / run / workflow_version,带 id)、workflow_id、app_id、last_trial_run_id、graph(个人应用的图)、graph_hash、copied_from(审核副本指向原蓝图)、created_at、updated_at | +| `blueprint_events` | blueprint_id、seq、actor、verified、op(`bp_*`)、before、after、at——蓝图的留痕 | +| `publish_plans` | id、blueprint_id、actor、steps(每步 kind、status、result、error_code、at)、summary(白话摘要)、level、status(planned / running / stopped / done)、created_at | +| `files` | id、sha256、name、size、mime、uploaded_by、uploaded_at、context(app_id、entry 或 task_id)、bound_run_id、bound_record(表 id、`_id`)、purpose | +| `records_tables` | id、app_id、name、source_id(登记的数据源)、version(表版本)、columns(列定义 + 敏感标记)、created_at;表版本另起一行,旧版本只读保留 | + +**新列**(走 `_COLUMN_MIGRATIONS`) + +- `apps.scope`(默认 shared)、`apps.owner`、`apps.blueprint_id`; +- `runs.origin_kind` 新增取值 `trial`、`personal_app`(总方案 X3 的列表加两项,仍只能由服务端写); +- `tasks.kind` 新增 `review`(个人应用提交审核); +- 组件 spec 加 `hidden`(`apps/spec.py` 的组件模型,`extra=forbid` 下显式加字段)。 + +### 3.2 蓝图 `agentlab.blueprint/1` + +```json +{ + "format": "agentlab.blueprint/1", + "purpose": {"value": "每周看各门店销售额、订单数和退款率", "source": "user", "status": "confirmed"}, + "template": {"id": "periodic_board", "version": 1}, + "data": [ + {"id": "gmv", "label": {"value": "销售额"}, "unit": {"value": "元"}, + "table": {"value": {"source": "shop", "table": "orders"}, "source": "catalog", "status": "proposed"}, + "measure": {"value": "sum(amount)", "source": "assistant", "status": "proposed"}, + "date": {"value": {"column": "paid_at", "rule": "按支付日"}, "source": "catalog", "status": "confirmed"}, + "filter": {"value": "不含已退款", "source": "user", "status": "confirmed"}, + "reuse": null} + ], + "cadence": {"value": {"preset": "weekly", "weekday": 1, "time": "09:00", "tz": "Asia/Shanghai", "period": "last_iso_week"}}, + "people": [], "fields": [], "writeback": null, + "access": {"use": [{"kind": "group", "name": "门店"}], "observe": [], "manage": []}, + "joints": {"sql_main": {"status": "filled", "by": "assistant"}, "metric_gmv": {"status": "pending"}} +} +``` + +- 每一项都是 `{value, source, status}`;`source` 取 user / assistant / template / catalog / caliber;`status` 取 confirmed / proposed / missing。 +- `measure` 是白话或简单聚合的提议,**不是**最终表达式;最终的 SQL 和指标式在关节里,由 `TemplateApplier` 落地并经过检查。 +- `reuse`:对到已发布口径卡时写 `{workflow_id, version, node_id, metric_id}`,实例化成 `caliber_from`。 + +**操作**:`bp_set(path, value)`、`bp_add(path, item)`、`bp_remove(path)`、`bp_ask(question, options[])`、`bp_template(id)`。路径用 JSON 指针。模型和用户共用这组操作;`BlueprintApplier` 按卡片的白名单校验(比如 `people[].policy.kind` 只能是 any / all / quorum)。 + +### 3.3 模板 `agentlab.template/1` 与首批五个 + +**模板定义**(后端模块): + +```text +id, version, label, description, applies_when(给助手看的适用条件) +cards: 用到哪些卡片、各自的约束 +skeleton(bp) -> GraphSpec # 带关节占位 +joints: [{id, kind, node_id, field, from_card | generated, check}] +app_skeleton(bp, graph) -> agentlab.app/1 # 在 derive_app 的结果上按模板排布 +schedule(bp) -> 定时任务参数 | None +tests: 每个模板至少一张完整蓝图的实例化 + 检查 + 运行 +``` + +**首批五个** + +| 模板 | 骨架 | 生成关节 | 确定性关节 | 应用页 | +|---|---|---|---|---| +| 周期看板 `periodic_board` | 入口(统计期)→ 取数(tool)→ 口径卡 → 成果(指标出具) | SQL、指标式 | 统计期字段、定时任务、出具契约的必需指标 | 印章、指标块、趋势、明细表、「按条件查看」(按需) | +| 审批流程 `approval` | 入口(表单)→ [按金额线分支] → 人工(一级或多级)→ [写回] → 已通过 / 已退回 | 无 | 表单、金额线、处理人与策略、时限与升级、写回目标 | 发起按钮、我发起的、实例进度、待办 | +| 异常处置 `exception_handling` | 定时:入口 → 取数(异常清单)→ 分支(有 / 无)→ 人工(负责人填处置说明,表格附在待办上)→ [复核] → 成果 | 异常的 SQL | 负责人、时限、复核策略 | 异常数指标、本期异常表、处置进度 | +| 填报登记 `records` | 入口(表单)→ [审批] → `records.append` → 成果;另配一张汇总看板(周期看板的骨架,查记录表) | 汇总的 SQL、指标式 | 表单与表结构、审批、行级可见 | 填报按钮、我的记录(`records_list`)、汇总看板 | +| 核对写回 `reconcile_writeback` | 定时:取数 → 口径卡(核对差异)→ 人工复核(确认要写回的)→ 写回 → 成果 | 取数的 SQL、差异的指标式 | 复核人、写回目标与字段对应、确认 | 差异指标、待复核清单、写回记录 | + +- 每个模板都能被个人应用用到的部分(只读)标出来:周期看板全部、异常处置去掉人工步骤只看清单、填报登记只看汇总和「我的记录」。 +- 「自由形」:从已有运行或工作流版本导入,没有关节,蓝图只有目的、谁能看、多久更新三张卡片;要改图只能回画布(搭建者)。 + +### 3.4 关节类型 + +| kind | 填什么 | 谁填 | 检查 | +|---|---|---|---| +| `sql` | 调用工具节点的 `args.sql` | 模型(look 表) | 只读守卫、SQL 猜列、数据源范围、目录检查(GraphChecker) | +| `metric_expr` | 口径卡一个指标的 `expression` | 模型 | 只许 `cell()`、口径卡算术、比较;静态追到原始数据(`engine/lineage.static_stops`) | +| `fields` | 入口或人工节点的表单 | 蓝图「填什么」 | `agentlab.form/1` 校验 | +| `assignee` / `policy` / `due` / `on_timeout` | 人工节点配置 | 蓝图「谁来做」 | 流程检查(`engine/lint_process.py`)、岗位派生 | +| `threshold` | 分支条件「字段 比较 常量」 | 蓝图 | 表达式解析器,禁止算术 | +| `capability` | 调用工具节点的 tool 和参数映射 | 蓝图「写回哪里」 | 能力登记、写入只在调用工具节点(总方案 A5)、参数按声明类型 | +| `contract` | 成果节点的出具契约(必需指标) | 蓝图「看哪些数」 | 出具契约检查(含指标出具) | + +`TemplateApplier.apply(op)` 只收 `fill_joint {joint, value}`,`joint` 必须是这个模板声明的生成关节;`TemplateChecker.issues()` 跑上表的检查加骨架比对,交回模型改(最多 2 轮,沿用 `Generation` 的修正阶段)。 + +### 3.5 试运行解析 + +- `POST /api/blueprints/{id}/trial {inputs?}`:实例化 → 保存草稿图 → 发起探索运行(`origin_kind=trial`)→ 返回 run_id;结束后蓝图状态记 trialed、`last_trial_run_id`。 +- `GET /api/blueprints/{id}/preview?page=…&run=…`:派生(或沿用)应用草稿,调 `resolve_page(draft=True, trial=run)`: + - 所有视图来源解析到这次运行;趋势、按需视图只给这一个点; + - 印章 `from = 试运行`、`trial = true`、notes 加「这不是正式结果」; + - 流程组件(实例进度、待办)对着这次运行;待办卡按钮写「以试运行身份…」。 +- 权限:蓝图的 manage(共享)或拥有者(个人)。 + +### 3.6 发布单 + +- `POST /api/blueprints/{id}/publish-plan {level}`:只读,返回 `{steps[], summary[], problems[{code, plain, who, fix?}], ok}`。 +- `POST /api/publish-plans/{plan_id}/run`:按步执行;每步结束写 `steps[i]`;某步失败时状态 stopped,返回白话原因。 +- `POST /api/publish-plans/{plan_id}/resume`:修好后从停下的那一步接着做;已完成的步骤按结果里的 id 核对,不重做。 +- 每一步调用现有服务函数(发布、定时任务、派生、应用发布、授权),不经 HTTP;预检阶段这些函数以「只检查」方式调用。 +- 审计:发布单的开始、每步、结束各写一条(`publish_plan.*`),和各对象自己的审计互相引用。 + +### 3.7 记录表 + +- **写入服务**:`records/store.py`。 + - `append(table, values, run_id, actor, action_key)`:在单进程锁内校验列、写一行、返回 `_seq` 和 `_id`; + - 同一个 `action_key` 第二次调用返回第一次的结果(幂等)。 +- **能力** `records.append`: + - 内置登记;参数是 `{table, values}`; + - 效果是写入,允许无人值守,查询能力是 `records.lookup(action_key)`。 +- **查询连接**: + - `data/engine.py` 打开 `origin=records` 的源时,在连接上建 TEMP VIEW:每张业务表一个同名视图; + - 视图条件是 `_seq <= 高水位`,加 `_superseded = 0`,加行级过滤; + - 高水位来自运行的 data_versions;行级过滤取自运行的发起人和这个应用的授权:正式运行以服务身份跑、不过滤;`records_list` 按看的人过滤。 +- **表版本**:「填什么」卡片变化时,按 `INCOMPATIBLE_KINDS` 判定,兼容的就 ADD COLUMN,否则新建版本文件并登记一行 `records_tables`。 + +### 3.8 文件 + +- **接口**: + - `POST /api/files`(multipart;context 必填)→ 引用; + - `GET /api/files/{id}`(按绑定对象核对读权限); + - `DELETE /api/files/{id}`(只能删自己还没绑定的)。 +- **控件**:`x-widget: file`,字段 `type: object`,属性 `{file_id, name, size, sha256, type}`;`accept`、`max_mb` 进 `x-file`。前后端校验同一份:`apps/forms.py`、`lib/schemaForm.ts`。 +- **模板函数** `file_text(ref, max_chars=…)`:运行时抽文本,结果记进运行事件(字数、是否截断),抽取的文本按工件存,进证据。 +- **清理**:调度循环每小时清一次 24 小时前上传、没有绑定的文件。 + +### 3.9 个人应用 + +- `POST /api/blueprints/{id}/save-personal`:校验图满足 `chat_policy`(节点、工具、无人工、无写入),建或更新 `scope=personal` 的 App,图存在蓝图上。 +- `POST /api/apps/{id}/refresh`(个人):发起探索运行(`origin_kind=personal_app`,身份是拥有者,强加 `MASK_RESULTS`、`READ_ONLY`),受每日上限约束。 +- 视图来源 `exploratory`:只在 `scope=personal` 的应用里合法(应用检查拦),解析时取最近一次成功封存的 `personal_app` 运行。 +- `POST /api/apps/{id}/submit-review {to}`:开 `review` 待办;搭建者处理时调 `POST /api/blueprints/{id}/copy` 得到共享副本。 +- `can_read_run` 加一条:拥有者能读自己个人应用的运行(`auth/access.py:74-96`)。 + +### 3.10 接口一览 + +| 接口 | 权限 | +|---|---| +| `POST /api/blueprints`(起点:对话 / 模板 / 运行 / 版本) | builder;个人:user + 开关 | +| `GET/PATCH /api/blueprints/{id}`(PATCH 收 `bp_*` 操作数组) | 拥有者或共享蓝图的 manage | +| `POST /api/copilot/blueprint-stream` | 同上 | +| `POST /api/copilot/fill-joints-stream` | 同上 | +| `POST /api/blueprints/{id}/trial`、`GET …/preview` | 同上 | +| `POST /api/blueprints/{id}/publish-plan`、`/api/publish-plans/{id}/run|resume` | builder(受管级再要 admin) | +| `POST /api/blueprints/{id}/save-personal`、`/api/apps/{id}/refresh`、`/submit-review` | user + 开关,拥有者 | +| `POST /api/blueprints/{id}/copy` | builder | +| `GET /api/templates` | 登录即可 | +| `GET /api/apps/{id}/candidates` | manage | +| `POST/GET/DELETE /api/files…` | 见 §3.8 | +| `GET /api/apps/{id}/records`(`records_list` 用) | use(只看自己的)/ observe | + +### 3.11 事件 + +- 运行事件:`run.origin` 的 kind 加 trial、personal_app;`file.extracted`(文件抽文本:字数、是否截断);`records.appended` 是动作事件(`action.*`)的一种,不另起。 +- 蓝图事件存 `blueprint_events`,不进运行事件。 + +### 3.12 权限矩阵(开登录时) + +| 操作 | admin | builder | user | +|---|---|---|---| +| 建共享蓝图、试运行、发布单(已发布级) | ✓ | ✓ | ✗ | +| 发布单(受管级、降级) | ✓ | ✗ | ✗ | +| 建个人应用、刷新、提交审核 | ✓ | ✓ | 开关开着时 ✓ | +| 审核个人应用(得到共享副本) | ✓ | ✓ | ✗ | +| 看别人的个人应用内容 | ✗ | ✗ | ✗ | +| 上传文件 | 有对应入口或待办的人 | 同左 | 同左 | +| 下载文件 | 读得到绑定的运行或记录的人 | 同左 | 同左 | +| 记录表逐行查看 | 全部 | 全部(有 observe) | 只看自己的(use) | + +关登录时全部放行,个人应用的开关照样生效(关着就没有入口)。 + +### 3.13 新增检查 + +- **蓝图**(`bp.*`,蓝图卡片和发布单预检里显示): + - `bp.missing`:缺必填卡片项; + - `bp.unconfirmed`:待确认项未确认就发布; + - `bp.data_unmapped`:看哪些数没对到表或口径; + - `bp.people_unbound`:岗位没有人; + - `bp.reconciler_unbound`:写回的能力没登记查询,结果不明时没人核实(同上,只挡发布;发布单上应用检查说同一件事的 `app.reconciler_unbound` 和核实岗位的 `app.slot_unbound` 不再重复列); + - `bp.template_mismatch`:卡片内容超出模板能力。 +- **模板**(`tpl.*`): + - `tpl.skeleton_changed`:实例化的图改了骨架; + - `tpl.joint_unfilled`:生成关节没填; + - `tpl.joint_refused`:模型想改非关节的位置。 +- **应用**: + - `app.view_source_personal`:`exploratory` 来源出现在共享应用里,error; + - `app.hidden_bound`:被隐藏组件是唯一绑定某个视图的组件,warning。 + - `app.records_rows_exposed`:看板的表格绑定的查询直接列出记录表的逐行(没有聚合),warning。正式运行看得到全部行,这样等于所有使用者都能看到别人的记录,绕过了行级可见;逐行查看请用 `records_list`。 +- **个人应用**:`personal.policy`,图里有使用者不能用的节点或工具(沿用 `chat_graph_problem` 的说法)。 +- **白话**:以上全部登记进 `app/plain.py`。 + +--- + +## 第四部分 界面 + +### 4.1 新建应用 + +- **入口**:应用首页右上「新建应用」。使用者在开关开着时也有这个入口,只能建个人应用。 +- **三个起点卡片**: + - 说说你要什么:大输入框加例句; + - 从场景模板开始:五张模板卡,每张写适用场景、会产出什么、要准备什么; + - 从已有的开始:最近的问数据回答,加上已发布的工作流(后者只给搭建者)。 +- **问数据的「做成应用」按钮**:改为「做成应用(蓝图)」,直接带着这次运行进蓝图,不再跳画布发布框。 + +### 4.2 蓝图页 + +- **左侧**:对话。助手的反问画成可点的选项,「这一轮在等你回答」常显。 +- **右侧**:卡片列,每张卡片头上标状态(已确认、待确认、缺)。卡片里每一项都可以点开改,改完标来源「你」。 +- **对到数据的项**:显示对到的表、中文名、粒度、业务日期,带一句「为什么对到这张表」,来自 look 和目录。 +- **底部**:「用真数试一次」(缺项时置灰,写清缺什么);试过之后才有「发布」(搭建者)、「存成我的应用」(使用者)。 + +### 4.3 试运行页 + +- 就是应用的样子,顶部横幅写「试运行:数据是真的,但这不是正式结果」,配试运行的输入和「换一期再试」。 +- 流程类:右侧列出试运行里的待办,自己点同意或驳回;写入步骤标「测试环境」或「演练」。 +- 出错时用白话说哪一步、为什么,加「交给助手修」或「回到卡片改」。 + +### 4.4 发布摘要 + +- 一页:每一步一句白话。问题分三栏:你来定、助手可以修、要找管理员。 +- 确认按钮写清结果,例如「发布并每周一 09:00 自动出数」。 +- 执行时逐步打勾;停下时写在哪一步、为什么、修好后「接着做」。 + +### 4.5 页面上直接改 + +- 应用页右上「编辑」(manage)。 +- 编辑状态下,组件悬停描边,点中出小工具条:改文字、隐藏、上移、下移、删除。 +- 页底「加一张数据卡片」,弹出候选列表,按口径卡、报告、明细、趋势分组。 +- 顶部「改取数」回到蓝图。 +- 改动自动存草稿,顶部写「有未发布的改动」和「发布」。 + +### 4.6 个人应用 + +- 应用首页「我的应用」分组,卡片角标「个人」。 +- 印章写「探索 · 未经发布审核」,顶部有「刷新」(写明今天还剩几次)和「提交审核」。 +- 审核中写状态;审核发布后写「已有正式版本:去看看」。 + +### 4.7 新组件和控件 + +- **文件控件**:拖放或点选;显示文件名、大小、上传进度、类型和大小限制;上传失败照实说。 +- **`records_list`**:列表、筛选、按时间排序;每行可以展开看全部字段和附件;被更正过的行显示「已更正」,可以展开看历史;使用者只看自己的,标题写「我的记录」。 + +### 4.8 文案与检查脚本 + +- **文案**:`termsApp.ts` 新增蓝图、模板、试运行、发布单、个人应用、记录、文件各组说法;后端白话层 `app/plain.py`。 +- **新检查脚本**: + - `check-blueprint`:起点、卡片、反问、试运行、发布摘要; + - `check-personal`:使用者建、刷新、提交审核; + - `check-records`:填报、行级可见、更正、表版本; + - `check-files`:上传、绑定、下载权限。 +- **要更新的**:`check-apps`(编辑状态、隐藏、候选)、`check-chat`(反问收尾、做成应用改道)、`check-studio`(反问记录)。 +- **检查脚本的新约束**:每个问题编号都要有白话。 + +--- + +## 第五部分 实施组织(供下次审批,这一步不执行) + +- **分支**:从最新 main 开集成分支 `feat/normal-user-apps`,所有包在同一分支、同一工作区。 +- **第 0 步契约骨架**:编排者一次提交,包括: + - 新表和新列、`agentlab.blueprint/1` 和 `agentlab.template/1` 的 pydantic 模型; + - 关节类型和操作的白名单、`app/plain.py` 的框架; + - 新路由的桩(501)、`records.append` 和 `table_import.new_period` 的能力登记; + - 前端类型和接口桩、占位路由。 + + 之后冻结热点文件:`db/models*.py`、`db/base.py`、`core/events.py`、`typesApp.ts`。 +- **一个 workflow,六个包**,每包一个完整代理,然后一个统一复核、一个集中修复: + +| 包 | 范围 | +|---|---| +| B1 蓝图与模板(后端) | Blueprint 对象和操作、模板登记和五个模板、实例化和骨架比对、`BlueprintApplier`/`TemplateApplier` 与 Checker、两个生成入口、look 新类别 `caliber`、并行填关节 | +| B2 试运行与发布单(后端) | 试运行解析(`trial` 旁路)、派生自图、发布单的预检 / 执行 / 续做、白话层的内容、反问收尾的服务端部分 | +| B3 记录表与文件(后端) | 记录表存储与连接层的临时视图、`records.append`、表版本、`records_list` 解析、文件存储 / 接口 / 绑定 / 清理、`file_text`、`table_import.new_period` | +| B4 个人应用(后端) | scope 与拥有者、`exploratory` 视图来源与印章、刷新与上限、提交审核与副本、`can_read_run`、开关 | +| F5 创建链路(前端) | 新建应用三个起点、蓝图页、试运行页、发布摘要、问数据「做成应用」改道、反问在问数据和画布的收尾 | +| F6 页面编辑与新组件(前端) | 编辑层、隐藏、候选、文件控件、`records_list`、个人应用的界面 | + +- **合并顺序**:B1 → B2 → B3 → B4 → F5 → F6,编排者做集成修正。 +- **验证分层**(沿用):代理只跑相关测试和 tsc、check-copy;完整关口由编排者在合并后跑一次。 +- **真实模型验收和端到端验收**:都在关口之后跑。 +- **合入 main 之前**:先问用户。 + +--- + +## 第六部分 验证与验收 + +- **W1 看板(搭建者,从一句话开始)**:「每周一看各门店上周销售额、订单数、退款率,不含退款单」。 + - 反问一次(按下单日还是支付日); + - 卡片对到正确的表和业务日期; + - 试运行出真数,点数字追到单元格; + - 发布摘要一页,确认后 9 步全部完成,应用打开就有数; + - 从说完需求到看到试运行结果,记录用时和模型调用次数。 +- **W2 审批(搭建者,从模板开始)**:报销审批,金额线 5000,财务组全员同意,48 小时升级给总监,通过后写到假外部系统。 + - 试运行里自己点完审批,写入走测试环境; + - 发布后真实走一单; + - 中途故意让岗位没绑人:发布单停在预检,白话说「谁来做」缺人。 +- **W3 填报登记**:门店每日报损。 + - 两位使用者各提交几条,彼此看不到对方的记录;观察者看全部;汇总看板是全量的; + - 更正一条:原行保留,汇总按更正后的算; + - 改表单:加一个可选字段不出新版本;把数字字段改成文字,出 v2,旧记录还在。 + - 同一次运行查两遍,数一样(高水位)。 +- **W4 真实模型**:本机助手模型、deepseek-v4.1-flash、glm-5.3,跑 W1、W2 的蓝图和关节,记录: + - 调用次数、查看次数、首个操作时间、总用时; + - 反问是否发生、关节检查的修正轮数。 + + 目标: + - 起草蓝图 ≤ 3 次调用; + - 填关节在快速模型上一分钟内完成; + - 卡片和关节出错时,用户能看懂、能改。 +- **W5 个人应用(使用者)**: + - 开关打开后,使用者从问数据的一次回答建「我的退款周报」; + - 刷新受每日上限约束;印章写「探索」;结果遮罩; + - 提交审核后,搭建者在副本上发布,提交人收到通知; + - 管理员看不到个人应用的内容。 +- **W6 文件**: + - 报销单附发票 PDF,agent 读出金额; + - 门店上传本周报表 Excel,作为一期导入(需确认); + - 不能读那次运行的人下载 403;没绑定的上传 24 小时后被清理(用缩短的时间测)。 +- **关口**:后端全量 pytest、tsc、check-copy、泄露扫描、`check-all --stacks 2`(含新增的四个检查)、check-auth;真浏览器端到端按 W1–W6 截图复核。 + +--- + +## 第七部分 风险与对策 + +| 风险 | 对策 | +|---|---| +| 蓝图对错数据(白话对到错的表) | 对到的项默认「待确认」,卡片上写为什么对到这张表;试运行看真数;数据目录越完整越准,卡片上提示补目录 | +| 模型慢或不稳 | 关节少、并行、确定性兜底(P3);验收按真实模型记录耗时 | +| 模板覆盖不了需求 | 「自由形」加搭建者回画布;模板登记可扩展;收集落在模板外的需求 | +| 发布单中途失败 | 步骤顺序让失败无害;可续做;摘要照实说停在哪 | +| 记录表并发写 | 单进程锁、事务短;README 写明不面向高频写入 | +| 行级可见被绕过 | 在连接层建临时视图,模型和 SQL 都看不到底表;测试覆盖直接写 `main.<表>` 的 SQL 被只读守卫拒 | +| 看板把记录逐行列给所有人 | 正式运行看全部行是设计如此;应用检查对「逐行列出记录表」的表格给警告(`app.records_rows_exposed`),发布摘要写明使用者能看到什么 | +| 个人应用变成「影子正式应用」 | 永远探索、印章写明、不能分享;共享只能经审核发布 | +| 文件泄露 | 下载按绑定对象核对读权限;界面写明谁能下载;不做公开链接 | +| 白话说错 | 白话层只有一份,按编号映射,检查脚本防遗漏 | + +--- + +## 第八部分 待拍板 + +| # | 问题 | 推荐 | +|---|---|---| +| Q1 | 记录表的存储 | 平台托管 SQLite、只追加、序号高水位钉版本、连接层临时视图做行级可见(N8) | +| Q2 | 填报提交走不走工作流 | 走:模板里 `records.append` 写入节点,可以加审批,沿用动作的幂等和对账 | +| Q3 | 使用者建个人应用的开关 | 管理员开关,默认关(同问数据) | +| Q4 | 个人应用的刷新 | 只手动刷新,加每日上限;不做定时自动刷新(自动刷新等于给使用者开定时任务) | +| Q5 | 文件的类型和大小 | 白名单:PDF、Word、Excel、CSV、图片(PNG/JPG)、纯文本;大小沿用 `max_upload_mb`(默认 50MB);未绑定的 24 小时清理 | +| Q6 | 首批模板 | 五个:周期看板、审批流程、异常处置、填报登记、核对写回,外加自由形 | +| Q7 | 流程类试运行的审批 | 派给试运行的人自己点;写入走测试环境或演练;不触发超时升级 | +| Q8 | 发布单里「跑第一次」 | 看板类默认勾选,流程类不跑 | + +--- + +## 附录 A 代码位置索引(main@69c5c74) + +| 位置 | 内容 | +|---|---| +| `backend/app/api/copilot_gen.py:567` | Generation;`:589` 起草、`:593` 修正、`:38` 修正轮数 | +| `backend/app/look/base.py:22-27,87-110` | 查看上限;Applied / Applier / Checker 协议 | +| `backend/app/api/copilot.py:1679` | `/generate-stream`;`:1744` 只问不改的一轮;`:2364` `/app-stream`;`:2468-2550` from-run 与来源记录 | +| `backend/app/apps/ops.py:264` | `polish_problem`(护栏的写法) | +| `backend/app/engine/autofix.py:1240` | `forbidden_changes`(骨架比对的写法) | +| `backend/app/apps/derive.py:85-90` | 派生时视图绑定时任务 | +| `backend/app/apps/resolve.py:82-108,454-476,541-546,203-221,813` | 最近封存的规则;草稿解析;按运行解析;印章来源;趋势无钉住项报错 | +| `backend/app/apps/spec.py:35-42,47,82,97-107` | 表单控件、视图来源、组件模型 | +| `backend/app/engine/launch.py:95-176` | 运行类别的判定 | +| `backend/app/auth/chat_policy.py:25-73` | 使用者能用的节点和工具 | +| `backend/app/api/runs.py:108-123` | 使用者运行的限制 | +| `backend/app/auth/access.py:74-96` | `can_read_run` | +| `backend/app/data/catalog.py:12-21` | 数据目录项的形状 | +| `backend/app/data/raw_store.py:33-43` | 按哈希存原件 | +| `backend/app/data/table_versions.py:1-33,344-360` | 表的版本模型和存放 | +| `backend/app/data/recipe_accumulate.py:38-41` | 不兼容的列变化 | +| `backend/app/engine/runner.py:242-262,1013-1045` | data_versions 的固定;封存 | +| `backend/app/api/workflows.py:427-503` | 发布 | +| `backend/app/api/schedules.py:461`,`scheduler/service.py:39-56` | 建定时任务;模板变量 | +| `backend/app/api/apps.py:186,203,251,370,474,273` | 建应用、派生、草稿、发布、授权、归档 | +| `frontend/src/store/chat.ts:1241,1433` | 问数据整轮重来;没有反问分支 | +| `frontend/src/apps/ViewRenderer.tsx:75-84` | 组件包在 `data-node` 里 | +| `frontend/src/pages/ChatPage.tsx:479-523` | 问数据的「做成应用」 | + +## 附录 B 术语 + +- **蓝图**:用户的需求被整理成的一组白话卡片,是生成工作流和应用的依据。 +- **卡片**:蓝图里用户能判断对错的一件事(看哪些数、谁来做……)。 +- **场景模板**:一类应用的骨架,带可填的关节。 +- **关节**:模板骨架里允许填的位置;确定性关节按卡片填,生成关节由助手填。 +- **试运行**:跑一次还没发布的图,用真数看应用的样子;不是正式结果。 +- **发布单**:一次确认发布的编排,逐步执行、可续做。 +- **个人应用**:使用者自己建、只有自己看得见的应用,永远是探索。 +- **记录表**:平台托管、只许追加的表,填报登记的每次提交是一行。 +- **高水位**:一次运行开始时记录表的最大序号,这次运行只看到这之前的行。 +- **白话层**:问题编号到白话说法的唯一映射。 diff --git a/docs/design/orchestration-round/00-总方案.md b/docs/design/orchestration-round/00-总方案.md new file mode 100644 index 00000000..07cf068b --- /dev/null +++ b/docs/design/orchestration-round/00-总方案.md @@ -0,0 +1,82 @@ +# 编排完善:问数据整轮服务端化、子工作流证据回传、驳回出口、数据事件触发、多进程与 PostgreSQL + +状态:2026-10-18 用户已拍板(多进程做到「多进程 + 换 PostgreSQL」;触发接「上传表格出新一期」「上游工作流跑完」「数据源有要处理的变化」,不接「口径发布」;问数据掉线「整轮放到服务端跑」)。其余决定按本文推荐。 + +## 0. 调查结论:问数据为什么会因为前端掉线而失败 + +一轮问数据(建图 → 发起运行 → 复核 → 按复核重来、落库)是浏览器在推(frontend/src/store/chat.ts 的 plan / launch / settle): + +- 建图是 `POST /api/copilot/generate-stream` 一条和页面绑在一起的流。网络一抖(`fetch` 抛 TypeError),前端 `onEnd(NETWORK_MESSAGE)` → `fail()`,这一轮记 error;服务端那边 Starlette 1.6 的 StreamingResponse 在客户端断开时取消生成任务,建了一半的图丢掉。代码注释原话:「建图跟着页面那条流走,页面一关就停」。只能整轮重试,已花的建图费用白花。 +- 发起运行是页面发的 `POST /api/runs`:断网时直接失败,要手点「重新运行本轮」。 +- 运行本身在服务端,页面的 WebSocket 断了会重连回放,这一段不怕掉线。 +- 运行结束后的复核(`/copilot/review`)、按复核重来、落库(`PATCH /conversations/{id}/turns/{turn}`)都是页面发起的:页面不在,这一轮就没人复核、不会重来,库里停在 running,15 分钟(BUILD_STALE)后才被判成断了。 + +根因:问数据一轮的编排放在浏览器里(细案 01 D1 当时的选项 C「整轮编排搬到后端」,判断「方向对,但要重做重连、断点、取消,应单独立项」)。评测已经有一份在服务端跑完整一轮的驱动(app/eval/answer.Driver),问数据没用它。 + +## 1. 问数据整轮放到服务端(W1-a) + +- **一轮是一个后台任务**:`POST /api/chat/turns {conversation_id, question, datasource_ids?, retry_of?, mode?}` → 202 `{turn_id}`。服务端用和评测**同一个驱动**跑:answer_ops → chat/turn.next_step → launch_run(origin=chat)→ 等终态(停在审批的照旧停着等人,不取消——这是和评测唯一的不同,用参数区分)→ 复核 → 按 retry 规则重来。评测、问数据各自的差异写成驱动的参数,不复制流程(总方案上一轮 X「一种说法」)。 +- **事件日志**:每一步(助手的操作、心跳、look、final、usage、运行 id、复核结论、重来、终局)按 seq 写进这一轮的事件日志(新表,或复用 run_events 的写法;不进运行的封存范围)。`GET /api/chat/turns/{id}/stream?after=` 先回放、再推实时(SSE,带心跳);断了按 last seq 重连;刷新、关掉再打开都能接着看。`GET /api/chat/turns/{id}` 给当前状态。 +- **停止**:`POST /api/chat/turns/{id}/cancel`(取消生成 / 运行,照现有取消规则)。 +- **落库**:conversation_turns 那一行由服务端写(状态、运行 id、答案、复核、meta.usage),前端不再写这一轮的结局;老的 PATCH 接口保留给别的用途(改标题等),不再被这一轮依赖。 +- **前端**:store/chat.ts 的 plan / launch / settle / retry 改成「发起 + 播放」:收到的操作照现在的渲染逻辑画(画布边收边长、过程步骤、答案、复核说明);断线自动重连、按 seq 补齐;界面上的「重试本轮」「重新运行本轮」「停止」都走新接口。问数据页的显示不变。 +- **服务重启**:还在建图的这一轮标中断(照实说,可「重试本轮」);已经发起运行的,重启后接着等这次运行、照常复核收尾(运行本身的中断规则不变)。第 2 波有了租约后,别的进程也能接手。 +- **单进程期间**:任务用 core/job_runtime;并发上限归入「交互」类(第 2 波的优先级)。 +- 用户(user 角色)的问数据权限、遮罩、只读规则一字不变(这些在 launch_run 和 answer_ops 里)。 + +## 2. 子工作流的证据交回父图(W1-b) + +现状:子图共享同一个 run 的事件流(节点 id 加前缀 `sub/x`),但只把出口的值交回父图(engine/nodes/multi.run_subgraph 的 `output`),子图里的查询快照、口径卡留在子图自己的状态里。父图报告引用不到子图里的数(ledger/compose.py 写明了这一点,口径账本为此改用内联实例化)。 + +- **出处随值走**:子图出口的每个字段,按出口模板解析出它的来源(cell(子图里的查询…) / 子图口径卡的指标 / 运行输入 / 别的),子图节点的产出带 `provenance {字段: 出处}`;父图里引用 `nodes.<子图节点>.output.<字段>` 的口径卡、报告,经 engine/lineage 跨子图往下追(同上一轮「转抄口径卡指标跨卡追」的机制:运行时、证据面板、静态检查三处同一套,限深度)。 +- **报告能直接引用子图里的查询和口径卡**:报告撰写的目录(build_catalog)收进子图里的查询快照和口径卡(带前缀的节点 id,别名照常编号),引用标记、证据面板一路点到子图里的那一格;封存、可验证报告照常(事件本来就在同一个运行里)。 +- **治理**:受管级里子工作流必须钉已发布未退役的版本(现有 lint);子图里追不到原始数据的,父图照样降档;子图的遮罩、只读规则照父图的运行。 +- 口径账本的组合(use_caliber)继续用内联实例化,不改;但召回 / 比对按出处能认出子图里的查询(反查用得上)。 + +## 3. 驳回出口(W1-b) + +现状:审批、补充输入、编辑稿件、动作确认、代码 / 工具审批、双盲裁决被驳回时抛 NodeRejected,运行记 cancelled + end_reason=rejected,节点照旧发 node.failed(reason=rejected)。没有「驳回之后走另一条路」。 + +- 这几类节点在画布上多一个可选的「驳回」出口(sourceHandle="rejected",和分支节点同一套连线机制)。接了它:驳回(含超时驳回 expired_reject)时节点**正常结束**,产出 `{rejected: true, decision, actor, note, kind}`,运行沿驳回那条边往下走;节点事件记「被驳回,走驳回出口」(node.finished 带 outcome=rejected,时间线、画布、航迹照「被驳回」画,颜色用中性色),运行照它的路径结束(成功结束也照实说「途经驳回」)。没接:行为一字不变(cancelled + rejected)。 +- 动作确认被驳回走出口时:照旧不发送写入、记「未确认(被驳回)」;下游可以接「通知」「退回修改」等。 +- 发布检查:驳回出口那条路要能走到出口或结束;受管级里驳回路径上的出具照常判档(契约照常核对)。应用:实例页、待办、通知照实写「被驳回,转到 X」。 +- 「失败重试」「出错时继续」照旧不管驳回(驳回不是故障,X9 不变)。 + +## 4. 数据事件触发(W1-c) + +一套定时机制(X4):Schedule 加触发方式。 + +- `trigger_kind`:cron(缺省,行为一字不变)| event。event 时 `trigger`: + - `table.version`:某张上传表格启用了新版本(按期累积的新一期,data/table_versions 启用那一下);可选只认某个期次规则; + - `workflow.finished`:某个工作流的正式运行成功结束(可选:只认某个应用 / 某种来源);B 的输入模板可引用 A 的出口字段; + - `source.changed`:某个数据源出现「要处理」级的变化(按源去抖:同一次探查 / 剖析产生的一批只触发一次)。 +- 目标只能是工作流(target_kind=workflow);运行来源仍记 origin_kind=schedule,origin 里带 `trigger {kind, event, ...}`,界面写「由事件触发:…」。 +- 输入模板变量:`{{ event.* }}`(table、version、period、activated_at;workflow_id、run_id、outputs.<字段>;source、counts)。 +- 去抖与重叠:同一触发在 `debounce_s`(缺省 60 秒)内的多次事件合成一次;上一次还没结束照 overlap=skip 跳过(照实记 skipped_overlap)。 +- 防环:保存时检查 workflow.finished 的串联不成环(A→B→A 拒收);运行时按 origin 里的触发链长度兜底(> 5 层拒发,照实记)。 +- 事件送达:事件在发生处(启用表格版本、运行收尾、变化记录)写一条「待派发事件」(库里一张表),调度循环一拍取走、按触发条件派发、写 ScheduleFire(kind=event,inputs 带事件内容)——不在发生处直接发起运行(事务边界、重复与重启都好说清),也为第 2 波多进程做准备。AGENTLAB_SCHEDULER 关着时不派发(照旧:拷来的数据目录不替人跑)。 +- 界面:定时页新建时选「定时 / 事件触发」,事件触发选事件种类和对象;列表、详情、触发历史照实显示;Webhook 通知照旧可配。 + +## 5. 多进程与 PostgreSQL(第 2 波:W2-a 数据库,W2-b 协调) + +### W2-a 数据库可移植 + PostgreSQL +- `AGENTLAB_DATABASE_URL`:缺省 SQLite(单机、单进程照旧);`postgresql+asyncpg://…` 时用 PostgreSQL。 +- 审计全部原生 SQL 和 SQLite 专用写法(json_extract、strftime、datetime('now')、INSERT OR REPLACE / ON CONFLICT、PRAGMA、按 rowid、LIKE 大小写、布尔与 JSON 列、时区),改成 SQLAlchemy 表达式或按方言分支;两种库都过全量测试(PostgreSQL 用本机 docker 的 postgres:16 起一个测试库,脚本化)。 +- 迁移:现有 add-only 的列 / 索引迁移按方言生效;新增一个从 SQLite 数据目录导入到 PostgreSQL 的命令(`python -m app.db copy-to-postgres`),幂等、可核对行数。 +- LangGraph 断点:PostgreSQL 时用官方 `langgraph-checkpoint-postgres`(要装 psycopg 3 / psycopg_pool,写进 pyproject 和 requirements.lock);SQLite 时照旧。 +- 数据目录(工件、上传表格的版本库、知识库索引、沙箱)仍是文件:多进程同机直接共用;多台机器要把数据目录放在共享存储上(写进 README,写文件一律先写临时文件再改名)。本轮不做对象存储。 + +### W2-b 多进程协调 +- **租约**:运行、后台任务(探查、起草、剖析、评测、影子起草、问数据一轮、召回、补算、维护)在库里认领:owner、lease_until、心跳;进程死了租约过期,别的进程按各自的规则接手或照实标中断(运行仍是「标中断、人接着跑」,不自动重跑有副作用的步骤)。 +- **优先级与并发**:运行分类(交互:问数据、画布试跑;应用:入口、按需视图、钩子、接口;定时;系统:评测、召回、对账、补算、出具),每类有全站并发上限和优先级,按库里的计数认领;评测不再挤占交互。 +- **调度循环**:只有一个进程当班(库里的租约),其余待命;定时认领本来就是条件 UPDATE。 +- **实时流跨进程**:运行事件已落库;订阅方不在跑这次运行的进程时,从库里按 seq 追(PostgreSQL 用 LISTEN/NOTIFY 唤醒,SQLite 用短轮询);问数据一轮的事件同。 +- **进程内的账和缓存**:每日花费账(裁判、核查、评测、按需视图、口径出具上限)、访问密钥的每分钟限流、按需探查锁、设置缓存、向量模型缓存、目录缓存等,改成库里的条件 UPDATE / 版本号失效;逐个列清单核对。 +- **验收**:PostgreSQL 上起两个工作进程跑端到端:运行被任一进程认领、另一个进程上的页面收得到实时事件、定时只触发一次、评测和问数据并发时优先级生效、杀掉一个进程后租约过期被接手或照实标中断。 + +## 6. 分波与关口 + +- 第 1 波(并行三包):W1-a 问数据整轮服务端化;W1-b 子工作流证据回传 + 驳回出口;W1-c 数据事件触发。骨架先由编排者加好:Schedule 的 trigger_kind / trigger 两列与迁移。 +- 第 2 波(顺序两包):W2-a 数据库可移植 + PostgreSQL;W2-b 多进程协调(建在 W2-a 之上)。 +- 每包一个完整代理,统一复核一个代理,修复交回原代理(不另起代理)。关口:后端全量(SQLite;第 2 波另加 PostgreSQL 全量)、tsc、check-copy、相关浏览器检查、泄露扫描;第 2 波另加多进程端到端。 +- 合 main 前问用户(本轮没有预先放行合 main)。 diff --git a/frontend/src/api/apps.ts b/frontend/src/api/apps.ts index be7813cd..95b56a3b 100644 --- a/frontend/src/api/apps.ts +++ b/frontend/src/api/apps.ts @@ -112,6 +112,9 @@ export const ledger = { */ version: (workflowId: string, version: number) => get<{ apps: { app_id: string; name: string; app_version: number }[]; effects: number; formal_runs: number + workflows?: { workflow_id: string; version: number; name: string; node_id: string; depth: number }[] + indirect_apps?: { app_id: string; name: string; app_version: number }[] + unreadable_workflows?: { workflow_id: string; version: number; name: string }[] answers?: number; exports?: number; schedules?: number; recall?: VersionRecallCounts }>( `/ledger/workflow-versions/${encodeURIComponent(workflowId)}/${version}`), } diff --git a/frontend/src/api/caliberLedger.ts b/frontend/src/api/caliberLedger.ts index 391831fb..8d60d0be 100644 --- a/frontend/src/api/caliberLedger.ts +++ b/frontend/src/api/caliberLedger.ts @@ -57,6 +57,8 @@ export const caliberLedger = { /** 一份草稿的完整内容:payload 摊平在上层(metrics / entry_fields / dropped / open_questions / alignment / trial) */ draft: (draftId: string, opts?: RequestOptions) => get(draftAt(draftId), opts), /** 起草后自动发起的那次只读试运行:状态和每个指标的值,running 时轮询到终态 */ + retryDraft: (draftId: string) => post<{ draft_id: string; status: string }>(`${draftAt(draftId)}/retry`, {}), + stopDraft: (draftId: string) => post(`${draftAt(draftId)}/stop`, {}), draftTrial: (draftId: string, opts?: RequestOptions) => get(`${draftAt(draftId)}/trial`, opts), /** * 按填好的入口字段再试运行一次(POST 同一个路径):参数按入口字段校验,缺必填回 422; diff --git a/frontend/src/canvas/Inspector.tsx b/frontend/src/canvas/Inspector.tsx index e67938bc..7533e88a 100644 --- a/frontend/src/canvas/Inspector.tsx +++ b/frontend/src/canvas/Inspector.tsx @@ -1924,16 +1924,146 @@ function CaseList({ nodeId, mode, value, issues, onChange }: { ) } +/** 指标类型:和 engine/schema.py 的 METRIC_KINDS 同一组;公式是缺省类型,不写进配置 */ +const METRIC_KIND_OPTIONS = [ + { value: 'formula', label: '公式' }, + { value: 'external', label: '外部常量' }, + { value: 'rule', label: '规则' }, + { value: 'assessment', label: '评估' }, +] as const + +type MetricKind = (typeof METRIC_KIND_OPTIONS)[number]['value'] + +/** 列表字段写成一行「、」分隔;空值不显示 */ +function listText(value: any): string { + return Array.isArray(value) ? value.filter((v) => typeof v === 'string' && v.trim()).join('、') : '' +} + +/** 同义词一行拆成列表:顿号、逗号、换行都算分隔 */ +function splitItems(text: string): string[] { + const seen = new Set() + const out: string[] = [] + for (const part of text.split(/[、,,\n]/)) { + const item = part.trim() + if (item && !seen.has(item)) { seen.add(item); out.push(item) } + } + return out +} + +/** 多行文本拆成列表:每行一条,空行不要 */ +function lineItems(text: string): string[] { + return text.split('\n').map((line) => line.trim()).filter(Boolean) +} + +/** 数字框:没填就删键,不把空串写成 0 */ +function numberText(value: any): string { + return typeof value === 'number' && Number.isFinite(value) ? String(value) : '' +} + +function numberValue(text: string): number | undefined { + if (!text.trim()) return undefined + const value = Number(text) + return Number.isFinite(value) ? value : undefined +} + +/** + * 指标定义卡:一条指标由「定义」和「计算」组成。 + * + * 计算按类型分四种:公式用表达式;外部常量直接给值;规则与评估的结构字段用 JSON 编辑(形状见 engine/schema.py + * 的 metric_definition_issues)。定义是业务说法:名称、同义词、单位、小数位、定义说明、适用范围、负责人、生效日期、 + * 不包含的情况。引用原文(citations)由文档起草逐字核对后留下,这里只读。 + * + * 空值一律删键:schema 把空串当成写了值,会和「没写」分不开。 + */ function MetricList({ nodeId, value, issues, onChange }: { nodeId: string; value: any[]; issues: FieldIssue[]; onChange: (v: any[]) => void }) { const uid = useId() + + /** 换掉一条指标对象:调用方先拼好,空值在下面几个 set 里已经删掉 */ + const replace = (i: number, next: any) => + onChange(value.map((m, idx) => (idx === i ? next : m))) + const update = (i: number, patch: any) => onChange(value.map((m, idx) => (idx === i ? { ...m, ...patch } : m))) + + /** 类型只改 kind:切回公式时把 kind 删掉;其余结构字段原样留着,切回来不丢东西 */ + const setKind = (i: number, kind: MetricKind) => { + const metric = value[i] ?? {} + const next = { ...metric } + if (kind === 'formula') delete next.kind + else next.kind = kind + replace(i, next) + } + + /** 外部常量:值、来源、截止日期、过期天数;空对象删掉 */ + const setExternal = (i: number, key: string, raw: any) => { + const metric = value[i] ?? {} + const external = { ...(metric.external && typeof metric.external === 'object' ? metric.external : {}) } + if (raw === undefined || raw === '') delete external[key] + else external[key] = key === 'stale_after_days' ? Math.trunc(raw) : raw + const next = { ...metric } + if (Object.keys(external).length) next.external = external + else delete next.external + replace(i, next) + } + + /** 定义说明:除了 citations 之外的几个键;定义对象空了就删掉 */ + const setDefinition = (i: number, key: string, raw: any) => { + const metric = value[i] ?? {} + const definition = { ...(metric.definition && typeof metric.definition === 'object' ? metric.definition : {}) } + if (raw === undefined || raw === '' || (Array.isArray(raw) && raw.length === 0)) delete definition[key] + else definition[key] = raw + const next = { ...metric } + if (Object.keys(definition).length) next.definition = definition + else delete next.definition + replace(i, next) + } + + /** 规则 / 评估:整个对象交给 JSON 编辑;清空就删键 */ + const setNested = (i: number, key: string, raw: any) => { + const metric = value[i] ?? {} + const next = { ...metric } + if (raw === undefined || raw === null) delete next[key] + else next[key] = raw + replace(i, next) + } + + const setList = (i: number, key: string, text: string) => { + const metric = value[i] ?? {} + const items = key === 'exclusions' ? lineItems(text) : splitItems(text) + const next = { ...metric } + if (items.length) next[key] = items + else delete next[key] + replace(i, next) + } + + const setNumber = (i: number, key: string, text: string, integer = false) => { + const raw = numberValue(text) + const metric = value[i] ?? {} + const next = { ...metric } + if (raw === undefined) delete next[key] + else next[key] = integer ? Math.trunc(raw) : raw + replace(i, next) + } + return (
{value.map((m, i) => { const own = issues.filter((x) => x.at?.index === i) + const at = (sub: string) => own.filter((x) => x.at?.sub === sub || x.at?.sub?.startsWith(`${sub}.`)) + const kind = (METRIC_KIND_OPTIONS.some((o) => o.value === m.kind) ? m.kind : 'formula') as MetricKind + const definition: Record = m.definition && typeof m.definition === 'object' ? m.definition : {} + const external: Record = m.external && typeof m.external === 'object' ? m.external : {} + const citations = Array.isArray(definition.citations) ? definition.citations.length : 0 + const exclusions = Array.isArray(definition.exclusions) + ? definition.exclusions.filter((v: any) => typeof v === 'string' && v.trim()).join('\n') : '' + const rest = own.filter((x) => { + const base = x.at?.sub?.split('.')[0] + if (!base) return true + if (base === 'expression') return kind !== 'formula' + return !['kind', 'external', 'rule', 'assessment', 'definition', 'decimals', 'synonyms'].includes(base) + }) return ( x.level === 'error') ? 'error' : own.length ? 'warning' : undefined} @@ -1952,12 +2082,134 @@ function MetricList({ nodeId, value, issues, onChange }: { onChange={(e) => update(i, { unit: e.target.value })} />
- - update(i, { expression: v })} /> +
+ + + + + setNumber(i, 'decimals', e.target.value, true)} /> + +
+ {at('kind').map((x, k) => )} + {at('decimals').map((x, k) => )} + {kind === 'formula' && ( + <> + + update(i, { expression: v })} /> + + {at('expression').map((x, k) => )} + + )} + {kind === 'external' && ( + <> +

外部常量不从查询计算,值由人填写。

+
+ + setExternal(i, 'value', numberValue(e.target.value))} /> + + + setExternal(i, 'source', e.target.value)} /> + + + setExternal(i, 'as_of', e.target.value)} /> + + + setExternal(i, 'stale_after_days', numberValue(e.target.value))} /> + +
+ {at('external').map((x, k) => )} + + )} + {kind === 'rule' && ( + <> + + setNested(i, 'rule', v)} /> + +

规则的输入、条件、成立与不成立时的取值都写在结构里。

+ {at('rule').map((x, k) => )} + + )} + {kind === 'assessment' && ( + <> + + setNested(i, 'assessment', v)} /> + +

评估按维度加权评分,再按分数落等级。

+ {at('assessment').map((x, k) => )} + + )} +
+ 计算约束 + + setNested(i, 'contract', v)} /> + +

+ shape 选 scalar 时要求查询恰好一行;grain 指定唯一粒度列,non_null 指定非空列,minimum / maximum 限制取值。 + ratio 可指定同一查询中的 numerator、denominator 列及 scale 换算倍率;subset 为 true 时检查分子不超过分母。 + 业务描述与 SQL 的一致性仍需审阅。 +

+ {at('contract').map((x, k) => )} +
+ + setList(i, 'synonyms', e.target.value)} /> - {own.map((x, k) => )} + {at('synonyms').map((x, k) => )} +
+ 定义说明与出处 +
+ +