入口字段声明不一致时合并,不把整份草稿判死

同一份文档分十轮起草,各轮对 factory_code 的必填/类型声明常对不上。原先直接抛错,34 条
指标的草稿因为一个字段的 required 不同整份作废。现在必填取并集、类型取更宽,每一处不一致
记一条说明摆在入口字段下面;建图和界面共用同一套合并逻辑。
This commit is contained in:
ThearchyHelios
2026-11-04 09:54:16 +08:00
parent 0d5f9609e7
commit 97cd9498a4
6 changed files with 82 additions and 33 deletions
+15 -12
View File
@@ -649,19 +649,21 @@ MAX_METRICS = 60
def _merged_fields(blocks: list[dict[str, Any]]) -> list[dict[str, Any]]:
"""各组声明的入口字段按名字合并去重(先出现的为准),给 payload 看:它是整张图的入口。
"""各组声明的入口字段合成一份,给界面看:它和建图用的是同一套合并(draftplan.merge_entry_fields)。
建图时 draftplan.build_graph 还会再合一次,并在同名冲突时抛 ValueError;这里只负责让界面看到一份像样的 entry_fields。
早先这里是「先出现的为准」的另一份实现,建图那边还会因为同名冲突抛错——两份逻辑说不到一起。现在只有一处。
"""
merged: dict[str, dict[str, Any]] = {}
for block in blocks:
for field in block.get("entry_fields") or []:
if not isinstance(field, dict):
continue
name = str(field.get("name") or "").strip()
if name and name not in merged:
merged[name] = dict(field)
return list(merged.values())
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 _fresh_metrics(metrics: list[dict[str, Any]], covered: list[str]) -> list[dict[str, Any]]:
@@ -695,6 +697,7 @@ def _draft_payload(*, entries: list[dict[str, Any]], blocks: list[dict[str, Any]
"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),
@@ -833,7 +836,7 @@ async def _extract_in_background(draft_id: str) -> None:
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
+37 -9
View File
@@ -493,8 +493,15 @@ 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) -> None:
"""入口字段按 name 合并;同名但类型或必填对不上就抛 ValueError,不拿其中一组去覆盖另一组。"""
def _merge_fields(target: dict[str, dict], value: Any, notes: list[str]) -> None:
"""入口字段按 name 合并,冲突取更保守的一边,**不抛错**。
同一份文档分几轮起草,各组对同一个字段的声明常常对不上:一组说必填、另一组给了缺省;一组按数、另一组按文本。
这不是错误——它们本来就是从不同批指标里各看了一次。合并时必填取并集(有一组要就必须给),类型取更宽的一个
(能填进文本),缺省和说明取先出现的非空值;每一处不一致记一条 note 交给人看,不悄悄拿一组覆盖另一组。
早先这里抛 ValueError,结果一份 34 条指标的草稿因为一个字段的 required 不同就整份作废——代价完全不成比例。
"""
for field in value if isinstance(value, list) else []:
if not isinstance(field, Mapping):
continue
@@ -507,9 +514,30 @@ def _merge_fields(target: dict[str, dict], value: Any) -> None:
if old is None:
target[name] = item
continue
if (str(old.get("type") or "string") != str(item.get("type") or "string")
or bool(old.get("required")) != bool(item.get("required"))):
raise ValueError(f"入口字段「{name}」在各组里的类型或必填不一致,不能合成一个入口")
merged = dict(old)
old_type = str(old.get("type") or "string")
new_type = str(item.get("type") or "string")
if old_type != new_type:
merged["type"] = "string" if "string" in (old_type, new_type) else old_type
notes.append(f"入口字段「{name}」在各组里的类型不一致({old_type} / {new_type}),按「{merged['type']}」合并")
if bool(old.get("required")) != bool(item.get("required")):
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, ""):
merged["default"] = item["default"]
if not merged.get("description") and item.get("description"):
merged["description"] = item["description"]
target[name] = merged
def merge_entry_fields(blocks: Any) -> tuple[list[dict], list[str]]:
"""各组草案的入口字段合成一份:(字段, 不一致的说明)。调用方把说明摆给起草的人看。"""
target: dict[str, dict] = {}
notes: list[str] = []
for block in _blocks(blocks):
_merge_fields(target, block.get("entry_fields"), notes)
return list(target.values()), notes
def build_graph(blocks: Any, *, name: str, question: str = "", datasource: str = "") -> dict:
@@ -518,9 +546,9 @@ def build_graph(blocks: Any, *, name: str, question: str = "", datasource: str =
blocks 是 [{"sql", "entry_fields", "metrics"}],也兼容老调用的单个 plan 字典(当成只有一组)。一组时取数节点仍叫
fetch,多组叫 fetch_1、fetch_2…(和 certify.build_graph 一致);第 i 组指标表达式里的 nodes.fetch 改写成对应的
节点 id,已经带下标的(nodes.fetch_2)不动。指标 id 跨组重名时按 m1…mN 全局重排,口径卡和出口写重排后的 id——
重名会让口径卡读错值,宁可换号也不能让两个指标共用一个 id。入口字段按 name 合并,同名但类型/必填对不上抛
ValueError(两份草案自己打架,调用方当成问题记下来)。datasource 是取数节点指向的数据源名,调用方按这次的数据源
补,也可以写在某一组里;各组指向不同数据源时用第一组的(草稿图只连一个数据源)。
重名会让口径卡读错值,宁可换号也不能让两个指标共用一个 id。入口字段按 name 合并,冲突取更保守的一边并记一条
note(见 merge_entry_fields),**不抛错**:一个字段的声明差异不该把整份草稿判死。datasource 是取数节点指向的
数据源名,调用方按这次的数据源补,也可以写在某一组里;各组指向不同数据源时用第一组的(草稿图只连一个数据源)。
调用两次结果逐字一致:节点 id、坐标都由输入决定,没有时间戳。报告配置、出口契约照 certify.build_graph 的写法,
发布门禁认的就是这一套(REPORT_POLICY、metrics_from、required)。
@@ -542,7 +570,7 @@ def build_graph(blocks: Any, *, name: str, question: str = "", datasource: str =
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"), [])
node_id = nid(index)
for metric in group.get("metrics") or []:
if not isinstance(metric, Mapping):
+8 -7
View File
@@ -575,15 +575,15 @@ async def test_a_round_that_brings_nothing_new_stops_the_loop_without_a_half_bui
assert [m["id"] for m in card["data"]["config"]["metrics"]] == ["m1"]
async def test_conflicting_entry_fields_across_groups_fail_the_draft_with_a_reason(
async def test_conflicting_entry_field_declarations_are_merged_not_failed(
client, documents, datasource, monkeypatch):
"""两组都声明 region,但类型对不上:合不成一个入口,build_graph 抛 ValueError,草稿照实记为失败。"""
"""两组把 region 声明得不一样:必填取并集、类型取更宽,草稿照常出图,只把不一致摆给人看。"""
doc = await documents("dd-rounds-clash")
source_id = await datasource()
first = json.loads(plan_reply(f"{doc}-2"))
first["uncovered"] = ["已支付订单数"]
second = json.loads(plan_reply(f"{doc}-2"))
second["entry_fields"] = [{**second["entry_fields"][0], "type": "integer"}]
second["entry_fields"] = [{**second["entry_fields"][0], "required": True}]
second["metrics"] = [{**second["metrics"][0], "id": "m2", "name": "已支付订单数",
"expression": "cell(nodes.fetch, 0, 'orders')"}]
second["uncovered"] = []
@@ -591,10 +591,11 @@ async def test_conflicting_entry_fields_across_groups_fail_the_draft_with_a_reas
body = await extract(client, doc, source_id)
made = (await client.get(f"/api/ledger/drafts/{body['draft_id']}")).json()
assert made["status"] == "failed" and made["workflow_id"] is None
assert "入口字段" in made["error"] and "store" in made["error"]
assert made["metric_count"] == 2 and made["progress"] == {"round": 2, "covered": 2, "uncovered": 0}
assert any(d["name"] == "入口字段" for d in made["dropped"])
assert made["status"] in ("ready", "questions_open") and made["workflow_id"] is not None
assert made["metric_count"] == 2 and made["blocks"] == 2
fields = {f["name"]: f for f in made["entry_fields"]}
assert fields["store"]["required"] is True
assert any("store" in note for note in made["entry_notes"])
async def test_a_real_trial_run_reads_values_from_both_fetch_nodes(client, documents, tmp_path, monkeypatch):
+14 -5
View File
@@ -224,17 +224,26 @@ def test_build_graph_accepts_one_block_as_a_single_group():
assert card["metrics"][0]["expression"] == "cell(nodes.fetch, 0, 'gmv')"
def test_build_graph_merges_entry_fields_and_refuses_conflicting_ones():
def test_build_graph_merges_entry_fields_and_widens_conflicts():
"""同名字段在各组里声明不一致时按更保守的一边合并,不抛错:一个字段的差异不该让整份草稿作废。"""
same = block("SELECT 1 AS gmv FROM orders", [dict(METRIC)])
merged = draftplan.build_graph([same, block("SELECT 2 AS cnt FROM orders", [{**METRIC, "id": "m2"}])],
name="x")
fields = next(node for node in merged["nodes"] if node["id"] == "start")["data"]["config"]["fields"]
assert [f["name"] for f in fields] == ["start"]
conflicting = block("SELECT 1 AS gmv FROM orders", [dict(METRIC)],
fields=[{**START, "required": False}])
with pytest.raises(ValueError, match="start"):
draftplan.build_graph([same, conflicting], name="x")
not_required = block("SELECT 1 AS gmv FROM orders", [dict(METRIC)],
fields=[{**START, "required": False}])
fields, notes = draftplan.merge_entry_fields([same, not_required])
assert fields[0]["required"] is True
assert any("start" in note and "必填" in note for note in notes)
typed = block("SELECT 1 AS gmv FROM orders", [dict(METRIC)],
fields=[{**START, "type": "integer"}])
fields, notes = draftplan.merge_entry_fields([same, typed])
assert fields[0]["type"] == "string"
assert any("start" in note and "类型" in note for note in notes)
assert draftplan.build_graph([same, typed], name="x")["nodes"]
@@ -497,6 +497,7 @@ export function CaliberDrafts() {
/** 入口字段:发布后按这些字段收参数 */
const renderEntryFields = (draft: CaliberDraft) => {
const fields = draft.entry_fields
const notes = draft.entry_notes ?? []
if (!fields) return null
return (
<div data-draft-entry-fields="">
@@ -518,6 +519,11 @@ export function CaliberDrafts() {
})}
</ul>
) : <p className="text-2xs text-faint">{D.entryFieldNone}</p>}
{notes.length > 0 && (
<ul className="mt-1 space-y-0.5 text-2xs leading-relaxed text-warn" data-draft-entry-notes="">
{notes.map((note, i) => <li key={`${i}:${note}`}>{note}</li>)}
</ul>
)}
</div>
)
}
+2
View File
@@ -566,6 +566,8 @@ export interface CaliberDraft extends CaliberDraftSummary {
metrics?: DraftMetric[]
/** 草稿图的入口字段 */
entry_fields?: DraftEntryField[]
/** 入口字段在各组里声明不一致的地方(必填取并集、类型取更宽),合并时记下的说明 */
entry_notes?: string[]
dropped?: DraftDropped[]
open_questions?: string[]
/** 还没有起草出来的指标名称:续跑跑到轮数上限或没有新指标后剩下的,可以再起一份 */