Files
AgentLab/backend/app/ledger/docattach.py
T
ThearchyHelios 7f66985dba 口径草稿:从业务文档抽定义,再从一次真实运行接上取数路径
新增 caliber_drafts 表与 /api/ledger/drafts 一组接口:抽取在后台跑,文档缺失整单拒绝,
有未决问题时停在 questions_open,问题清完才到 path_pending。

接上运行沿用从运行固化的建图逻辑,定义按同名或同义词并入指标卡,expression 与 id 不动;
文档引用记进 origin.documents,复核能落回具体段落。接入只产出可试跑的草稿,不发布、不发起运行。
2026-11-03 22:50:14 +08:00

187 lines
9.5 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""文档草稿接上一次真实运行:定义来自文档,取数路径来自运行(docs/design/next-round/02-口径账本.md 做法 8 的另一半)。
**为什么必须由运行接上。** 文档里写的是业务说法,不是库里的表、列和 SQL(见 ledger/docdraft)。取数路径只能来自一次真的跑过、
结果对得上的运行:查了什么、结果长什么样、报告引用了哪一条,都在这次运行的封存范围里。所以接上之后工作流里的取数节点照抄
运行里那条查询(跨轮变化的字面量按 certify.build_graph 的办法变成入口参数),文档只贡献定义。
**为什么只并定义、不改式子。** 草稿不知道取数,改写 expression 或指标 id 会破坏已经证明过的算法。并进同名或同义词的指标,
expression、id、单位这些原样保留;对不上的指标名进 warnings,由人决定是新建一条还是忽略,而不是让整次接入失败。
**为什么要记文档引用。** 定义的事实来源是文档里那几句原文。origin.documents 按指标定义里的引用汇总去重,记下 document_id、
chunk_id、ordinal 和逐字 quote:之后复核、追责都能落回具体段落,不必再相信「草稿就是这么写的」。ordinal 从库里补(草稿的
引用只带 chunk,段落序号只有 chunks 表知道),chunk 已被删掉时照抄引用里的值,读不到就留空。
**为什么这一路不发布、不发起运行、不写审计。** 接入只产出一份可试跑的草稿:发布是人的决定,发起运行是另一个动作。
它和 freeze 的区别只在定义从哪来、以及 origin 里多记了 draft_id 和 documents。
"""
from __future__ import annotations
from collections.abc import Mapping
from typing import Any
from sqlalchemy import select
from app.db.models import Run
#: 接不上时的说法(给人看)
NOT_FOUND = "运行记录不存在,或没有权限读这次运行"
NOT_SUCCEEDED = "这次运行没有成功结束,不能接上文档草稿"
NOT_MATCHED = "这次运行还没有比对口径,比对完才能把文档草稿接上取数"
NO_QUERIES = "这次运行的报告没有引用任何查询结果,没有可接上的取数"
SNAPSHOT = "查询快照读不出来(文件缺失或与哈希不一致),不能接上文档草稿"
UNPARSED = "查询无法规范化({reason}),不能接上文档草稿"
NOT_MERGED = "这次运行没有单行结果的数值列,草稿里的定义没有可并入的口径卡"
DESCRIPTION = ("从业务文档起草的口径:定义来自文档草稿,{n} 个指标,取数路径接自运行 {run_id};"
"经人工审核后再发布")
class AttachRefused(Exception):
"""接不上。status 是给接口用的 HTTP 状态(现在都是 409),message 给人看。"""
def __init__(self, status: int, message: str) -> None:
super().__init__(message)
self.status = status
self.message = message
@property
def status_code(self) -> int:
return self.status
def _citation_rows(payload: dict[str, Any]) -> list[dict[str, Any]]:
"""草稿各指标的定义引用,按 (document_id, chunk, quote) 去重,先后照草稿。"""
rows: list[dict[str, Any]] = []
seen: set[tuple[str, str, str]] = set()
metrics = payload.get("metrics")
for metric in metrics if isinstance(metrics, list) else []:
if not isinstance(metric, Mapping):
continue
definition = metric.get("definition")
citations = definition.get("citations") if isinstance(definition, Mapping) else None
if not isinstance(citations, list):
citations = metric.get("citations")
for citation in citations if isinstance(citations, list) else []:
if not isinstance(citation, Mapping):
continue
chunk_id = citation.get("chunk") or citation.get("chunk_id") or citation.get("chunkId")
quote = citation.get("quote")
if not isinstance(chunk_id, str) or not chunk_id:
continue
if not isinstance(quote, str) or not quote.strip():
continue
document_id = str(citation.get("document_id") or "")
key = (document_id, chunk_id, quote)
if key in seen:
continue
seen.add(key)
ordinal = citation.get("ordinal")
rows.append({"document_id": document_id, "chunk_id": chunk_id,
"ordinal": ordinal if isinstance(ordinal, int) else None, "quote": quote})
return rows
async def _documents(session: Any, rows: list[dict[str, Any]]) -> list[dict[str, Any]]:
"""引用补上库里记的 document_id / ordinal(见模块说明);库里查不到的照抄引用里的值。"""
from app.db.models import Chunk
ids = list(dict.fromkeys(row["chunk_id"] for row in rows))
found: dict[str, tuple[str, int]] = {}
if ids:
for chunk_id, document_id, ordinal in (await session.execute(
select(Chunk.id, Chunk.document_id, Chunk.ordinal).where(Chunk.id.in_(ids)))).all():
found[str(chunk_id)] = (str(document_id), int(ordinal))
for row in rows:
if row["chunk_id"] in found:
row["document_id"], row["ordinal"] = found[row["chunk_id"]]
return rows
async def attach(session: Any, *, payload: dict, name: str, run_id: str, draft_id: str = "",
principal: Any = None) -> dict:
"""把文档草稿接上一次真实运行,落库成新的草稿工作流。
返回 {workflow_id, version, metrics, warnings}:metrics 是口径卡上并完定义之后的指标(id / expression 没动),warnings 是
合并时对不上的那些指标(原样交给界面)。不能接时抛 AttachRefused(409)。payload 的形状是草稿接口交出来的那一份:
{question?, metrics: [docdraft 的指标对象]}。不发布、不发起运行、不写审计。
"""
from app.api.deps import can_read_run, current_principal, ensure_role
from app.api.evidence import _sealed
from app.core.artifact_store import graph_hash, load
from app.db.models import DataSource, Workflow, WorkflowVersion
from app.ledger import certify, docdraft, store
ensure_role("builder")
principal = principal if principal is not None else current_principal()
run = await session.get(Run, run_id)
if run is None or not await can_read_run(principal, run):
raise AttachRefused(409, NOT_FOUND)
if run.status != "succeeded":
raise AttachRefused(409, NOT_SUCCEEDED)
recorded = await store.latest(run_id)
if recorded is None or recorded.content is None:
raise AttachRefused(409, NOT_MATCHED)
queries = certify.referenced_queries(recorded.content)
if not queries:
raise AttachRefused(409, NO_QUERIES)
kinds = {n: k for n, k in (await session.execute(select(DataSource.name, DataSource.kind))).all()}
# 封存的查询快照(和 freeze 同一个读法):比对工件只认封存范围内的查询,这里再按形状解析一次
await _sealed(run_id)
shapes: list[certify.Shape] = []
snaps: list[dict[str, Any]] = []
for artifact in queries:
try:
snap = load(artifact)
except ValueError:
raise AttachRefused(409, SNAPSHOT) from None
shape = certify.shape_of(snap, kinds)
if shape is None:
raise AttachRefused(409, SNAPSHOT)
if shape.problem:
raise AttachRefused(409, UNPARSED.format(reason=shape.problem))
shapes.append(shape)
snaps.append(snap)
question = payload.get("question")
if not isinstance(question, str) or not question.strip():
question = name
graph, _fields, _metrics, _multi = await certify.build_graph(
shapes=shapes, snaps=snaps, kinds=kinds,
labels=lambda snap, source: certify._labels(session, snap, source),
name=name, question=question)
# 定义并进同名 / 同义词的指标;表达式与 id 不动(见模块说明)
drafts = payload.get("metrics")
drafts = drafts if isinstance(drafts, list) else []
card = next((n for n in graph.get("nodes") or []
if isinstance(n, dict) and n.get("type") == "metrics"), None)
merged: list[dict[str, Any]] = []
warnings: list[str] = []
if card is None:
warnings.append(NOT_MERGED)
else:
config = card.setdefault("data", {}).setdefault("config", {})
card_metrics = config.get("metrics")
merged, warnings = docdraft.merge_definition(
card_metrics if isinstance(card_metrics, list) else [], drafts)
config["metrics"] = merged
if name.strip():
config["caliber"] = name.strip()[:40]
documents = await _documents(session, _citation_rows(payload))
workflow = Workflow(
name=(name.strip() or "文档口径草稿")[:200],
description=DESCRIPTION.format(n=len(merged), run_id=run_id),
graph=graph, status="draft", tags=["doc_draft"],
origin={"run_id": run_id, "freeze": True, "sources": [{"run_id": run_id}],
"draft_id": draft_id, "documents": documents})
session.add(workflow)
await session.flush()
session.add(WorkflowVersion(workflow_id=workflow.id, version=1, graph=graph, graph_hash=graph_hash(graph),
note=f"从文档草稿接上运行 {run_id}"))
await session.commit()
return {"workflow_id": workflow.id, "version": 1, "metrics": merged, "warnings": warnings}
__all__ = ["AttachRefused", "attach"]