From 695090d05abf544bc7d52d79027c8a9827ffcd07 Mon Sep 17 00:00:00 2001 From: chenpei Date: Tue, 8 Sep 2026 00:09:22 +0800 Subject: [PATCH] feat: gate CAD dimension scope conflicts --- .../STRUCTURE_GRAPH_UPGRADE.md | 8 + .../ai-solid-wood-cost/VALIDATION_LEDGER.md | 8 + .../probe_structure_graph.py | 207 +++++++++++++++++- .../ai-solid-wood-cost/structure_graph.py | 92 +++++++- .../test_manufacturing_oracle.py | 78 ++++++- .../test_structure_graph.py | 72 ++++++ 6 files changed, 459 insertions(+), 6 deletions(-) diff --git a/server_patch/ai_3d_model/ai-solid-wood-cost/STRUCTURE_GRAPH_UPGRADE.md b/server_patch/ai_3d_model/ai-solid-wood-cost/STRUCTURE_GRAPH_UPGRADE.md index e8b21ef..08b9603 100644 --- a/server_patch/ai_3d_model/ai-solid-wood-cost/STRUCTURE_GRAPH_UPGRADE.md +++ b/server_patch/ai_3d_model/ai-solid-wood-cost/STRUCTURE_GRAPH_UPGRADE.md @@ -86,6 +86,14 @@ - 若冲突两侧实体在最小分离轴上都没有任何可行区间,该问题标记`minimum_separation_axis_infeasible=true`。AI不得继续往返移件,只能依证据修正尺寸/外形口径/覆盖与内嵌语义,或建立显式槽口、避让等加工几何。 - 无用户参与的隔离探针在一轮原图修复未使端到端阻塞严格下降时立即停止,输出`geometry_dead_end_requires_focused_source_adjudication`。正式交互应先调用AI聚焦原图复读;只有高置信尺寸仍相互矛盾时,向用户集中询问一个能决定结构的问题,不重启全流程。 +## v102-v104 实际占用包络、参考尺寸口径与单问题兜底(2026-09-08) + +- `envelope_mm`固定表示包含门板、拉手等凸出件的实际占用外形;图纸若明确只标柜体、开口或框架,可另建`reference_bounds_mm`。结构图谱不能自行把原图“整体尺寸”改成参考尺寸,必须引用真实用户确认,或引用独立聚焦原图裁决且置信度不低于0.8。 +- 编译结果同时输出`occupied_envelope_mm`、`reference_bounds_mm`和`dimension_scope_audit`;参考包络必须完全落在实际占用包络内。这样可表达“柜体深400mm、门板外覆后成品深420mm”,而不是通过移动门板或缩短层板伪造无碰撞。 +- v102聚焦复读虽正确判断原图尺寸冲突,但生成的问题暴露内部几何过程,并给出“把背板加厚”等会扩大占用空间的错误选项;该轮判定失败。v103增加长度和术语门禁后,模型又跳过柜门,直接追问层板与背板尺寸;仍判定失败。 +- v104由结构化冲突中的门板角色、板厚轴和最短分离轴确定首问必须是`overall_dimension_scope`。多模态模型连续两次仍返回过长选项,被输出校验器拒绝;系统没有重试循环,而是生成受控兜底问题:“尺寸图标注的总深 400mm,是否包含柜门厚度?”,只给“包含柜门(成品最外尺寸)/不包含柜门(只算柜体/框架)”两个选项。 +- v104证明的是防循环和用户提问边界生效,不是AI图像裁决成功:报告明确保存模型两次格式失败和`deterministic_dead_end_question_fallback`来源。它仍停在单一事实待确认状态,没有交付CAD、BOM或成本,也未部署生产。 + ## 同行公开资料的可用结论 Zoo公开说明其代理会操作可编辑的参数化模型,执行几何代码,读取质量/体积等模型信息,并用多视图快照反复检查。我们借鉴“结构表示+几何执行反馈+视觉反馈”机制,不能据其宣传推断多张家具图片已经可以稳定得到生产级完整BOM,也不能据此承诺成本误差5%。 diff --git a/server_patch/ai_3d_model/ai-solid-wood-cost/VALIDATION_LEDGER.md b/server_patch/ai_3d_model/ai-solid-wood-cost/VALIDATION_LEDGER.md index 93285cc..4996215 100644 --- a/server_patch/ai_3d_model/ai-solid-wood-cost/VALIDATION_LEDGER.md +++ b/server_patch/ai_3d_model/ai-solid-wood-cost/VALIDATION_LEDGER.md @@ -257,3 +257,11 @@ - 编译器新增精确单轴区间扣减:在所属总成/产品允许边界内,计算移动件不与所有切向相交实体碰撞的全部下坐标区间。该样例层板Y轴允许区间[0,20]被门板与背板完全覆盖;背板Y轴[0,391]也无可行子区间,因此返回`minimum_separation_axis_infeasible=true`,不再把局部位移候选误当成解。 - v101从同一已审计检查点隔离续跑,刷新基线为3个编译阻塞+1个真实内核碰撞。AI一轮修复产生11个编译问题,被拒绝后立即停止;最终状态`geometry_dead_end_requires_focused_source_adjudication`,仅执行1轮,没有再循环。下一交互节点必须先由AI聚焦复核外形口径、覆盖/内嵌关系、零件深度或明确避让加工;仍无法消解时只问用户一个聚焦问题。 - 最终完整回归205项全部通过(239.624秒)。以上现场运行均为`/tmp`隔离试验,每次启动前确认真实Chat API活动、等待、持久队列均为0;未覆盖生产文件、未重启生产服务。真实多产品盲测、生产级孔位模板和冻结后成本误差≤5%仍未完成。 + +## 2026-09-08 v102-v104 尺寸口径证据门与用户问题收敛(候选,未上线) + +- 新增实际占用包络与参考包络双层语义。原图账本仍默认核对实际占用外形;把某一轴改判为柜体/开口参考尺寸时,必须由会话事实或本轮原图独立裁决授权,不能由待修复图谱自证。参考包络越出实际占用外形会被阻断。 +- v102隔离运行正确识别尺寸源冲突,但向用户暴露过多内部技术描述并给出会扩大占用空间的错误方案;v103虽拦截内部术语,仍把问题降到层板/背板局部尺寸,遗漏更上游的“总深是否包含柜门”口径。两轮均作为失败证据保留,未交付用户。 +- v104启动前服务器只读检查为`active=0 waiting=0 dewu_busy=0 dewu_queue=0 durable_inflight=0`。从同一已审计v96检查点恢复,只执行一轮:基线3个编译阻塞和1个真实B-rep冲突;AI候选造成11个编译问题被拒绝;随后进入一次聚焦原图裁决,没有继续前后移动循环。 +- v104多模态模型连续2次仍因选项过长未通过输出校验,系统明确记录`focused_source_adjudication_error`,并使用`deterministic_dead_end_question_fallback`安全收口为一个问题:“尺寸图标注的总深 400mm,是否包含柜门厚度?”,选项仅为“包含柜门(成品最外尺寸)”与“不包含柜门(只算柜体/框架)”。报告位于服务器隔离目录`/tmp/dalongxia-cad-v85-eI8HeF/probe-sideboard19/sideboard/report.json`。 +- 本地新增参考尺寸证据门、问题优先级、焦点不匹配拒绝和确定性兜底专项检查后,结构图谱/制造层74项通过;最终候选完整回归210项通过(235.977秒)。v104仍不等于图像裁决或制造模型通过,生产服务未改、未重启。 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 150548d..3807d8b 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 @@ -213,6 +213,139 @@ def has_minimum_axis_packing_dead_end(feedback: list[dict]) -> bool: ) +def normalize_focused_source_adjudication( + value, *, image_ids: set[str], required_question_focus: str | None = None, +) -> dict: + """Validate the one-shot AI decision used after a proven geometry dead end.""" + if not isinstance(value, dict): + raise ValueError("聚焦尺寸裁决必须返回JSON对象") + decision = value.get("decision") + allowed = { + "occupied_overall_dimension", "reference_carcass_or_opening_dimension", + "explicit_machining_relief", "source_dimensions_conflict", + } + if decision not in allowed: + raise ValueError("聚焦尺寸裁决decision不在受控枚举中") + axis = value.get("axis") + confidence = value.get("confidence") + evidence_ids = value.get("evidence_image_ids") or [] + if axis not in {"x", "y", "z"}: + raise ValueError("聚焦尺寸裁决缺少有效轴") + if ( + isinstance(confidence, bool) or not isinstance(confidence, (int, float)) + or not 0 <= float(confidence) <= 1 + ): + raise ValueError("聚焦尺寸裁决置信度无效") + if ( + not isinstance(evidence_ids, list) or not evidence_ids + or not set(evidence_ids).issubset(image_ids) + ): + raise ValueError("聚焦尺寸裁决必须引用本轮真实原图ID") + evidence = str(value.get("evidence") or "").strip() + if not evidence: + raise ValueError("聚焦尺寸裁决缺少可复核的图像依据") + question = str(value.get("single_user_question") or "").strip() + options = value.get("question_options") or [] + question_focus = str(value.get("question_focus") or "").strip() + requires_question = decision == "source_dimensions_conflict" or float(confidence) < 0.8 + allowed_focus = { + "overall_dimension_scope", "component_dimension", + "assembly_relation", "machining_relief", + } + if requires_question: + if ( + question_focus not in allowed_focus or not question + or not isinstance(options, list) or not 2 <= len(options) <= 3 + ): + raise ValueError("原图无法消解时必须返回一个明确问题和2至3个互斥选项") + if required_question_focus and question_focus != required_question_focus: + raise ValueError(f"当前冲突必须先确认{required_question_focus},不能跳到后续技术问题") + if len(question) > 100 or any(len(str(item)) > 60 for item in options): + raise ValueError("用户确认问题必须短、通俗,不能把技术分析塞进选项") + if re.search(r"image\d+|[XYZxyz]轴|instance|AABB|overlap|干涉体积", question): + raise ValueError("用户确认问题不得暴露图片ID、坐标轴或几何内部术语") + if question_focus == "overall_dimension_scope": + joined = " ".join(str(item) for item in options) + if "包含" not in joined or "不包含" not in joined: + raise ValueError("整体尺寸口径问题必须给出“包含/不包含”互斥选项") + if not requires_question: + question, options, question_focus = "", [], "" + cleaned_options = [ + re.sub(r"^[A-Ca-c][\.\u3001]\s*", "", str(item).strip())[:60] + for item in options[:3] + ] + return { + "decision": decision, + "axis": axis, + "confidence": round(float(confidence), 4), + "evidence_image_ids": sorted(set(evidence_ids)), + "evidence": evidence[:1000], + "requires_user_question": requires_question, + "question_focus": question_focus or None, + "single_user_question": question[:500] or None, + "question_options": cleaned_options, + "source": "independent_focused_multiview_adjudication", + } + + +def dead_end_question_focus(dead_end_feedback: list[dict], graph: dict) -> str: + """Choose the first user-decidable fact without exposing CAD internals.""" + parts = {str(row.get("id")): row for row in graph.get("parts") or []} + instances = { + str(row.get("id")): parts.get(str(row.get("part_id"))) or {} + for row in graph.get("instances") or [] + } + for conflict in dead_end_feedback: + axes = set(conflict.get("minimum_separation_axes") or []) + related_ids = {str(conflict.get("a") or ""), str(conflict.get("b") or "")} + for row in conflict.get("minimum_axis_packing_feasibility") or []: + related_ids.add(str(row.get("moving_instance_id") or "")) + related_ids.update(str(item.get("instance_id") or "") for item in row.get("blocking_instances") or []) + for instance_id in related_ids: + part = instances.get(instance_id) or {} + if ( + part.get("functional_role") in {"door", "drawer_front"} + and part.get("thickness_axis") in axes + ): + return "overall_dimension_scope" + return "component_dimension" + + +def deterministic_dead_end_question( + *, focus: str, axis: str, dimension_ledger: dict, graph: dict, +) -> dict: + """Safe UI fallback when the model cannot phrase the one remaining fact.""" + axis_names = {"x": "总宽", "y": "总深", "z": "总高"} + fact = (dimension_ledger or {}).get(axis) or {} + value = fact.get("value_mm") + value_text = f"{float(value):g}mm" if isinstance(value, (int, float)) and not isinstance(value, bool) else "标注的整体尺寸" + parts = {str(row.get("id")): row for row in graph.get("parts") or []} + has_door = any(part.get("functional_role") == "door" for part in parts.values()) + front_name = "柜门" if has_door else "前置面板" + if focus == "overall_dimension_scope": + return { + "decision": "source_dimensions_conflict", "axis": axis, "confidence": 1.0, + "evidence_image_ids": sorted(set(fact.get("source_image_ids") or [])), + "evidence": "几何区间检查证明整体尺寸口径会直接决定前置件与内部板件能否同时放置。", + "requires_user_question": True, "question_focus": focus, + "single_user_question": f"尺寸图标注的{axis_names.get(axis, '整体尺寸')} {value_text},是否包含{front_name}厚度?", + "question_options": [ + f"包含{front_name}(成品最外尺寸)", + f"不包含{front_name}(只算柜体/框架)", + ], + "source": "deterministic_dead_end_question_fallback", + } + return { + "decision": "source_dimensions_conflict", "axis": axis, "confidence": 1.0, + "evidence_image_ids": sorted(set(fact.get("source_image_ids") or [])), + "evidence": "几何区间检查证明当前部件尺寸无法在允许边界内共存。", + "requires_user_question": True, "question_focus": focus, + "single_user_question": f"请确认{axis_names.get(axis, '该方向')}上标注的部件尺寸或两件的前后关系。", + "question_options": ["按尺寸图数值,修正前后关系", "前后关系正确,需修正部件尺寸"], + "source": "deterministic_dead_end_question_fallback", + } + + def select_resume_checkpoint(rows: list[dict]) -> dict: """Select only an accepted, internally consistent audited checkpoint.""" candidates = [ @@ -286,7 +419,7 @@ GRAPH_PROMPT = """根据全部原始图片构建可编辑、可验证的家具 "contact_derivation":"aabb_within_assembly_v1", "observations":[{"id":"e1","source_type":"image","image_id":"image1","confidence":0.0,"text":"原图观察,不是经验","region":[0,0,1,1]}], "parameters":{"parameter_name":{"kind":"length/count/scalar","origin":"observed/derived/estimated","value":0,"unit":"mm/cm/m","evidence_ids":["e1"],"confidence":0.0}}, - "envelope_mm":["width","depth","height"], + "envelope_mm":["实际占用宽","实际占用深","实际占用高"], "assemblies":[{"id":"lower_module","role":"carcass","bounds_mm":{"x":[0,"width"],"y":[0,"depth"],"z":[0,"lower_height"]},"evidence_ids":["e1"]},{"id":"drawer_1","role":"drawer","bounds_mm":{"x":["side_thickness","width-side_thickness"],"y":[0,"drawer_depth"],"z":["drawer_z_min","drawer_z_max"]},"evidence_ids":["e1"]}], "parts":[{"id":"part_id","name":"中文部件名","shape":"box","functional_role":"side","thickness_axis":"x","size_mm":["参数或算式","参数或算式","参数或算式"],"observed_quantity":1,"evidence_ids":["e1"]}], "instances":[{"id":"板件物理实例ID","part_id":"panel_part_id","assembly_id":"明确所属总成ID","placement":{"x":{"mode":"align","self_anchor":"min","target":"envelope","target_anchor":"min","offset_mm":0},"y":{"mode":"absolute","value_mm":0},"z":{"mode":"align","self_anchor":"max","target":"另一实例ID","target_anchor":"min","offset_mm":0}}},{"id":"五金物理实例ID","part_id":"hardware_part_id","assembly_id":"明确所属总成ID","placement":{"x":{"mode":"absolute","value_mm":0},"y":{"mode":"absolute","value_mm":0},"z":{"mode":"absolute","value_mm":0}},"mountings":[{"host_instance_id":"实际安装板件ID","kind":"surface","face":"y-","embed_depth_mm":0,"evidence_ids":["e1"]}]}], @@ -297,6 +430,9 @@ GRAPH_PROMPT = """根据全部原始图片构建可编辑、可验证的家具 参数:observed仅用于图上明确可读的标注,必须引用实际图片证据;照片比例、隐蔽板厚等用estimated且confidence<=0.55。 derived参数不写value/unit,改写expression为参数名和加减乘除的算式,如width-2*panel_thickness。 先定义变量,再引用;数字是无量纲系数,不要把mm和件数直接相加。零件size_mm/position_mm中数字字面量单位固定mm。 +默认envelope_mm是门板、把手、凸出脚等全部结构安装后的实际占用外形,不是柜体净尺寸。 +若尺寸箭头实际量的是柜体、框架或开口参考范围,只有独立端点裁决或真实用户证据验证后,才能增加reference_bounds_mm和dimension_scope_overrides; +图谱模型自己不得把overall改口径来躲避冲突。reference_bounds_mm必须位于实际占用envelope_mm内。 全图只使用一个产品坐标系:X=产品宽、Y=产品深、Z=产品高。固定写orientation_solver=role_axis_templates_v1。 size_mm必须直接按产品[X宽,Y深,Z高]填写;instances禁止输出axis_order,也禁止对零件做第二次轴交换。 固定写placement_solver=anchored_axes_v1,禁止输出position_mm或心算XYZ坐标。 @@ -890,6 +1026,16 @@ def main(): break if row.get("patch_error") and has_minimum_axis_packing_dead_end(round_base_feedback): report["status"] = "geometry_dead_end_requires_focused_source_adjudication" + dead_end_feedback = [ + item for item in round_base_feedback + if isinstance(item, dict) and item.get("minimum_separation_axis_infeasible") is True + ] + required_question_focus = dead_end_question_focus(dead_end_feedback, round_base_graph) + dead_end_axis = next(iter( + axis for item in dead_end_feedback + for axis in (item.get("minimum_separation_axes") or []) + if axis in {"x", "y", "z"} + ), "y") report["blocking"] = { "code": "minimum_axis_packing_dead_end", "detail": ( @@ -899,6 +1045,65 @@ def main(): ), "next_owner": "ai_focused_source_adjudication_then_single_user_question_if_still_ambiguous", } + print(json.dumps({ + "case": case, "phase": "ai_focused_source_adjudication", + "dead_end_count": len(dead_end_feedback), + }, ensure_ascii=False), flush=True) + try: + adjudication_response = engine._request_model_json({ + "model": args.model, + "messages": [{"role": "user", "content": images + [{ + "type": "text", "text": ( + "你现在只做一次聚焦的原图几何口径裁决,不重画CAD、不报价、不读公司BOM。" + "几何编译器已证明当前冲突在最短分离轴内没有任何合法平移位置。" + "必须回看每张原图的尺寸箭头两端、门/背板/层板覆盖关系和可见槽口," + "在下列四种decision中只选一个:" + "occupied_overall_dimension(箭头是成品实际外形)、" + "reference_carcass_or_opening_dimension(箭头仅量柜体/框架/开口)、" + "explicit_machining_relief(原图明确有槽口/避让使实体可共存)、" + "source_dimensions_conflict(原图本身无法唯一解释)。" + "不得从家具常识猜测;不可见就选source_dimensions_conflict。" + "只返回JSON:{decision,axis,confidence,evidence_image_ids,evidence," + "question_focus,single_user_question,question_options}。只有decision=source_dimensions_conflict或" + "confidence<0.8时才能填写一个single_user_question和2至3个互斥选项," + "question_focus只能是overall_dimension_scope/component_dimension/assembly_relation/" + "machining_relief。优先确认已标“整体”的尺寸是否包含前置门板/面板等凸出件;" + "只有这一口径已明确时,才问部件尺寸或槽口。问题不超过100字,选项只能2至3个且每个不超过60字;" + "不得出现image1、X/Y/Z轴、AABB、干涉体积等内部术语,不得给出会让占用空间更大的伪修复选项。" + f"本轮结构规则确定若需要询问,question_focus必须是{required_question_focus}。" + "其余情况留空。\n当前不可平移消解的结构化冲突:" + + json.dumps(dead_end_feedback, ensure_ascii=False) + ), + }]}], + "response_format": {"type": "json_object"}, "temperature": 0.0, + "enable_thinking": True, "thinking_budget": 1536, + "vl_high_resolution_images": True, "max_tokens": 3500, + }, "原图尺寸口径聚焦裁决失败", max_attempts=2, + timeout_seconds=150, decoder=lambda text: normalize_focused_source_adjudication( + json.loads(text), image_ids=image_ids, + required_question_focus=required_question_focus, + )) + focused = adjudication_response + report["focused_source_adjudication"] = focused + if focused["requires_user_question"]: + report["status"] = "geometry_source_conflict_requires_one_user_question" + report["blocking"]["next_owner"] = "user_single_focused_question" + report["blocking"]["single_user_question"] = focused["single_user_question"] + report["blocking"]["question_options"] = focused["question_options"] + else: + report["status"] = "focused_source_adjudication_ready_for_graph_repair" + report["blocking"]["next_owner"] = "graph_repair_with_independent_scope_adjudication" + except Exception as exc: + report["focused_source_adjudication_error"] = f"{type(exc).__name__}: {exc}" + focused = deterministic_dead_end_question( + focus=required_question_focus, axis=dead_end_axis, + dimension_ledger=dimension_ledger, graph=round_base_graph, + ) + report["focused_source_adjudication"] = focused + report["status"] = "geometry_source_conflict_requires_one_user_question" + report["blocking"]["next_owner"] = "user_single_focused_question" + report["blocking"]["single_user_question"] = focused["single_user_question"] + report["blocking"]["question_options"] = focused["question_options"] (case_dir / "report.json").write_text( json.dumps(report, ensure_ascii=False, indent=2), encoding="utf-8" ) 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 b712a99..b18b53f 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 @@ -240,7 +240,8 @@ def apply_graph_patch(graph: dict, patches: list[dict]) -> tuple[dict, list[dict raise GraphError("图谱局部修改必须包含1到64条操作") result = copy.deepcopy(graph) allowed_roots = { - "observations", "parameters", "envelope_mm", "parts", "instances", + "observations", "parameters", "envelope_mm", "reference_bounds_mm", + "dimension_scope_overrides", "parts", "instances", "connections", "uncertainties", "constraint_solver", "contact_derivation", "placement_solver", "orientation_solver", "assemblies", "assembly_connections", } @@ -1070,6 +1071,7 @@ def compile_graph( source_image_ids: set | None = None, user_evidence: dict | None = None, dimension_ledger: dict | None = None, + dimension_scope_adjudications: dict | None = None, topology_contract: dict | None = None, ) -> dict: """Resolve a graph without repairing it by category-specific heuristics.""" @@ -1117,6 +1119,21 @@ def compile_graph( raise GraphError("整体包围尺寸必须有三个正值") except (GraphError, KeyError, TypeError) as exc: issue("envelope", "/envelope_mm", str(exc)) + reference_bounds = {} + if graph.get("reference_bounds_mm") is not None: + try: + raw_reference_bounds = graph["reference_bounds_mm"] + if not isinstance(raw_reference_bounds, dict) or set(raw_reference_bounds) != {"x", "y", "z"}: + raise GraphError("参考包络必须分别声明X/Y/Z最小与最大值") + for axis, axis_name in enumerate("xyz"): + values = [resolver.length(value) for value in raw_reference_bounds[axis_name]] + if len(values) != 2 or values[1] <= values[0]: + raise GraphError(f"参考包络{axis_name.upper()}轴上限必须大于下限") + if envelope and (values[0] < -0.5 or values[1] > envelope[axis] + 0.5): + raise GraphError(f"参考包络{axis_name.upper()}轴必须位于实际占用包络内") + reference_bounds[axis_name] = values + except (GraphError, KeyError, TypeError) as exc: + issue("reference_bounds", "/reference_bounds_mm", str(exc)) parts = {} panel_roles = { "top", "bottom", "side", "shelf", "door", "back", "drawer_front", @@ -1813,20 +1830,84 @@ def compile_graph( # model cannot silently turn a 300 mm side-view depth into product width. # Conflicted or low-confidence labels are intentionally omitted by the # upstream ledger builder and therefore remain AI-estimated draft values. + dimension_scope_adjudications = dimension_scope_adjudications or {} + dimension_scope_audit = [] + scope_overrides = graph.get("dimension_scope_overrides") or {} + if not isinstance(scope_overrides, dict): + issue("dimension_scope_override", "/dimension_scope_overrides", "尺寸口径覆盖必须按X/Y/Z轴声明") + scope_overrides = {} + unknown_override_axes = sorted(set(scope_overrides) - set("xyz")) + if unknown_override_axes: + issue( + "dimension_scope_override", "/dimension_scope_overrides", + "尺寸口径覆盖只允许X/Y/Z轴", + unknown_axes=unknown_override_axes, + ) if len(envelope) == 3: for axis_index, axis in enumerate("xyz"): fact = (dimension_ledger or {}).get(axis) if not isinstance(fact, dict) or not isinstance(fact.get("value_mm"), (int, float)): continue + comparison_mm = float(envelope[axis_index]) + comparison_scope = "occupied_overall" + override = scope_overrides.get(axis) + if override is not None: + path = f"/dimension_scope_overrides/{axis}" + if not isinstance(override, dict) or override.get("ledger_scope") != "reference": + issue( + "dimension_scope_override", path, + "口径覆盖目前只支持把原图整体尺寸改判为参考包络", + ) + elif axis not in reference_bounds: + issue("dimension_scope_override", path, "引用参考口径前必须完整声明reference_bounds_mm") + else: + evidence_id = str(override.get("evidence_id") or "") + adjudication_id = str(override.get("adjudication_id") or "") + user_verified = bool( + evidence_id + and evidence_id in observations + and observations[evidence_id].get("source_type") == "user" + ) + adjudication = dimension_scope_adjudications.get(adjudication_id) + external_verified = bool( + adjudication_id + and isinstance(adjudication, dict) + and adjudication.get("axis") == axis + and adjudication.get("ledger_scope") == "reference" + and isinstance(adjudication.get("confidence"), (int, float)) + and not isinstance(adjudication.get("confidence"), bool) + and float(adjudication["confidence"]) >= 0.8 + and set(adjudication.get("source_image_ids") or []).issubset(source_image_ids) + and bool(adjudication.get("source_image_ids")) + ) + if not user_verified and not external_verified: + issue( + "dimension_scope_override_unverified", path, + "把原图整体尺寸改判为柜体/开口参考尺寸必须来自独立AI端点裁决或真实用户确认,图谱模型不能自行改口径", + evidence_id=evidence_id or None, + adjudication_id=adjudication_id or None, + ) + else: + comparison_mm = float(reference_bounds[axis][1] - reference_bounds[axis][0]) + comparison_scope = "reference_bounds" + dimension_scope_audit.append({ + "axis": axis, + "ledger_scope": "reference", + "comparison_mm": comparison_mm, + "verification_source": "user" if user_verified else "independent_source_adjudication", + "evidence_id": evidence_id or None, + "adjudication_id": adjudication_id or None, + }) expected = float(fact["value_mm"]) tolerance = max(1.0, float(fact.get("tolerance_mm") or 1.0)) - if abs(envelope[axis_index] - expected) > tolerance: + if abs(comparison_mm - expected) > tolerance: issue( "overall_dimension_conflict", f"/envelope_mm/{axis_index}", - f"CAD整体{axis.upper()}轴{envelope[axis_index]:.3f}mm与原图尺寸箭头{expected:.3f}mm冲突", + f"CAD{comparison_scope}的{axis.upper()}轴{comparison_mm:.3f}mm与原图尺寸箭头{expected:.3f}mm冲突", axis=axis, - compiled_mm=envelope[axis_index], + comparison_scope=comparison_scope, + compiled_mm=comparison_mm, source_mm=expected, source_image_ids=list(fact.get("source_image_ids") or []), ) @@ -1847,6 +1928,9 @@ def compile_graph( "mounting_audit": mounting_audit, "collision_audit": collision_pairs, "dimension_ledger": dimension_ledger or {}, + "occupied_envelope_mm": envelope, + "reference_bounds_mm": reference_bounds, + "dimension_scope_audit": dimension_scope_audit, "topology_contract": topology_contract or {}, "bom": [{"part_id": key, "name": part.get("name"), "size_mm": part["size_mm"], "quantity": quantities[key], "functional_role": part.get("functional_role"), 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 403f902..b45cc00 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 @@ -10,8 +10,9 @@ from unittest import mock from manufacturing_oracle import plan_manufacturing_features from probe_structure_graph import ( authoritative_interference_feedback, cad_source, compile_lower_bound_can_improve, + dead_end_question_focus, deterministic_dead_end_question, diagnostic_mounting_clearance_cuts, has_minimum_axis_packing_dead_end, - refresh_compiled_kernel_feedback, + normalize_focused_source_adjudication, refresh_compiled_kernel_feedback, round_blocker_count, select_resume_checkpoint, source_for_role, structured_kernel_feedback, structured_patch_feedback, ) @@ -225,6 +226,81 @@ class ManufacturingOracleTest(unittest.TestCase): {"code": "solid_interference", "minimum_separation_axis_infeasible": True}, ])) + def test_focused_source_adjudication_only_asks_after_unresolved_source_conflict(self): + resolved = normalize_focused_source_adjudication({ + "decision": "reference_carcass_or_opening_dimension", "axis": "y", + "confidence": 0.93, "evidence_image_ids": ["image2"], + "evidence": "箭头两端停在柜体侧板边界", + "single_user_question": "不应保留", "question_options": ["A", "B"], + }, image_ids={"image1", "image2"}) + self.assertFalse(resolved["requires_user_question"]) + self.assertIsNone(resolved["single_user_question"]) + + unresolved = normalize_focused_source_adjudication({ + "decision": "source_dimensions_conflict", "axis": "y", "confidence": 0.62, + "evidence_image_ids": ["image2", "image4"], + "evidence": "侧视图没有画出门板与深度箭头的端点关系", + "question_focus": "overall_dimension_scope", + "single_user_question": "400mm总深是否包含门板?", + "question_options": ["包含门板", "不包含门板"], + }, image_ids={"image1", "image2", "image3", "image4"}) + self.assertTrue(unresolved["requires_user_question"]) + self.assertEqual(2, len(unresolved["question_options"])) + + with self.assertRaises(ValueError): + normalize_focused_source_adjudication({ + "decision": "source_dimensions_conflict", "axis": "y", "confidence": 0.4, + "evidence_image_ids": ["not-current"], "evidence": "x", + "question_focus": "overall_dimension_scope", + "single_user_question": "?", "question_options": ["A", "B"], + }, image_ids={"image1"}) + with self.assertRaises(ValueError): + normalize_focused_source_adjudication({ + "decision": "source_dimensions_conflict", "axis": "y", "confidence": 0.7, + "evidence_image_ids": ["image1"], "evidence": "端点不清晰", + "question_focus": "assembly_relation", + "single_user_question": "image1中层板在Y轴是否overlap背板?", + "question_options": ["是", "否"], + }, image_ids={"image1"}) + + with self.assertRaises(ValueError): + normalize_focused_source_adjudication({ + "decision": "source_dimensions_conflict", "axis": "y", "confidence": 0.7, + "evidence_image_ids": ["image1"], "evidence": "总深端点不清晰", + "question_focus": "component_dimension", + "single_user_question": "层板和背板哪个尺寸需要修改?", + "question_options": ["修改层板", "修改背板"], + }, image_ids={"image1"}, required_question_focus="overall_dimension_scope") + + def test_dead_end_prefers_overall_dimension_scope_when_door_consumes_axis(self): + graph = graph_for_role("door") + graph["parts"][1]["thickness_axis"] = "y" + graph["instances"][2]["id"] = "door-instance" + feedback = [{ + "minimum_separation_axes": ["y"], + "minimum_axis_packing_feasibility": [{ + "moving_instance_id": "shelf-instance", + "blocking_instances": [{"instance_id": "door-instance"}], + }], + }] + self.assertEqual("overall_dimension_scope", dead_end_question_focus(feedback, graph)) + + def test_deterministic_dead_end_question_is_short_and_source_grounded(self): + graph = graph_for_role("door") + question = deterministic_dead_end_question( + focus="overall_dimension_scope", axis="y", + dimension_ledger={"y": { + "value_mm": 400, "source_image_ids": ["image2"], + }}, graph=graph, + ) + self.assertEqual("overall_dimension_scope", question["question_focus"]) + self.assertEqual("deterministic_dead_end_question_fallback", question["source"]) + self.assertIn("总深 400mm", question["single_user_question"]) + self.assertIn("是否包含柜门厚度", question["single_user_question"]) + self.assertEqual(2, len(question["question_options"])) + self.assertIn("包含柜门", question["question_options"][0]) + self.assertIn("不包含柜门", question["question_options"][1]) + def test_resume_skips_rejected_round_and_chooses_latest_tie(self): rows = [ {"round": 1, "graph": {"id": "old"}, "compiled": {"issues": [{}]}}, 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 844d1bd..b0ebf95 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 @@ -734,6 +734,78 @@ class StructureGraphTest(unittest.TestCase): self.assertTrue(feasible["feasible_without_dimension_or_bounds_change"]) self.assertTrue(feasible["feasible_lower_position_intervals_mm"]) + def test_reference_envelope_scope_requires_verified_user_or_independent_adjudication(self): + graph = anchored_frame() + graph["parameters"]["occupied_depth"] = param(420) + graph["envelope_mm"][1] = "occupied_depth" + graph["reference_bounds_mm"] = { + "x": [0, "w"], "y": [20, "occupied_depth"], "z": [0, "h"], + } + graph["dimension_scope_overrides"] = { + "y": {"ledger_scope": "reference", "adjudication_id": "depth-scope"}, + } + ledger = {"y": {"value_mm": 400, "tolerance_mm": 1, "source_image_ids": ["original-front"]}} + + unverified = compile_graph( + graph, source_image_ids={"original-front"}, dimension_ledger=ledger + ) + self.assertIn( + "dimension_scope_override_unverified", + {row["code"] for row in unverified["issues"]}, + ) + + verified = compile_graph( + graph, + source_image_ids={"original-front"}, + dimension_ledger=ledger, + dimension_scope_adjudications={"depth-scope": { + "axis": "y", "ledger_scope": "reference", "confidence": 0.92, + "source_image_ids": ["original-front"], + }}, + ) + codes = {row["code"] for row in verified["issues"]} + self.assertNotIn("dimension_scope_override_unverified", codes) + self.assertNotIn("overall_dimension_conflict", codes) + self.assertEqual("independent_source_adjudication", verified["dimension_scope_audit"][0]["verification_source"]) + self.assertEqual(400, verified["dimension_scope_audit"][0]["comparison_mm"]) + + def test_reference_envelope_scope_accepts_exact_conversation_fact(self): + graph = anchored_frame() + graph["parameters"]["occupied_depth"] = param(420) + graph["envelope_mm"][1] = "occupied_depth" + graph["reference_bounds_mm"] = { + "x": [0, "w"], "y": [20, "occupied_depth"], "z": [0, "h"], + } + fact = { + "id": "confirmed-depth-scope", "source_type": "user", + "text": "400mm是柜体深度,不包含覆盖门", "confidence": 1.0, + } + graph["observations"].append(fact) + graph["dimension_scope_overrides"] = { + "y": {"ledger_scope": "reference", "evidence_id": fact["id"]}, + } + ledger = {"y": {"value_mm": 400, "tolerance_mm": 1, "source_image_ids": ["original-front"]}} + result = compile_graph( + graph, + source_image_ids={"original-front"}, + user_evidence={fact["id"]: fact}, + dimension_ledger=ledger, + ) + codes = {row["code"] for row in result["issues"]} + self.assertNotIn("dimension_scope_override_unverified", codes) + self.assertNotIn("overall_dimension_conflict", codes) + self.assertEqual("user", result["dimension_scope_audit"][0]["verification_source"]) + + outside = copy.deepcopy(graph) + outside["reference_bounds_mm"]["y"] = [-20, 400] + rejected = compile_graph( + outside, + source_image_ids={"original-front"}, + user_evidence={fact["id"]: fact}, + dimension_ledger=ledger, + ) + self.assertIn("reference_bounds", {row["code"] for row in rejected["issues"]}) + def test_anchored_duplicate_instance_id_and_loose_assembly_bounds_are_blocking(self): duplicate = anchored_frame() duplicate["instances"].append(copy.deepcopy(duplicate["instances"][0]))