mirror of
https://github.com/ThearchyHelios/AgentLab.git
synced 2026-11-15 00:51:59 +00:00
入口字段声明不一致时合并,不把整份草稿判死
同一份文档分十轮起草,各轮对 factory_code 的必填/类型声明常对不上。原先直接抛错,34 条 指标的草稿因为一个字段的 required 不同整份作废。现在必填取并集、类型取更宽,每一处不一致 记一条说明摆在入口字段下面;建图和界面共用同一套合并逻辑。
This commit is contained in:
+15
-12
@@ -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
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -566,6 +566,8 @@ export interface CaliberDraft extends CaliberDraftSummary {
|
||||
metrics?: DraftMetric[]
|
||||
/** 草稿图的入口字段 */
|
||||
entry_fields?: DraftEntryField[]
|
||||
/** 入口字段在各组里声明不一致的地方(必填取并集、类型取更宽),合并时记下的说明 */
|
||||
entry_notes?: string[]
|
||||
dropped?: DraftDropped[]
|
||||
open_questions?: string[]
|
||||
/** 还没有起草出来的指标名称:续跑跑到轮数上限或没有新指标后剩下的,可以再起一份 */
|
||||
|
||||
Reference in New Issue
Block a user