fix: stage CAD repair validation gates

This commit is contained in:
chenpei 2026-09-08 12:01:06 +08:00
parent cbdddb4eb4
commit 1f1e878b75
4 changed files with 118 additions and 12 deletions

View File

@ -321,16 +321,19 @@ def build_and_authoritatively_validate(ai3d, build_dir: pathlib.Path, source_nam
def round_blocker_count(row: dict) -> int:
"""Count end-to-end blockers for monotonic repair acceptance.
"""Count blockers at the currently authoritative validation stage.
Compiler issues alone are insufficient: an edit can remove a loose-bounds
warning while creating more exact B-rep clashes. A candidate therefore
has to improve the complete compile/manufacturing/kernel/visual chain.
B-rep and visual diagnostics generated from a graph that still fails its
structural compiler are useful artifacts, but they are not stable repair
targets: moving a part to address one such clash can multiply topology
errors. Until compilation passes, compare compiler blockers only. After it
passes, require monotonic improvement across the later gates as before.
"""
compiled = row.get("compiled") or {}
total = len(compiled.get("issues") or [])
if compiled.get("ok"):
total += len((row.get("manufacturing_plan") or {}).get("issues") or [])
if not compiled.get("ok"):
return total
total += len((row.get("manufacturing_plan") or {}).get("issues") or [])
kernel_feedback = row.get("cad_kernel_feedback") or []
total += len(kernel_feedback)
if row.get("cad_kernel_error") and not kernel_feedback:
@ -361,6 +364,36 @@ def has_minimum_axis_packing_dead_end(feedback: list[dict]) -> bool:
)
def qualified_occupied_scope_adjudication(dimension_ledger: dict, axis: str) -> dict | None:
"""Return an already-qualified overall-arrow fact for one axis.
Dimension qualification is independent from graph generation. When it has
accepted an arrow explicitly scoped as ``overall``, a later collision is
evidence that the graph is wrong, not that the drawing became ambiguous.
"""
fact = (dimension_ledger or {}).get(axis) or {}
if fact.get("ledger_scope") != "occupied_overall":
return None
source_ids = sorted(set(fact.get("source_image_ids") or []))
if not source_ids:
return None
return {
"decision": "occupied_overall_dimension",
"axis": axis,
"confidence": round(float(fact.get("confidence") or 1.0), 4),
"evidence_image_ids": source_ids,
"evidence": (
f"尺寸资格门已将{axis.upper()}方向箭头验收为成品整体外形:"
f"{fact.get('text') or fact.get('value_mm')}"
)[:1000],
"requires_user_question": False,
"question_focus": None,
"single_user_question": None,
"question_options": [],
"source": "qualified_overall_dimension_ledger",
}
def normalize_focused_source_adjudication(
value, *, image_ids: set[str], required_question_focus: str | None = None,
) -> dict:
@ -1456,9 +1489,18 @@ def main():
+ evidence_context
)
if graph:
compile_gate_instruction = ""
if accepted_compile_issue_count not in (None, 0):
compile_gate_instruction = (
f"当前结构编译器仍有{accepted_compile_issue_count}项阻塞;本轮只能修改"
"编译器反馈中明确列出的稳定ID和路径,不得为了诊断草图的"
"B-rep碰撞或外观问题改动其他零件、总成、尺寸或定位。"
"一次只修一个反馈优先级的问题族,修复后必须保留其他已通过约束。"
)
prompt = (
"你在修正一份已存在的参数化家具结构图谱。必须回看本轮所有原图,并只返回局部JSON修改,不能返回整份图谱。"
"每条操作为add/replace/remove和JSON Pointer路径;修复引用、参数、实例位置、接触面或遗漏部件。"
+ compile_gate_instruction
+ "每条操作为add/replace/remove和JSON Pointer路径;修复引用、参数、实例位置、接触面或遗漏部件。"
"不能通过放大公差、删除有原图证据的部件/连接或改低置信度来逃避问题。"
"一次最多24条patch,优先级固定为:原图整体尺寸冲突、总成局部边界错误、五金安装宿主/安装面错误、实体穿插、已定义却无实例的部件、总成归属/总成关系错误、几何未真实接触、其他外观差异。"
"若零件observed_quantity大于实例数,必须为每个真实物理件补instances,不能只保留零件定义。"
@ -1677,6 +1719,15 @@ def main():
if not row.get("patch_error") and isinstance(row.get("compiled"), dict):
candidate_blocker_count = round_blocker_count(row)
row["end_to_end_blocker_count"] = candidate_blocker_count
if row["compiled"].get("ok") is not True:
# Keep diagnostic kernel/render artifacts in the report,
# but do not feed their secondary failures into the next
# graph repair while the structural contract is invalid.
feedback = enrich_hardware_feedback_with_verified_placements(
graph, row["compiled"], list(row["compiled"].get("issues") or []),
compile_candidate=compile_candidate_graph,
)
row["repair_feedback_stage"] = "compiler_only"
if (
round_base_graph
and accepted_total_blocker_count is not None
@ -1723,6 +1774,7 @@ def main():
}
if (
row.get("patch_error")
and round_base_compile_issue_count == 0
and has_minimum_axis_packing_dead_end(round_base_feedback)
and not (resolved_scope_axes & dead_end_axes)
):
@ -1750,8 +1802,12 @@ def main():
"case": case, "phase": "ai_focused_source_adjudication",
"dead_end_count": len(dead_end_feedback),
}, ensure_ascii=False), flush=True)
qualified_overall = (
qualified_occupied_scope_adjudication(dimension_ledger, dead_end_axis)
if required_question_focus == "overall_dimension_scope" else None
)
try:
adjudication_response = engine._request_model_json({
adjudication_response = qualified_overall or engine._request_model_json({
"model": args.model,
"messages": [{"role": "user", "content": images + [{
"type": "text", "text": (
@ -1794,6 +1850,31 @@ def main():
else:
report["status"] = "focused_source_adjudication_ready_for_graph_repair"
report["blocking"]["next_owner"] = "graph_repair_with_independent_scope_adjudication"
scope_fact = normalize_scope_facts({
"id": f"qualified-scope-{focused['axis']}",
"source_type": "independent_source_adjudication",
"axis": focused["axis"],
"ledger_scope": (
"occupied_overall"
if focused["decision"] == "occupied_overall_dimension" else "reference"
),
"confidence": focused["confidence"],
"source_image_ids": focused["evidence_image_ids"],
"text": focused["evidence"],
}, image_ids=image_ids, dimension_ledger=dimension_ledger)[0]
scope_facts = [
item for item in scope_facts if item["axis"] != scope_fact["axis"]
] + [scope_fact]
graph, bound_user_evidence, bound_scope_adjudications, binding_audit = bind_scope_facts(
graph, scope_facts,
)
report["focused_scope_binding_audit"] = binding_audit
feedback = [{
"code": "qualified_overall_scope_preserved",
"axis": focused["axis"],
"detail": "整体尺寸已由原图资格门确认为成品外包围,禁止再询问或改为柜体参考尺寸。",
"correction": "保持该整体尺寸,仅修正门板/部件厚度、内嵌关系、局部位置或有原图依据的避让加工。",
}] + dead_end_feedback
except Exception as exc:
report["focused_source_adjudication_error"] = f"{type(exc).__name__}: {exc}"
focused = deterministic_dead_end_question(
@ -1808,6 +1889,8 @@ def main():
(case_dir / "report.json").write_text(
json.dumps(report, ensure_ascii=False, indent=2), encoding="utf-8"
)
if report["status"] == "focused_source_adjudication_ready_for_graph_repair":
continue
break
if (
row.get("patch_error")

View File

@ -133,6 +133,13 @@ def build_dimension_ledger(labels: list[dict], *, source_image_id: str) -> dict:
"source_image_ids": [source_image_id],
"text": str(row.get("text") or "")[:120],
"location": str(row.get("location") or "")[:200],
# This ledger accepts only labels whose independent reader has
# already classified the arrow as ``scope=overall``. Persist that
# semantic contract so a later CAD collision cannot turn an
# authoritative finished-product dimension into a carcass/opening
# dimension merely to make an invalid graph fit.
"ledger_scope": "occupied_overall",
"scope_source": "qualified_overall_arrow",
})
ledger = {"conflicts": [], "rejected": rejected}
for axis, rows in candidates.items():

View File

@ -17,6 +17,7 @@ from probe_structure_graph import (
diagnostic_mounting_clearance_cuts, has_minimum_axis_packing_dead_end,
enrich_hardware_feedback_with_verified_placements,
normalize_focused_source_adjudication, normalize_scope_facts,
qualified_occupied_scope_adjudication,
normalize_visual_review,
propose_reference_scope_projection, refresh_compiled_kernel_feedback,
round_blocker_count, select_resume_checkpoint, source_for_role,
@ -439,7 +440,7 @@ class ManufacturingOracleTest(unittest.TestCase):
self.assertEqual({"x": 12.0, "y": 20.0, "z": 30.0}, feedback[0]["overlap_size_mm"])
self.assertIn("/instances/left-door/placement", feedback[0]["legal_patch_roots"])
def test_repair_score_counts_kernel_regression_after_compile_improvement(self):
def test_repair_score_uses_compiler_gate_before_kernel_and_visual(self):
previous = {
"compiled": {"ok": False, "issues": [{}, {}, {}, {}, {}, {}, {}]},
"cad_kernel_feedback": [{}, {}, {}],
@ -448,9 +449,9 @@ class ManufacturingOracleTest(unittest.TestCase):
"compiled": {"ok": False, "issues": [{}, {}, {}, {}, {}]},
"cad_kernel_feedback": [{}, {}, {}, {}, {}, {}, {}, {}, {}, {}, {}, {}, {}, {}, {}],
}
self.assertEqual(10, round_blocker_count(previous))
self.assertEqual(20, round_blocker_count(candidate))
self.assertGreater(round_blocker_count(candidate), round_blocker_count(previous))
self.assertEqual(7, round_blocker_count(previous))
self.assertEqual(5, round_blocker_count(candidate))
self.assertLess(round_blocker_count(candidate), round_blocker_count(previous))
compile_pass_with_hidden_review_failure = {
"compiled": {"ok": True, "issues": []},
@ -459,6 +460,19 @@ class ManufacturingOracleTest(unittest.TestCase):
}
self.assertEqual(1, round_blocker_count(compile_pass_with_hidden_review_failure))
def test_qualified_overall_arrow_cannot_be_reopened_by_cad_collision(self):
decision = qualified_occupied_scope_adjudication({
"y": {
"value_mm": 400, "confidence": 0.97,
"source_image_ids": ["image4"], "text": "400",
"ledger_scope": "occupied_overall",
},
}, "y")
self.assertEqual("occupied_overall_dimension", decision["decision"])
self.assertFalse(decision["requires_user_question"])
self.assertEqual("qualified_overall_dimension_ledger", decision["source"])
self.assertIsNone(qualified_occupied_scope_adjudication({"y": {"value_mm": 400}}, "y"))
def test_compile_lower_bound_compares_with_end_to_end_score(self):
self.assertTrue(compile_lower_bound_can_improve(4, 10))
self.assertFalse(compile_lower_bound_can_improve(10, 10))

View File

@ -598,6 +598,8 @@ class StructureGraphTest(unittest.TestCase):
{"axis": "z", "value": 1100, "unit": "mm", "scope": "overall", "confidence": 0.72},
], source_image_id="dimension")
self.assertEqual(800, ledger["x"]["value_mm"])
self.assertEqual("occupied_overall", ledger["x"]["ledger_scope"])
self.assertEqual("qualified_overall_arrow", ledger["x"]["scope_source"])
self.assertNotIn("y", ledger)
self.assertNotIn("z", ledger)
conflicted = build_dimension_ledger([