From 1f1e878b7576a2826530cf5ad344a2fc4c233d25 Mon Sep 17 00:00:00 2001 From: chenpei Date: Tue, 8 Sep 2026 12:01:06 +0800 Subject: [PATCH] fix: stage CAD repair validation gates --- .../probe_structure_graph.py | 99 +++++++++++++++++-- .../ai-solid-wood-cost/structure_graph.py | 7 ++ .../test_manufacturing_oracle.py | 22 ++++- .../test_structure_graph.py | 2 + 4 files changed, 118 insertions(+), 12 deletions(-) diff --git a/server_patch/ai_3d_model/ai-solid-wood-cost/probe_structure_graph.py b/server_patch/ai_3d_model/ai-solid-wood-cost/probe_structure_graph.py index baa8b66..76d73b5 100644 --- a/server_patch/ai_3d_model/ai-solid-wood-cost/probe_structure_graph.py +++ b/server_patch/ai_3d_model/ai-solid-wood-cost/probe_structure_graph.py @@ -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") diff --git a/server_patch/ai_3d_model/ai-solid-wood-cost/structure_graph.py b/server_patch/ai_3d_model/ai-solid-wood-cost/structure_graph.py index 33663b3..cd2aaea 100644 --- a/server_patch/ai_3d_model/ai-solid-wood-cost/structure_graph.py +++ b/server_patch/ai_3d_model/ai-solid-wood-cost/structure_graph.py @@ -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(): diff --git a/server_patch/ai_3d_model/ai-solid-wood-cost/test_manufacturing_oracle.py b/server_patch/ai_3d_model/ai-solid-wood-cost/test_manufacturing_oracle.py index 616e26f..1f655ea 100644 --- a/server_patch/ai_3d_model/ai-solid-wood-cost/test_manufacturing_oracle.py +++ b/server_patch/ai_3d_model/ai-solid-wood-cost/test_manufacturing_oracle.py @@ -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)) diff --git a/server_patch/ai_3d_model/ai-solid-wood-cost/test_structure_graph.py b/server_patch/ai_3d_model/ai-solid-wood-cost/test_structure_graph.py index aae34b0..b9d5049 100644 --- a/server_patch/ai_3d_model/ai-solid-wood-cost/test_structure_graph.py +++ b/server_patch/ai_3d_model/ai-solid-wood-cost/test_structure_graph.py @@ -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([