From 272a9aa29faa172b388cc9e29ecf669dbbdf9f5f Mon Sep 17 00:00:00 2001 From: chenpei Date: Mon, 7 Sep 2026 21:04:02 +0800 Subject: [PATCH] feat: make CAD repair iterations monotonic --- .../STRUCTURE_GRAPH_UPGRADE.md | 7 + .../ai-solid-wood-cost/VALIDATION_LEDGER.md | 10 + .../probe_structure_graph.py | 69 +++++- .../ai-solid-wood-cost/structure_graph.py | 207 +++++++++++++++++- .../test_structure_graph.py | 108 ++++++++- 5 files changed, 379 insertions(+), 22 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 f4e70e7..dfc328b 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 @@ -62,6 +62,13 @@ - 冻结图谱/CAD/BOM和报价后,再读取公司同款成本评价。公司数据用于基准单价和事后偏差审计,不给视觉模型提供目标BOM。 - 真实Chat API验收需明确实际运行版本、全程中文进度、无重复提问/计费、可修改前序参数,并核对每个交付文件;不能把隔离模型请求当作完整API验收。 +## v90 修复事务规范化与收敛保护(2026-09-07) + +- 模型输出不是直接写入权威图谱。响应先统一为有界 patch 列表;直接数组和 `patches` 对象都可解析,但非法成员、空变化和超过上限的修改会被拒绝。 +- 整段实例 placement 只在同时含有且仅含 x/y/z 时可进入规范化,并拆为实际变化的单轴替换;原始路径、展开路径和响应形状都进入审计。底层补丁器继续拒绝整段 placement 写入,形成两层保护。 +- 局部修改按事务应用到候选副本并重新编译;编译问题数不能高于上一已接受版本。回归候选被完整丢弃,下一轮收到问题数和代码分布,而不是在损坏状态上继续修。 +- sideboard7 隔离证据显示该机制使编译问题从 57 项总体降到 15 项,并成功消费一次直接数组响应;但剩余总成边界和穿插仍是阻断项。这里的“收敛”只描述验证器问题计数,不等于图片理解正确或可制造。 + ## 同行公开资料的可用结论 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 0595116..c385940 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 @@ -223,3 +223,13 @@ - 服务器隔离样例在原始四图上正确建立数量合同与 1200×400×800mm 尺寸账本,但三轮仍未收敛:编译问题 60→55→59,B-rep 冲突 3→6→5,最终状态 `graph_and_cad_kernel_not_passed`。失败报告位于服务器临时目录 `/tmp/dalongxia-cad-v85-eI8HeF/probe-sideboard4/sideboard/report.json`。 - 本轮确认的新根因是轴语义仍由模型以 `axis_order` 自由表达,局部修复可能把门板宽高厚重新映射到错误产品轴;同时修复提示不足以阻止对多个实例盲加统一偏移。下一步应把面板厚度轴和功能角色编译成受限放置模板,再让模型只选择开口、靠边关系和间隙参数。 - 未修改生产文件、未重启 Chat API、未改变用户任务;真实多产品盲测、公司 BOM 事后对比和总成本误差≤5%仍未完成。 + +## 2026-09-07 v90 受控修复响应与单调收敛(候选,未上线) + +- v89 的“发现整段 placement 就整批拒绝”虽然阻止了坐标污染,但真实模型经常把有效单轴修复与整段覆盖混在同一批,导致全部有效修改也被丢弃;另一次模型直接返回 patch 数组,旧调用对数组执行 `.get()` 后异常。该策略已记录为失败,避免以后重复采用。 +- 新增通用修复响应规范化:既接受 `{"patches": [...]}`,也接受直接 patch 数组;格式正确的完整 x/y/z placement 仅展开为与当前图谱不同的单轴 replace,并记录原路径与展开路径。底层 `apply_graph_patch` 仍禁止整段覆盖,因此规范化不能绕过安全边界。 +- 每轮候选图谱先重新编译;编译问题数高于上一已接受版本时保留旧图谱并返回结构化回归反馈,不让问题数反弹的修复继续污染后续轮次。 +- 本地最终完整回归 187 项通过(236.066 秒),其中新增列表响应、稳定 ID/数字索引、完整 placement 单轴展开、畸形与无变化响应拒绝等覆盖。 +- 服务器确认生产 Chat API 活动任务、等待任务、持久队列均为 0 后,仅在 `/tmp/dalongxia-cad-v85-eI8HeF/probe-sideboard7` 运行隔离原图试验;未修改生产文件、未重启服务、未读取公司 BOM。 +- 同一组四张原图仍正确固定 1200×400×800mm 和 top=1、bottom=1、side=2、door=2、shelf=1、leg=4。五轮编译问题 57→52→35→30→15,CAD B-rep 冲突 11→16→9→12→3;最终仍有门/五金总成边界、一个门把手断联、背板与层板以及左侧板与铰链穿插,状态仍为 `graph_and_cad_kernel_not_passed`。 +- 该结果证明修复链可消费反馈并总体收敛,但没有通过制造门禁、没有生成可交付报价,也没有完成不同公司产品盲测或冻结后总成本误差≤5%。 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 f13ead0..99fc38d 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 @@ -16,7 +16,7 @@ import time from structure_graph import ( GraphError, SCHEMA_VERSION, apply_graph_patch, build_dimension_ledger, - build_source_topology_contract, compile_graph, + build_source_topology_contract, compile_graph, normalize_graph_patch_payload, ) from manufacturing_oracle import plan_manufacturing_features @@ -157,13 +157,14 @@ GRAPH_PROMPT = """根据全部原始图片构建可编辑、可验证的家具 严格JSON结构: {"schema_version":"furniture-assembly-graph/1", "placement_solver":"anchored_axes_v1", + "orientation_solver":"role_axis_templates_v1", "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"], "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":"part_id","assembly_id":"明确所属总成ID","axis_order":[0,1,2],"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}}}], + "instances":[{"id":"物理实例ID","part_id":"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}}}], "connections":[], "assembly_connections":[{"id":"drawer_slide","kind":"sliding","a":"drawer_1","b":"carcass","axis":"y","travel_limits_mm":[0,250],"evidence_ids":["e1"]}], "uncertainties":[]} @@ -171,14 +172,19 @@ GRAPH_PROMPT = """根据全部原始图片构建可编辑、可验证的家具 参数:observed仅用于图上明确可读的标注,必须引用实际图片证据;照片比例、隐蔽板厚等用estimated且confidence<=0.55。 derived参数不写value/unit,改写expression为参数名和加减乘除的算式,如width-2*panel_thickness。 先定义变量,再引用;数字是无量纲系数,不要把mm和件数直接相加。零件size_mm/position_mm中数字字面量单位固定mm。 -size_mm为零件本地尺寸,axis_order明确映射到产品X宽/Y深/Z高。固定写placement_solver=anchored_axes_v1,禁止输出position_mm或心算XYZ坐标。 +全图只使用一个产品坐标系: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坐标。 每个part必须声明functional_role,只允许top/bottom/side/shelf/door/back/drawer_front/drawer_left/drawer_right/drawer_back/drawer_bottom/drawer_support/divider/leg/rail/stile/frame_member/hardware/other。 -所有板件还必须声明thickness_axis=x/y/z,它是size_mm本地坐标中最薄的一轴;不能根据中文名称猜方向。非板件可写null。 -例如size_mm=[t,depth,height]的侧板写thickness_axis=x;size_mm=[inner_width,depth,t]的层板写thickness_axis=z。axis_order再把这个本地厚度轴映射到产品坐标。 +所有板件还必须声明产品坐标中的thickness_axis=x/y/z,它必须是size_mm最薄的一轴;不能根据中文名称猜方向。非板件可写null。 +角色朝向规则:top/bottom/shelf/drawer_bottom/drawer_support厚度轴为z;side/drawer_left/drawer_right为x;door/back/drawer_front/drawer_back为y;divider只允许x或z。 +例如侧板size_mm=[t,depth,height]且thickness_axis=x;层板size_mm=[inner_width,depth,t]且thickness_axis=z;门板size_mm=[door_width,t,door_height]且thickness_axis=y。 每个实例的placement必须有x/y/z三轴。每轴只允许absolute(value_mm),或align(self_anchor,target,target_anchor,offset_mm)。 anchor只能是min/center/max;target只能是envelope、字面量assembly、当前实例自己的assembly_id,或先定义的实例ID。当前assembly_id与assembly等价,不能用其他总成ID做跨总成定位。每个部件优先在所属assembly局部边界内定位;例如模块右侧板X最大面对齐所属总成X最大面,应写self_anchor=max,target=assembly,target_anchor=max,实际坐标由求解器计算。只有部件确实位于整件产品最外边界时才能直接锚定envelope。 固定写contact_derivation=aabb_within_assembly_v1。AI只声明每个物理件所属总成,同一总成内的固定/支撑接触由编译器根据真实几何正面积相交自动推导,禁止AI猜固定连接面。 每个assembly必须至少有一个实例,不能创建空root。每个assembly必须给出严格bounds_mm,边界等于所属实体并集的真实包围盒,不能把整件产品边界复制给局部模块。尺寸图或多视图显示上下分段时,按真实结构分成上下模块;上部模块的Z最小值必须来自分段尺寸或可追溯派生参数,其侧板不得再次落到产品Z=0。 +assembly.role只允许carcass/drawer/door/module/frame/hardware_group/other;门总成写door,禁止写door_assembly、sliding_door等自创角色。滑动或铰接性质写在assembly_connections.kind,不写进role。 +若顶板、底板、侧板、背板或腿属于同一carcass,它的bounds_mm必须覆盖这些实体的完整并集;不能只用内部净空作为carcass边界。落地腿的Z最小面通常对齐产品envelope最小面,不能把腿的Z最大面对齐最小面而放到产品外。 抽屉的前/后/左/右帮板和底板属于同一drawer总成;柜体板属于carcass,不能把“抽屉”运动性质挂在某一块帮板上。层板、底板和抽屉帮板必须按相邻侧板之间的净空生成,不能按产品总宽横穿侧板;任何实体正体积穿插都必须修正。 每个会独立运动的抽屉、平开门或移门必须是独立assembly;单块门板也要建立包含一个实例的总成。不同总成之间用assembly_connections,a/b只能引用assemblies中的稳定ID,禁止引用零件实例ID:fixed必须在最终几何中真实接触;sliding必须有axis与travel_limits_mm;hinged必须有axis与angle_limits_deg。connections中不要生成fixed/support。 不同规格独立part;相同规格由多个instances引用,BOM数量只等于真实实例数,不额外产生总成板。 @@ -363,7 +369,8 @@ def main(): }, ensure_ascii=False) ) graph, feedback = {}, [] - for turn in range(max(1, min(args.rounds, 3))): + accepted_compile_issue_count = None + for turn in range(max(1, min(args.rounds, 5))): started = time.time() row = {"round": turn + 1} prompt = GRAPH_PROMPT + evidence_context @@ -376,12 +383,17 @@ def main(): "若零件observed_quantity大于实例数,必须为每个真实物理件补instances,不能只保留零件定义。" "同一总成的固定关系不准手写connections;必须修正部件尺寸、placement锚点或assembly_id,让求解器计算坐标并从真实接触面推导连接。" "anchored_axes_v1下严禁修改不存在的position_mm;必须修改总成bounds_mm、零件size_mm或placement锚点/offset_mm。" - "每个板件必须保留functional_role和本地thickness_axis;编译器会用它区分面贴面、边贴面与边贴边,不允许用名称代替接口语义。" + "禁止replace或remove整个/instances//placement;只修改反馈明确指出的/placement/x、/placement/y或/placement/z单轴,不能连带改坏已通过的轴。" + "若仍返回完整placement,系统只会拆成实际发生变化的单轴修改并记录审计,不会允许绕过底层保护。" + "assembly.role只允许carcass/drawer/door/module/frame/hardware_group/other;门总成必须写door,运动类型写assembly_connections.kind。" + "图谱固定使用orientation_solver=role_axis_templates_v1;每个板件必须保留functional_role,size_mm直接表示产品X/Y/Z,thickness_axis也是产品轴。" + "严禁新增或修改axis_order;若角色朝向冲突,必须修正part的size_mm与thickness_axis,不能对instance二次旋转。" + "编译器会用这些字段区分面贴面、边贴面与边贴边,不允许用名称代替接口语义。" "修正定位时优先使用稳定实例ID。若某轴当前没有value_mm或offset_mm,不得replace不存在的叶子字段;" "必须replace整个轴对象,或对align轴用add新增offset_mm。" "合法示例:{\"patches\":[{\"op\":\"replace\",\"path\":\"/instances/door_left_inst/placement/x\"," "\"value\":{\"mode\":\"absolute\",\"value_mm\":20}}]}。" - "路径可修改observations、parameters、envelope_mm、assemblies、parts、instances、connections、assembly_connections、uncertainties、placement_solver、contact_derivation;" + "路径可修改observations、parameters、envelope_mm、assemblies、parts、instances、connections、assembly_connections、uncertainties、placement_solver、orientation_solver、contact_derivation;" "数组追加使用/-。修改后仍需符合furniture-assembly-graph/1。\n上一版图谱:" + json.dumps(graph, ensure_ascii=False) + evidence_context @@ -398,12 +410,48 @@ def main(): decoder=lambda text: json.loads(text)) if graph: row["patch_response"] = response + previous_graph = graph try: - graph, patch_audit = apply_graph_patch(graph, response.get("patches")) + normalized_patches, normalization_audit = normalize_graph_patch_payload(graph, response) + row["patch_normalization_audit"] = normalization_audit + row["normalized_patches"] = normalized_patches + candidate_graph, patch_audit = apply_graph_patch(graph, normalized_patches) row["patch_audit"] = patch_audit + candidate_compiled = compile_graph( + candidate_graph, source_image_ids=image_ids, dimension_ledger=dimension_ledger, + topology_contract=topology_contract, + ) + candidate_issue_count = len(candidate_compiled.get("issues") or []) + if ( + accepted_compile_issue_count is not None + and candidate_issue_count > accepted_compile_issue_count + ): + graph = previous_graph + row["patch_error"] = "repair_regression_rejected" + row["patch_regression"] = { + "previous_issue_count": accepted_compile_issue_count, + "candidate_issue_count": candidate_issue_count, + "candidate_issue_codes": dict(__import__("collections").Counter( + item.get("code") for item in candidate_compiled.get("issues") or [] + )), + } + feedback = [{ + "code": "repair_regression_rejected", + "detail": ( + f"本轮局部修改使编译问题从{accepted_compile_issue_count}项增加到" + f"{candidate_issue_count}项,已保留上一版图谱;下一轮必须缩小修改范围。" + ), + "correction": "逐条采用编译器给出的稳定ID和单轴路径,禁止整段覆盖placement或引入统一经验偏移。", + }] + list(candidate_compiled.get("issues") or [])[:12] + report["status"] = "regressive_patch_rejected" + row["checkpoint_phase"] = "regressive_patch_rejected_graph_preserved" + checkpoint(row) + else: + graph = candidate_graph except GraphError as exc: row["patch_error"] = f"{type(exc).__name__}: {exc}" - feedback = structured_patch_feedback(graph, response.get("patches"), exc) + raw_patches = response.get("patches") if isinstance(response, dict) else response + feedback = structured_patch_feedback(graph, raw_patches, exc) row["patch_feedback"] = feedback report["status"] = "patch_not_applied" row["checkpoint_phase"] = "invalid_patch_rejected_graph_preserved" @@ -417,6 +465,7 @@ def main(): topology_contract=topology_contract, ) row["compiled"] = compiled + accepted_compile_issue_count = len(compiled.get("issues") or []) feedback = list(compiled["issues"]) manufacturing_plan = plan_manufacturing_features( compiled, 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 33ab236..2297bb4 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 @@ -18,6 +18,31 @@ from collections import Counter SCHEMA_VERSION = "furniture-assembly-graph/1" +# New graphs use one product coordinate system end-to-end: X=width, +# Y=depth, Z=height. These templates are role semantics, not Chinese-name +# heuristics. They remove the formerly independent ``axis_order`` degree of +# freedom that let a model rotate a panel a second time after already writing +# product-oriented dimensions. +ROLE_PRODUCT_THICKNESS_AXES = { + "top": {"z"}, + "bottom": {"z"}, + "side": {"x"}, + "shelf": {"z"}, + "door": {"y"}, + "back": {"y"}, + "drawer_front": {"y"}, + "drawer_left": {"x"}, + "drawer_right": {"x"}, + "drawer_back": {"y"}, + "drawer_bottom": {"z"}, + "drawer_support": {"z"}, + # A divider can be a vertical partition or a horizontal subdivision. + # Prefer shelf/drawer_support for horizontal members, but keep both axes + # legal so the compiler remains product-independent. + "divider": {"x", "z"}, +} +ROLE_AXIS_SOLVER = "role_axis_templates_v1" + def build_source_topology_contract(source_structure: dict, *, min_confidence: float = 0.82) -> dict: """Promote only strongly evidenced source-part counts into a compile gate. @@ -126,6 +151,89 @@ def build_dimension_ledger(labels: list[dict], *, source_image_id: str) -> dict: return ledger +def normalize_graph_patch_payload(graph: dict, payload) -> tuple[list[dict], list[dict]]: + """Normalize model repair output without weakening patch safety. + + JSON-capable models sometimes return the requested patch array directly. + They can also replace a complete three-axis ``placement`` object even when + only one axis changed. Accept the harmless response-shape variant and + expand a well-formed complete placement into explicit changed-axis writes. + ``apply_graph_patch`` still rejects broad placement writes. + """ + if isinstance(payload, list): + raw_patches = payload + response_shape = "patch_list" + elif isinstance(payload, dict): + raw_patches = payload.get("patches") + response_shape = "patch_object" + else: + raise GraphError("图谱修复响应必须是patches对象或patch数组") + if not isinstance(raw_patches, list) or not 1 <= len(raw_patches) <= 64: + raise GraphError("图谱局部修改必须包含1到64条操作") + + instances = graph.get("instances") if isinstance(graph, dict) else None + if not isinstance(instances, list): + raise GraphError("当前图谱缺少instances数组,无法规范化placement修改") + + def instance_for(token: str, path: str) -> dict: + if re.fullmatch(r"0|[1-9]\d*", token): + index = int(token) + if not 0 <= index < len(instances) or not isinstance(instances[index], dict): + raise GraphError(f"修改路径不存在: {path}") + return instances[index] + matches = [ + row for row in instances + if isinstance(row, dict) and str(row.get("id") or "") == token + ] + if len(matches) != 1: + raise GraphError(f"修改路径中的稳定ID不存在或不唯一: {path}") + return matches[0] + + normalized: list[dict] = [] + audit: list[dict] = [{ + "kind": "response_shape", + "source": response_shape, + "normalized_to": "patches", + }] + for index, patch in enumerate(raw_patches): + if not isinstance(patch, dict): + raise GraphError(f"第{index + 1}条修改操作无效") + path = patch.get("path") + match = re.fullmatch(r"/instances/([^/]+)/placement", str(path or "")) + if patch.get("op") == "replace" and match: + value = patch.get("value") + if not isinstance(value, dict) or set(value) != set("xyz"): + raise GraphError("整段placement规范化要求value恰好包含x、y、z三轴") + instance = instance_for(match.group(1), path) + current = instance.get("placement") + if not isinstance(current, dict) or set(current) != set("xyz"): + raise GraphError("当前实例placement必须恰好包含x、y、z三轴") + expanded_paths = [] + for axis in "xyz": + if value[axis] == current[axis]: + continue + axis_path = f"{path}/{axis}" + normalized.append({ + "op": "replace", + "path": axis_path, + "value": copy.deepcopy(value[axis]), + }) + expanded_paths.append(axis_path) + audit.append({ + "kind": "placement_expansion", + "source_index": index + 1, + "source_path": path, + "expanded_paths": expanded_paths, + }) + else: + normalized.append(copy.deepcopy(patch)) + if not normalized: + raise GraphError("图谱修复没有产生任何有效变化") + if len(normalized) > 64: + raise GraphError("规范化后的图谱局部修改超过64条") + return normalized, audit + + def apply_graph_patch(graph: dict, patches: list[dict]) -> tuple[dict, list[dict]]: """Apply a bounded RFC6902 subset and return an immutable audit trail.""" if not isinstance(patches, list) or not 1 <= len(patches) <= 64: @@ -134,7 +242,7 @@ def apply_graph_patch(graph: dict, patches: list[dict]) -> tuple[dict, list[dict allowed_roots = { "observations", "parameters", "envelope_mm", "parts", "instances", "connections", "uncertainties", "constraint_solver", "contact_derivation", - "placement_solver", "assemblies", "assembly_connections", + "placement_solver", "orientation_solver", "assemblies", "assembly_connections", } audit = [] @@ -172,6 +280,13 @@ def apply_graph_patch(graph: dict, patches: list[dict]) -> tuple[dict, list[dict raise GraphError(f"第{index + 1}条修改操作无效") path = patch.get("path") parts = tokens(path) + if ( + len(parts) == 3 and parts[0] == "instances" and parts[2] == "placement" + and patch.get("op") in {"replace", "remove"} + ): + raise GraphError( + "禁止整段覆盖实例placement;必须只修改反馈指出的x/y/z单轴,避免已正确坐标被连带污染" + ) parent = result for token in parts[:-1]: try: @@ -628,6 +743,15 @@ def compile_graph( if graph.get("schema_version") != SCHEMA_VERSION: issue("schema_version", "/schema_version", "结构图谱版本不匹配") + orientation_solver = graph.get("orientation_solver") + if orientation_solver not in {None, ROLE_AXIS_SOLVER}: + issue( + "orientation_solver", "/orientation_solver", + f"朝向求解器只支持{ROLE_AXIS_SOLVER};未知模式不能用于制造图", + actual=orientation_solver, expected=ROLE_AXIS_SOLVER, + ) + role_axis_mode = orientation_solver == ROLE_AXIS_SOLVER + orientation_audit = [] observations = {} source_image_ids = source_image_ids or set() user_evidence = user_evidence or {} @@ -686,17 +810,37 @@ def compile_graph( thickness_axis = part.get("thickness_axis") if role in panel_roles: if thickness_axis not in {"x", "y", "z"}: - raise GraphError("板件必须明确本地厚度轴thickness_axis") + raise GraphError( + "板件必须明确厚度轴thickness_axis;新图谱使用产品X宽/Y深/Z高坐标" + if role_axis_mode else "板件必须明确本地厚度轴thickness_axis" + ) thickness_index = "xyz".index(thickness_axis) if size[thickness_index] > min(size) + 0.6: raise GraphError("声明的厚度轴不是此板件最薄轴") elif thickness_axis not in {None, "", "x", "y", "z"}: raise GraphError("非板件厚度轴只能为空或x/y/z") parts[key] = {**part, "size_mm": size} + if role_axis_mode and role in ROLE_PRODUCT_THICKNESS_AXES: + expected_axes = sorted(ROLE_PRODUCT_THICKNESS_AXES[role]) + accepted = thickness_axis in ROLE_PRODUCT_THICKNESS_AXES[role] + orientation_audit.append({ + "part_id": key, "functional_role": role, + "product_thickness_axis": thickness_axis, + "expected_product_thickness_axes": expected_axes, + "accepted": accepted, + }) + if not accepted: + issue( + "role_axis_template", f"/parts/{key}/thickness_axis", + "板件角色与产品坐标厚度轴矛盾;必须修正产品X/Y/Z尺寸和厚度轴,禁止通过实例二次旋转掩盖", + part_id=key, functional_role=role, actual=thickness_axis, + expected=expected_axes, + ) except (GraphError, KeyError, TypeError) as exc: issue("part_geometry", f"/parts/{index}", str(exc)) assemblies = {} assembly_bounds = {} + allowed_assembly_roles = {"carcass", "drawer", "door", "module", "frame", "hardware_group", "other"} if graph.get("assemblies") is not None: for index, assembly in enumerate(graph.get("assemblies") or []): path = f"/assemblies/{index}" @@ -704,8 +848,14 @@ def compile_graph( if not key or key in assemblies: issue("assembly_identity", path, "总成ID缺失或重复") continue - if assembly.get("role") not in {"carcass", "drawer", "door", "module", "frame", "hardware_group", "other"}: - issue("assembly_role", path, "总成必须声明可审计的结构角色") + if assembly.get("role") not in allowed_assembly_roles: + issue( + "assembly_role", f"{path}/role", + "总成必须使用固定结构角色枚举;例如门总成的role是door,不是door_assembly", + assembly_id=key, actual=assembly.get("role"), + allowed=sorted(allowed_assembly_roles), + legal_repair={"op": "replace", "path": f"/assemblies/{key}/role", "value_shape": sorted(allowed_assembly_roles)}, + ) continue refs = assembly.get("evidence_ids") or [] if not refs or any(ref not in observations for ref in refs): @@ -736,16 +886,26 @@ def compile_graph( try: if not key or key in instances or key in instance_specs or inst.get("part_id") not in parts or not inst.get("assembly_id"): raise GraphError("实例ID重复、零件不存在或未声明所属总成") - # Axis order is explicit. No board-name/longest-edge remapping. - if inst.get("axis_order", [0, 1, 2]) not in ([0, 1, 2], [0, 2, 1], [1, 0, 2], [1, 2, 0], [2, 0, 1], [2, 1, 0]): + # Legacy graphs may explicitly map local axes. New graphs have + # already declared part dimensions in product X/Y/Z and therefore + # cannot rotate them again at instance level. + axis_order = inst.get("axis_order", [0, 1, 2]) + if axis_order not in ([0, 1, 2], [0, 2, 1], [1, 0, 2], [1, 2, 0], [2, 0, 1], [2, 1, 0]): raise GraphError("只支持明确的正交轴映射,任意角度需后续几何编译器") - size = [parts[inst["part_id"]]["size_mm"][a] for a in inst.get("axis_order", [0, 1, 2])] + if role_axis_mode and axis_order != [0, 1, 2]: + issue( + "role_axis_template", f"/instances/{key}/axis_order", + "新图谱的零件尺寸已经是产品X宽/Y深/Z高,实例禁止再次交换坐标轴", + instance_id=key, actual=axis_order, expected=[0, 1, 2], + ) + size = [parts[inst["part_id"]]["size_mm"][a] for a in axis_order] local_thickness_axis = parts[inst["part_id"]].get("thickness_axis") product_thickness_axis = None if local_thickness_axis in {"x", "y", "z"}: - product_thickness_axis = "xyz"[ - inst.get("axis_order", [0, 1, 2]).index("xyz".index(local_thickness_axis)) - ] + product_thickness_axis = ( + local_thickness_axis if role_axis_mode else + "xyz"[axis_order.index("xyz".index(local_thickness_axis))] + ) inherited = { "functional_role": parts[inst["part_id"]].get("functional_role"), "thickness_axis": product_thickness_axis, @@ -785,10 +945,33 @@ def compile_graph( "assembly_range_mm": bounds[axis], }) if outside_axes: + legal_alignment_replacements = [] + for row in outside_axes: + axis_name = row["axis"] + legal_alignment_replacements.extend([ + { + "op": "replace", + "path": f"/instances/{key}/placement/{axis_name}", + "value": {"mode": "align", "self_anchor": "min", "target": inst["assembly_id"], + "target_anchor": "min", "offset_mm": 0}, + }, + { + "op": "replace", + "path": f"/instances/{key}/placement/{axis_name}", + "value": {"mode": "align", "self_anchor": "max", "target": inst["assembly_id"], + "target_anchor": "max", "offset_mm": 0}, + }, + ]) issue( "outside_assembly_bounds", f"/instances/{key}", f"部件超出所属总成{inst['assembly_id']}的局部边界;不能借用整件产品坐标掩盖模块错位", - assembly_id=inst["assembly_id"], axes=outside_axes, + instance_id=key, assembly_id=inst["assembly_id"], axes=outside_axes, + current_placement=instance_specs.get(key, {}).get("placement"), + legal_alignment_replacements=legal_alignment_replacements, + correction=( + "先核对零件产品轴尺寸,再选择与所属总成min或max面一致的零偏移锚点;" + "没有独立图像证据不得给多个部件统一追加经验偏移。" + ), ) used = Counter(inst.get("assembly_id") for inst in instances.values()) for key in assemblies: @@ -1135,6 +1318,8 @@ def compile_graph( "visual_validation_required": True, "manufacturing_validation_required": True, "parameters": {key: {"value": value, "length_exponent": dimension, "confidence": confidence, "estimated": estimated} for key, (value, dimension, confidence, estimated) in resolver.resolved.items()}, + "orientation_solver": orientation_solver, + "orientation_audit": orientation_audit, "instances": list(instances.values()), "solver_adjustments": solver_adjustments, "placement_audit": placement_audit, "derived_contacts": derived_contacts, 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 c079ea9..d2b2538 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 @@ -6,6 +6,7 @@ import unittest from structure_graph import ( GraphError, ParameterResolver, SCHEMA_VERSION, apply_graph_patch, build_dimension_ledger, build_source_topology_contract, compile_graph, + normalize_graph_patch_payload, ) @@ -94,6 +95,51 @@ def anchored_frame_with_drawer_assembly(): class StructureGraphTest(unittest.TestCase): + def test_role_axis_mode_accepts_direct_product_axes_without_instance_mapping(self): + graph = anchored_frame() + graph["orientation_solver"] = "role_axis_templates_v1" + result = compile_case(graph) + self.assertTrue(result["ok"], result["issues"]) + self.assertEqual("role_axis_templates_v1", result["orientation_solver"]) + self.assertTrue(all(row["accepted"] for row in result["orientation_audit"])) + sizes = {row["id"]: row["size_mm"] for row in result["instances"]} + self.assertEqual([18.0, 400.0, 1100.0], sizes["i1"]) + self.assertEqual([764.0, 400.0, 18.0], sizes["i3"]) + + def test_role_axis_mode_blocks_role_thickness_mismatch(self): + graph = anchored_frame() + graph["orientation_solver"] = "role_axis_templates_v1" + graph["parts"][0]["functional_role"] = "door" + result = compile_case(graph) + issue = next(row for row in result["issues"] if row["code"] == "role_axis_template") + self.assertEqual("x", issue["actual"]) + self.assertEqual(["y"], issue["expected"]) + + def test_role_axis_mode_blocks_second_instance_axis_swap(self): + graph = anchored_frame() + graph["orientation_solver"] = "role_axis_templates_v1" + graph["instances"][0]["axis_order"] = [2, 1, 0] + result = compile_case(graph) + issue = next( + row for row in result["issues"] + if row["code"] == "role_axis_template" and row["path"].endswith("/axis_order") + ) + self.assertEqual([0, 1, 2], issue["expected"]) + + def test_role_axis_mode_is_invariant_to_display_names(self): + graph = anchored_frame() + graph["orientation_solver"] = "role_axis_templates_v1" + first = compile_case(graph) + graph["parts"][0]["name"] = "unrelated-display-name" + graph["parts"][1]["name"] = "另一个完全无关的显示名" + second = compile_case(graph) + self.assertEqual(first["orientation_audit"], second["orientation_audit"]) + structural = lambda rows: [ + {key: value for key, value in row.items() if key != "name"} + for row in rows + ] + self.assertEqual(structural(first["bom"]), structural(second["bom"])) + def test_independent_multiview_role_count_blocks_silent_graph_omission(self): graph = anchored_frame() source_structure = { @@ -249,6 +295,61 @@ class StructureGraphTest(unittest.TestCase): {"op": "replace", "path": "/assemblies/missing/bounds_mm/z", "value": [0, 1]}, ]) + def test_local_graph_patch_rejects_whole_placement_overwrite(self): + graph = anchored_frame() + with self.assertRaisesRegex(GraphError, "禁止整段覆盖实例placement"): + apply_graph_patch(graph, [{ + "op": "replace", "path": "/instances/i1/placement", + "value": copy.deepcopy(graph["instances"][1]["placement"]), + }]) + + def test_patch_payload_expands_whole_placement_to_changed_axes(self): + graph = anchored_frame() + replacement = copy.deepcopy(graph["instances"][0]["placement"]) + replacement["x"] = {"mode": "absolute", "value_mm": 12} + patches, audit = normalize_graph_patch_payload(graph, {"patches": [{ + "op": "replace", "path": "/instances/i1/placement", "value": replacement, + }]}) + self.assertEqual([{ + "op": "replace", "path": "/instances/i1/placement/x", + "value": {"mode": "absolute", "value_mm": 12}, + }], patches) + self.assertEqual(["/instances/i1/placement/x"], audit[1]["expanded_paths"]) + updated, patch_audit = apply_graph_patch(graph, patches) + self.assertEqual(12, updated["instances"][0]["placement"]["x"]["value_mm"]) + self.assertEqual("/instances/i1/placement/x", patch_audit[0]["path"]) + + def test_patch_payload_accepts_direct_list_and_numeric_instance(self): + graph = anchored_frame() + replacement = copy.deepcopy(graph["instances"][1]["placement"]) + replacement["z"] = {"mode": "absolute", "value_mm": 19} + patches, audit = normalize_graph_patch_payload(graph, [{ + "op": "replace", "path": "/instances/1/placement", "value": replacement, + }]) + self.assertEqual("patch_list", audit[0]["source"]) + self.assertEqual("/instances/1/placement/z", patches[0]["path"]) + + def test_patch_payload_rejects_malformed_or_noop_whole_placement(self): + graph = anchored_frame() + with self.assertRaisesRegex(GraphError, "恰好包含x、y、z"): + normalize_graph_patch_payload(graph, {"patches": [{ + "op": "replace", "path": "/instances/i1/placement", "value": {"x": {}}, + }]}) + with self.assertRaisesRegex(GraphError, "没有产生任何有效变化"): + normalize_graph_patch_payload(graph, {"patches": [{ + "op": "replace", "path": "/instances/i1/placement", + "value": copy.deepcopy(graph["instances"][0]["placement"]), + }]}) + + def test_invalid_assembly_role_feedback_contains_legal_enum_and_stable_path(self): + graph = anchored_frame_with_drawer_assembly() + graph["assemblies"][1]["role"] = "drawer_assembly" + result = compile_case(graph) + issue = next(row for row in result["issues"] if row["code"] == "assembly_role") + self.assertEqual("/assemblies/drawer-1/role", issue["legal_repair"]["path"]) + self.assertIn("drawer", issue["allowed"]) + self.assertNotIn("drawer_assembly", issue["allowed"]) + def test_direct_source_dimension_ledger_blocks_axis_swap(self): graph = frame(800, 400, 1100, 18) valid = compile_graph( @@ -449,7 +550,12 @@ class StructureGraphTest(unittest.TestCase): def test_part_outside_assembly_and_solid_interference_are_blocking(self): outside = anchored_frame_with_drawer_assembly() outside["instances"][-1]["placement"]["z"] = {"mode": "absolute", "value_mm": 650} - self.assertIn("outside_assembly_bounds", {row["code"] for row in compile_case(outside)["issues"]}) + result = compile_case(outside) + self.assertIn("outside_assembly_bounds", {row["code"] for row in result["issues"]}) + issue = next(row for row in result["issues"] if row["code"] == "outside_assembly_bounds") + self.assertEqual("drawer-solid", issue["instance_id"]) + self.assertEqual(2, len(issue["legal_alignment_replacements"])) + self.assertEqual(0, issue["legal_alignment_replacements"][0]["value"]["offset_mm"]) collision = anchored_frame() collision["instances"][2]["placement"]["x"] = {"mode": "absolute", "value_mm": 10}