diff --git a/backend/app/api/ledger.py b/backend/app/api/ledger.py index 431722bd..cb2e9a0a 100644 --- a/backend/app/api/ledger.py +++ b/backend/app/api/ledger.py @@ -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 diff --git a/backend/app/ledger/draftplan.py b/backend/app/ledger/draftplan.py index 931eefc7..0016f975 100644 --- a/backend/app/ledger/draftplan.py +++ b/backend/app/ledger/draftplan.py @@ -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): diff --git a/backend/tests/test_ledger_docdraft_api.py b/backend/tests/test_ledger_docdraft_api.py index 7e3e7df1..95f570eb 100644 --- a/backend/tests/test_ledger_docdraft_api.py +++ b/backend/tests/test_ledger_docdraft_api.py @@ -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): diff --git a/backend/tests/test_ledger_draftplan.py b/backend/tests/test_ledger_draftplan.py index 7ff2bd55..ecb01e3e 100644 --- a/backend/tests/test_ledger_draftplan.py +++ b/backend/tests/test_ledger_draftplan.py @@ -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"] diff --git a/frontend/src/pages/ledger/CaliberDrafts.tsx b/frontend/src/pages/ledger/CaliberDrafts.tsx index 4b76f059..773be7a9 100644 --- a/frontend/src/pages/ledger/CaliberDrafts.tsx +++ b/frontend/src/pages/ledger/CaliberDrafts.tsx @@ -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 (
{D.entryFieldNone}
} + {notes.length > 0 && ( +