From 2471105cba005681aeec1129184c0c378b3e4d98 Mon Sep 17 00:00:00 2001 From: chenpei Date: Tue, 8 Sep 2026 17:10:52 +0800 Subject: [PATCH] fix: hot-reload model credentials and harden CAD partitions --- .../ai-solid-wood-cost/VALIDATION_LEDGER.md | 24 ++ .../probe_structure_graph.py | 346 ++++++++++++++++-- .../solid_wood_cost_engine.py | 37 +- .../ai-solid-wood-cost/structure_graph.py | 21 +- .../test_manufacturing_oracle.py | 138 ++++++- .../test_solid_wood_cost_engine.py | 25 ++ server_patch/aliyun_key_hot_reload/README.md | 34 ++ .../aliyun_key_hot_reload/chat_api.patch | 42 +++ .../codex_app_server_runtime.patch | 221 +++++++++++ 9 files changed, 855 insertions(+), 33 deletions(-) create mode 100644 server_patch/aliyun_key_hot_reload/README.md create mode 100644 server_patch/aliyun_key_hot_reload/chat_api.patch create mode 100644 server_patch/aliyun_key_hot_reload/codex_app_server_runtime.patch 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 1d9be8f..e0deb24 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 @@ -334,3 +334,27 @@ - 定位求解现按实例依赖树隔离失败:非法实例及依赖它的实例返回稳定 ID 路径,其余合法坐标、审计和 BOM 实例继续保留;对已知定位失败的零件、角色和总成暂缓派生数量/边界级联检查。新增测试证明一个跨总成引用只阻断受影响实例,不再把 `/instances` 整体清空。 - 初始图谱由一次大请求改为四个有边界的多模态阶段:核心证据/参数/总成/零件、非五金物理实例、五金模板安装、总成运动关系。每一段都重新获得全部原图;程序在合并前强制校验稳定 ID、逐零件实例数量、三轴定位结构、五金宿主和总成连接端点。初始结构实例只允许锚定整体或自身总成,禁止跨总成和实例依赖;失败阶段会保存审计并停止合并,不把半份图谱送入 CAD。 - 本地完整回归 236 项通过(238.562 秒),其中 1 项真实 OCCT 内核测试按设计仅在服务器开关启用时跳过;Python 语法检查通过。分阶段通用算法尚未做新的服务器原图实跑,也未部署生产;不同公司产品冻结后 BOM/成本盲测与总成本误差不超过 5% 仍未完成。 + +## 2026-09-08 v124-v131 凭据热更新与分区合同收紧(生产基础设施已修复,CAD 候选未上线) + +- 阿里云新密钥的本地文件与服务器受保护密钥文件 SHA-256 指纹一致,直接 `qwen3-vl-plus` 请求返回 HTTP 200。旧故障的真正原因是 Chat API 主进程和持久 Codex 子进程在启动时缓存了旧值,即使受保护文件已替换,请求仍使用被云端封锁的旧 Key,日志为 HTTP 401 `InvalidApiKey` / `API-key is blocked`。 +- 生产 Chat API 现在在每次请求时优先重读 `*_API_KEY_FILE`;持久 Codex 运行时在无在途请求时检查密钥指纹,变化时只替换子进程并保留会话恢复信息。以后旋转 Key 不再依赖主服务重启。 +- 部署前实际检查过去 5 分钟真实请求为 0、持久队列中运行/排队任务为 0;备份后仅在空闲窗口重启主服务一次。服务自 2026-09-08 15:40:20 CST 持续 active,健康端点 HTTP 200、NRestarts=0。部署后紧接的两条真实 Chat API Codex shadow 记录为 `completed`,部署前紧邻三条均因 `CodexAppServerError` 失败;新日志中未再出现密钥被锁错误。 +- 凭据热更新本地回归 15 项通过,包括“文件新值覆盖旧环境变量”和“只替换持久子进程”;已部署文件哈希与本地候选一致。Git 补丁仅包含受保护文件的读取与指纹机制,不包含密钥明文。 +- v124 首次真实分区请求被阿里云以 HTTP 400 拒绝:使用 `response_format=json_object` 时提示未字面包含 `json`。所有分区提示现已显式要求 JSON,并为非鉴权 HTTP 错误保留不含凭据的安全现场信息;该 400 后未再出现。 +- v126-v129 逐段暴露并阻断了非法稳定 ID、只生成门总成却没有主柜体总成、缺少 `functional_role` 以及不符合受限定位模式的 x 轴结构。核心响应现强制主总成、固定角色枚举和完整必填字段;结构实例只接受明确的 absolute/align JSON 形状。这些失败都在合并/CAD 前终止,没有伪造输出。 +- v130 暴露旧续跑只接受“已完整编译图”,因此无法从“素材已验收、某个分区已通过”的失败报告继续。续跑现分为素材、分区阶段和完整编译图三级检查点;只复用连续且状态为 accepted 的完整响应,失败阶段仍会重跑。 +- v131 从原图审计报告续跑:核心阶段 83.383 秒通过,非五金结构实例 36.849 秒通过;五金阶段 36.302 秒返回后因所有铰链/拉手实例都缺少 `assembly_id` 而被拒绝。现已将五金实例的完整字段合同、合法五金 part ID 和合法 assembly ID 显式写入提示,错误也会返回精确的实际引用值。 +- 当前完整回归 240 项通过(246.140 秒,1 项服务器 OCCT 依赖用例在本机按设计跳过),最新断点与五金合同专项回归通过。v131 仍未生成可验收 CAD;不同公司产品盲测、冻结后与公司 BOM 对比、总成本误差不超过 5% 仍未完成,因此产品成本评估状态必须继续保持测试失败/继续优化。 + +## 2026-09-08 v132-v138 分区续跑与当前合同失效门(候选,未上线) + +- v132 从已接受的核心和结构分区续跑,五金阶段补齐了总成 ID,但把定位轴返回为标量占位,未满足受限三轴定位合同;该响应在合并前被拒绝,没有进入 CAD。 +- v133 对仅缺少语义的标量五金定位做确定性绝对坐标归一化,并保留审计;五金阶段通过,关系阶段因缺少稳定关系 ID、使用未允许的 `type` 字段以及点坐标形式的轴而被拒绝。 +- v134 收紧关系合同后关系阶段通过,但核心分区把 `observations` 返回为对象而非证据列表,绑定证据时触发类型异常。核心分区现要求非空对象列表;旧对象形检查点会失效并重跑,不能进入图谱/CAD。 +- v135 使用新核心合同重新读取原图;核心阶段通过,结构实例将 x/y/z 放在实例顶层而不是 `placement` 下。只对字段层级错误执行无语义变化的确定性嵌套并记录审计,不修改轴值、总成或角色。 +- v136 完成四个分区后由编译器发现核心仍有三个裸数值参数、对象形包围盒,以及五金零件缺少模板族/模板 ID。核心合同现强制每个参数为带证据状态的节点、包围盒必须为三元素数组、每个五金零件必须从允许模板对中选择。 +- v137 暴露完整图谱续跑选择器仍可能拾取旧分区合同生成的已编译图。续跑选择器现逐个验证核心、结构、五金和关系分区均符合当前合同;旧合同图谱不再进入局部修复循环。 +- v138 在服务器 `/tmp/dalongxia-cad-v85-eI8HeF/probe-sideboard46-v138-current-core` 以当前合同从原图重新生成四个分区:核心、结构、五金和关系阶段均一次返回并通过分区格式门,总耗时约 229 秒;随后制造编译器正确阻断缺少允许转轴方向的四只铰链、旋钮安装槽位不一致,以及落在门总成边界外的铰链定位。最终状态为 `graph_and_cad_kernel_not_passed`,没有误标成功、没有输出错误生产图。 +- 最终候选完整回归 243 项通过(258.547 秒),其中 1 项真实 OCCT 内核用例按设计仅在服务器依赖启用时跳过;新增断点续跑、旧合同失效、分区形状和安全归一化检查均通过。 +- 本轮明确证明凭据热更新与分区检查点机制已恢复真实模型调用,并把错误从格式/断点层推进到具体五金装配语义;但 v138 仍未生成可独立验收的 CAD。不同公司产品盲测、冻结后成本对比和总成本误差不超过 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 9f6b608..4195141 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 @@ -18,7 +18,8 @@ import subprocess import time from structure_graph import ( - GraphError, SCHEMA_VERSION, apply_graph_patch, build_dimension_ledger, + ALLOWED_ASSEMBLY_ROLES, ALLOWED_PART_ROLES, GraphError, SCHEMA_VERSION, + apply_graph_patch, build_dimension_ledger, build_source_topology_contract, compile_graph, normalize_graph_patch_payload, ) from manufacturing_oracle import plan_manufacturing_features @@ -783,19 +784,84 @@ def propose_reference_scope_projection( return (result if changed else None), audit -def select_resume_checkpoint(rows: list[dict]) -> dict: - """Select only an accepted, internally consistent audited checkpoint.""" +def find_resume_checkpoint(rows: list[dict]) -> dict | None: + """Find an accepted compiled checkpoint, or allow a source/stage-only resume.""" candidates = [ row for row in rows if ( isinstance(row.get("graph"), dict) and isinstance(row.get("compiled"), dict) and not row.get("patch_error") + and round_partition_contract_is_current(row) ) ] - if not candidates: + return ( + min(candidates, key=lambda row: (round_blocker_count(row), -int(row.get("round") or 0))) + if candidates else None + ) + + +def select_resume_checkpoint(rows: list[dict]) -> dict: + """Select only an accepted, internally consistent audited checkpoint.""" + checkpoint = find_resume_checkpoint(rows) + if checkpoint is None: raise RuntimeError("resume report has no audited compiled graph checkpoint") - return min(candidates, key=lambda row: (round_blocker_count(row), -int(row.get("round") or 0))) + return checkpoint + + +def partition_response_is_reusable(stage: str, response) -> bool: + """Reject stale stage checkpoints that no longer satisfy the active contract.""" + if not isinstance(response, dict): + return False + if stage == "core": + observations = response.get("observations") + parameters = response.get("parameters") + envelope = response.get("envelope_mm") + parts = response.get("parts") + return ( + isinstance(observations, list) + and bool(observations) + and all(isinstance(item, dict) for item in observations) + and isinstance(parameters, dict) + and all(isinstance(item, dict) for item in parameters.values()) + and isinstance(envelope, list) + and len(envelope) == 3 + and isinstance(response.get("assemblies"), list) + and isinstance(parts, list) + and all( + isinstance(item, dict) + and ( + item.get("functional_role") != "hardware" + or ( + bool(item.get("hardware_family")) + and bool(item.get("hardware_template_id")) + ) + ) + for item in parts + ) + ) + if stage in {"structural_instances", "hardware_instances"}: + return isinstance(response.get("instances"), list) + if stage == "assembly_connections": + return isinstance(response.get("assembly_connections"), list) + return False + + +def round_partition_contract_is_current(row: dict) -> bool: + """Do not resume a compiled graph built from a now-invalid stage schema.""" + audit = row.get("initial_graph_partition_audit") if isinstance(row, dict) else None + if not isinstance(audit, list): + return True + for item in audit: + if not isinstance(item, dict): + continue + if item.get("status") not in {"accepted", "reused_audited_stage"}: + continue + stage = str(item.get("stage") or "") + response = item.get("response") + if stage and response is not None and not partition_response_is_reusable(stage, response): + return False + return True def structured_patch_feedback(graph: dict, patches, error: Exception | str) -> list[dict]: @@ -899,7 +965,7 @@ mounting_center_mm是孔型中心,必须在宿主安装平面上且完整孔 def generate_partitioned_initial_graph( *, engine, model: str, images: list[dict], evidence_context: str, - hardware_contract: dict, + hardware_contract: dict, resume_stages: dict[str, dict] | None = None, ) -> tuple[dict, list[dict]]: """Generate one graph through bounded, independently validated AI passes. @@ -909,6 +975,7 @@ def generate_partitioned_initial_graph( Company BOM data is deliberately absent from this interface. """ audit = [] + resume_stages = resume_stages or {} def request(stage: str, instruction: str, *, max_tokens: int, timeout_seconds: int): row = { @@ -917,6 +984,21 @@ def generate_partitioned_initial_graph( "company_bom_used_in_inference": False, } audit.append(row) + resumed = resume_stages.get(stage) + if isinstance(resumed, dict): + result = copy.deepcopy(resumed) + row.update({ + "status": "reused_audited_stage", "elapsed_seconds": 0.0, + "response": copy.deepcopy(result), + }) + print(json.dumps({ + "phase": "partitioned_graph_stage", "stage": stage, + "status": "reused_audited_stage", + }, ensure_ascii=False), flush=True) + return result, row + print(json.dumps({ + "phase": "partitioned_graph_stage", "stage": stage, "status": "started", + }, ensure_ascii=False), flush=True) started = time.time() try: result = engine._request_model_json({ @@ -934,16 +1016,29 @@ def generate_partitioned_initial_graph( "status": "failed", "elapsed_seconds": round(time.time() - started, 3), "error": f"{type(exc).__name__}: {exc}", }) + print(json.dumps({ + "phase": "partitioned_graph_stage", "stage": stage, "status": "failed", + "elapsed_seconds": row["elapsed_seconds"], + }, ensure_ascii=False), flush=True) raise PartitionedGraphError(f"{stage}: {exc}", audit) from exc row.update({"status": "received", "elapsed_seconds": round(time.time() - started, 3)}) + print(json.dumps({ + "phase": "partitioned_graph_stage", "stage": stage, "status": "received", + "elapsed_seconds": row["elapsed_seconds"], + }, ensure_ascii=False), flush=True) if not isinstance(result, dict): row["status"] = "rejected" row["error"] = "response_not_object" raise PartitionedGraphError(f"{stage}: 模型返回必须是JSON对象", audit) + row["response"] = copy.deepcopy(result) return result, row def fail(stage: str, row: dict, detail: str): row.update({"status": "rejected", "error": detail}) + print(json.dumps({ + "phase": "partitioned_graph_stage", "stage": stage, + "status": "rejected", "detail": detail, + }, ensure_ascii=False), flush=True) raise PartitionedGraphError(f"{stage}: {detail}", audit) core_prompt = ( @@ -953,10 +1048,32 @@ def generate_partitioned_initial_graph( "不能用常见家具经验补件。固定schema_version=furniture-assembly-graph/2、" "placement_solver=anchored_axes_v1、orientation_solver=role_axis_templates_v1、" "contact_derivation=aabb_within_assembly_v1。零件size_mm直接使用产品X/Y/Z坐标;" - "每个独立运动门或抽屉建立独立assembly,assembly边界必须紧贴其结构实体。" + "parameters必须是JSON对象,每个长度参数必须是完整节点:" + "观测值使用{\"kind\":\"length\",\"origin\":\"observed\",\"value\":数值," + "\"unit\":\"mm\",\"evidence_ids\":[\"证据ID\"],\"confidence\":0到1数值};" + "派生值使用{\"kind\":\"length\",\"origin\":\"derived\",\"expression\":\"已声明参数表达式\"}。" + "禁止把裸数值直接放入parameters。envelope_mm必须是恰3项JSON数组," + "例如[\"overall_width_mm\",\"overall_depth_mm\",\"overall_height_mm\"],禁止输出{x:[],y:[],z:[]}对象。" + "assemblies每项必须完整包含id、role、bounds_mm={x:[min,max],y:[min,max],z:[min,max]}、" + "evidence_ids;role只允许carcass/drawer/door/module/frame/hardware_group/other。" + "parts每项必须完整包含id、name、shape='box'、functional_role、thickness_axis、" + "material_family、size_mm=[x,y,z]、observed_quantity、evidence_ids;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,非板件可为null;material_family只允许" + "engineered_panel/solid_wood/metal/plastic/glass/other。" + "每个产品必须至少建立一个非运动主assembly,role只能是carcass、frame、module或other;" + "每个独立运动门或抽屉另建独立assembly,assembly边界必须紧贴其结构实体。" + "所有铰链、拉手、滑轨、旋钮等采购五金的functional_role必须是hardware并绑定给定模板," + "绝不能作为other或普通结构件。每个hardware part必须逐字复制" + "合同中同一条的hardware_family与hardware_template_id,size_mm固定为[1,1,1]占位;" + "禁止根据外观自造五金尺寸或省略模板ID。" "只返回一份图谱核心JSON,字段为schema_version、placement_solver、orientation_solver、" "contact_derivation、observations、parameters、envelope_mm、可选reference_bounds_mm、assemblies、" - "parts、uncertainties。不得引用公司BOM。\n可选五金模板合同:" + "parts、uncertainties。observations必须是JSON数组,禁止输出对象或image_ids/view_evidence容器;" + "每项必须为{\"id\":\"obs_front\",\"source_type\":\"image\",\"image_id\":\"image1\"," + "\"text\":\"本条观察\",\"confidence\":0到1之间数值};id必须稳定唯一,image_id必须引用本轮原图ID。" + "不得引用公司BOM。\n可选五金模板合同:" + json.dumps(hardware_contract, ensure_ascii=False, separators=(",", ":")) + evidence_context ) @@ -975,13 +1092,79 @@ def generate_partitioned_initial_graph( fail("core", core_audit, f"第1段越权输出{forbidden}") if not isinstance(core.get("assemblies"), list) or not isinstance(core.get("parts"), list): fail("core", core_audit, "assemblies和parts必须是数组") + parameters = core.get("parameters") + if ( + not isinstance(parameters, dict) + or any(not isinstance(item, dict) for item in parameters.values()) + ): + fail("core", core_audit, "parameters每个参数必须是带来源的JSON节点,不能是裸数值") + if not isinstance(core.get("envelope_mm"), list) or len(core["envelope_mm"]) != 3: + fail("core", core_audit, "envelope_mm必须是X/Y/Z顺序的三项数组") + observations = core.get("observations") + if ( + not isinstance(observations, list) or not observations + or any(not isinstance(item, dict) for item in observations) + ): + fail("core", core_audit, "observations必须是非空证据对象数组") + observation_ids = [] + for observation in observations: + observation_id = str(observation.get("id") or "") + confidence = observation.get("confidence") + if ( + not observation_id + or observation.get("source_type") != "image" + or not str(observation.get("image_id") or "") + or isinstance(confidence, bool) + or not isinstance(confidence, (int, float)) + or not 0 <= float(confidence) <= 1 + ): + fail("core", core_audit, f"证据{observation_id or '?'}缺少完整图像字段或置信度非法") + observation_ids.append(observation_id) + if len(set(observation_ids)) != len(observation_ids): + fail("core", core_audit, "observation ID重复") assembly_ids = [str(row.get("id") or "") for row in core["assemblies"] if isinstance(row, dict)] part_ids = [str(row.get("id") or "") for row in core["parts"] if isinstance(row, dict)] if not assembly_ids or "" in assembly_ids or len(set(assembly_ids)) != len(assembly_ids): fail("core", core_audit, "总成ID缺失或重复") if not part_ids or "" in part_ids or len(set(part_ids)) != len(part_ids): fail("core", core_audit, "零件ID缺失或重复") + invalid_assembly_roles = { + str(row.get("id") or ""): row.get("role") + for row in core["assemblies"] if isinstance(row, dict) + and row.get("role") not in ALLOWED_ASSEMBLY_ROLES + } + if invalid_assembly_roles: + fail("core", core_audit, f"总成角色不在固定枚举中: {invalid_assembly_roles!r}") + stationary_assembly_ids = [ + str(row.get("id") or "") for row in core["assemblies"] if isinstance(row, dict) + and row.get("role") not in {"door", "drawer", "hardware_group"} + ] + if not stationary_assembly_ids: + fail("core", core_audit, "缺少承载非运动结构件的主总成(carcass/frame/module/other)") + allowed_hardware_pairs = { + ( + str(item.get("hardware_family") or ""), + str(item.get("hardware_template_id") or ""), + ) + for item in hardware_contract if isinstance(item, dict) + } for part in core["parts"]: + role = part.get("functional_role") if isinstance(part, dict) else None + if role not in ALLOWED_PART_ROLES: + fail("core", core_audit, f"零件{part.get('id') if isinstance(part, dict) else '?'}的functional_role不合法: {role!r}") + if isinstance(part, dict) and role != "hardware" and ( + part.get("hardware_family") or part.get("hardware_template_id") + ): + fail("core", core_audit, f"零件{part.get('id')}声明五金模板但functional_role不是hardware") + if isinstance(part, dict) and role == "hardware": + pair = ( + str(part.get("hardware_family") or ""), + str(part.get("hardware_template_id") or ""), + ) + if not all(pair) or (allowed_hardware_pairs and pair not in allowed_hardware_pairs): + fail("core", core_audit, f"五金零件{part.get('id')}未绑定合法模板: {pair!r}") + if part.get("size_mm") != [1, 1, 1]: + fail("core", core_audit, f"五金零件{part.get('id')}的size_mm必须是[1,1,1]不计价占位") quantity = part.get("observed_quantity") if isinstance(part, dict) else None if isinstance(quantity, bool) or not isinstance(quantity, int) or quantity < 1: fail("core", core_audit, f"零件{part.get('id') if isinstance(part, dict) else '?'}缺少正整数observed_quantity") @@ -1000,13 +1183,22 @@ def generate_partitioned_initial_graph( } structural_prompt = ( "你正在执行家具CAD结构图谱的第2/4段:根据全部原图和已锁定核心,只输出每个非五金物理实例。" - "返回{\"instances\":[...]},数量必须逐part精确等于observed_quantity。每个实例必须有唯一id、" - "part_id、assembly_id和x/y/z三轴placement。每轴只允许absolute(value_mm),或" - "align(self_anchor,target,target_anchor,offset_mm)。为消除跨总成歧义,本段align的target" + "只返回JSON对象{\"instances\":[...]},数量必须逐part精确等于observed_quantity。每个实例必须有唯一id、" + "part_id、assembly_id和x/y/z三轴placement。每轴只能使用两种精确JSON结构:" + "{\"mode\":\"absolute\",\"value_mm\":数值},或" + "{\"mode\":\"align\",\"self_anchor\":\"min|center|max\",\"target\":\"envelope或合法assembly_id\"," + "\"target_anchor\":\"min|center|max\",\"offset_mm\":数值}。" + "三轴必须放在单一placement字段内,例如" + "{\"id\":\"inst_top\",\"part_id\":\"part_top\",\"assembly_id\":\"carcass\"," + "\"placement\":{\"x\":{...},\"y\":{...},\"z\":{...}}};禁止把x/y/z直接放在实例顶层。" + "为消除跨总成歧义,本段align的target" "只允许envelope、字面量assembly或该实例自己的assembly_id;禁止引用其他总成或其他实例。" "门板/抽屉部件必须在自己的紧边界assembly内定位;不得输出五金、mountings、connections或成本。" - "不得引用公司BOM。\n已锁定核心:" - + json.dumps(core, ensure_ascii=False, separators=(",", ":")) + evidence_context + "不得引用公司BOM。part_id与assembly_id必须逐字符复制下列合法ID,不得翻译、加前缀或另造ID。" + "\n合法非五金part_id:" + json.dumps(sorted(structural_parts), ensure_ascii=False) + + "\n合法assembly_id:" + json.dumps(sorted(assembly_ids), ensure_ascii=False) + + "\n已锁定核心:" + json.dumps(core, ensure_ascii=False, separators=(",", ":")) + + evidence_context ) structural_response, structural_audit = request( "structural_instances", structural_prompt, max_tokens=5000, timeout_seconds=180, @@ -1016,6 +1208,7 @@ def generate_partitioned_initial_graph( fail("structural_instances", structural_audit, "缺少instances数组") seen_instance_ids = set() structural_counts = {} + structural_normalizations = [] for item in structural_instances: if not isinstance(item, dict): fail("structural_instances", structural_audit, "结构实例必须是对象") @@ -1025,14 +1218,29 @@ def generate_partitioned_initial_graph( if not instance_id or instance_id in seen_instance_ids: fail("structural_instances", structural_audit, "结构实例ID缺失或重复") if part_id not in structural_parts or assembly_id not in assembly_ids: - fail("structural_instances", structural_audit, f"实例{instance_id}引用了非法零件或总成") + fail( + "structural_instances", structural_audit, + f"实例{instance_id}引用非法ID:part_id={part_id!r}, assembly_id={assembly_id!r}; " + f"合法part_id={sorted(structural_parts)!r}, 合法assembly_id={sorted(assembly_ids)!r}", + ) placement = item.get("placement") + if placement is None and all(isinstance(item.get(axis), dict) for axis in "xyz"): + placement = {axis: item.pop(axis) for axis in "xyz"} + item["placement"] = placement + structural_normalizations.append({ + "instance_id": instance_id, + "from": "top_level_axes", + "to": "nested_placement", + }) if not isinstance(placement, dict) or set(placement) != {"x", "y", "z"}: fail("structural_instances", structural_audit, f"实例{instance_id}必须声明三轴placement") for axis in "xyz": rule = placement[axis] if not isinstance(rule, dict) or rule.get("mode") not in {"absolute", "align"}: - fail("structural_instances", structural_audit, f"实例{instance_id}的{axis}轴定位无效") + fail( + "structural_instances", structural_audit, + f"实例{instance_id}的{axis}轴定位无效: {rule!r}", + ) if rule.get("mode") == "align" and rule.get("target") not in {"envelope", "assembly", assembly_id}: fail("structural_instances", structural_audit, f"实例{instance_id}的{axis}轴发生跨总成或实例引用") seen_instance_ids.add(instance_id) @@ -1043,18 +1251,28 @@ def generate_partitioned_initial_graph( "structural_instances", structural_audit, f"零件{part_id}实例数{structural_counts.get(part_id, 0)}不等于证据数量{part['observed_quantity']}", ) - structural_audit.update({"status": "accepted", "instance_count": len(structural_instances)}) + structural_audit.update({ + "status": "accepted", "instance_count": len(structural_instances), + "deterministic_normalizations": structural_normalizations, + }) hardware_instances = [] if hardware_parts: hardware_prompt = ( "你正在执行家具CAD结构图谱的第3/4段:只生成五金实例和安装关系。必须回看全部原图。" - "返回{\"instances\":[...]},每个hardware part的实例数必须等于observed_quantity。" - "placement仅为1×1×1占位,x/y/z都必须使用absolute;真实几何完全由模板mountings决定。" + "只返回JSON对象{\"instances\":[...]},每个hardware part的实例数必须等于observed_quantity。" + "每个instance必须完整包含id、part_id、assembly_id、placement和mountings;" + "part_id和assembly_id必须逐字符复制下方合法ID,不得省略、翻译或自造。" + "placement仅为1×1×1占位,必须逐字输出" + "{\"x\":{\"mode\":\"absolute\",\"value_mm\":0},\"y\":{\"mode\":\"absolute\"," + "\"value_mm\":0},\"z\":{\"mode\":\"absolute\",\"value_mm\":0}};" + "真实几何完全由模板mountings决定。" "每个mounting的host_instance_id只能引用给出的结构实例ID,slot、kind、face、" "embed_depth_mm、mounting_center_mm和evidence_ids必须齐全并服从模板合同。" "没有图像证据不得增加五金。不得输出结构实例、总成连接或成本,不得引用公司BOM。\n核心:" + json.dumps(core, ensure_ascii=False, separators=(",", ":")) + + "\n合法五金part_id:" + json.dumps(sorted(hardware_parts), ensure_ascii=False) + + "\n合法assembly_id:" + json.dumps(sorted(assembly_ids), ensure_ascii=False) + "\n合法结构实例ID:" + json.dumps(sorted(seen_instance_ids), ensure_ascii=False) + "\n五金模板合同:" + json.dumps(hardware_contract, ensure_ascii=False, separators=(",", ":")) + evidence_context @@ -1066,6 +1284,7 @@ def generate_partitioned_initial_graph( if not isinstance(hardware_instances, list): fail("hardware_instances", hardware_audit, "缺少instances数组") hardware_counts = {} + hardware_normalizations = [] for item in hardware_instances: if not isinstance(item, dict): fail("hardware_instances", hardware_audit, "五金实例必须是对象") @@ -1075,8 +1294,23 @@ def generate_partitioned_initial_graph( if not instance_id or instance_id in seen_instance_ids: fail("hardware_instances", hardware_audit, "五金实例ID缺失或与结构实例重复") if part_id not in hardware_parts or assembly_id not in assembly_ids: - fail("hardware_instances", hardware_audit, f"五金实例{instance_id}引用了非法零件或总成") + fail( + "hardware_instances", hardware_audit, + f"五金实例{instance_id}引用了非法零件或总成: " + f"part_id={part_id!r}, assembly_id={assembly_id!r}", + ) placement = item.get("placement") + if isinstance(placement, dict) and set(placement) == {"x", "y", "z"}: + for axis in "xyz": + scalar = placement.get(axis) + if isinstance(scalar, (int, float)) and not isinstance(scalar, bool): + placement[axis] = {"mode": "absolute", "value_mm": float(scalar)} + hardware_normalizations.append({ + "instance_id": instance_id, + "axis": axis, + "from": "numeric_placeholder", + "to": "absolute_placeholder", + }) if not isinstance(placement, dict) or set(placement) != {"x", "y", "z"} or any( not isinstance(placement[axis], dict) or placement[axis].get("mode") != "absolute" for axis in "xyz" @@ -1095,7 +1329,10 @@ def generate_partitioned_initial_graph( "hardware_instances", hardware_audit, f"五金{part_id}实例数{hardware_counts.get(part_id, 0)}不等于证据数量{part['observed_quantity']}", ) - hardware_audit.update({"status": "accepted", "instance_count": len(hardware_instances)}) + hardware_audit.update({ + "status": "accepted", "instance_count": len(hardware_instances), + "deterministic_normalizations": hardware_normalizations, + }) else: audit.append({ "stage": "hardware_instances", "status": "skipped_no_hardware_parts", @@ -1113,9 +1350,13 @@ def generate_partitioned_initial_graph( } for row in core["assemblies"]] relation_prompt = ( "你正在执行家具CAD结构图谱的第4/4段:只生成总成之间的真实装配/运动关系。" - "必须回看全部原图,返回{\"assembly_connections\":[...]}。a和b只能引用给出的assembly ID;" + "必须回看全部原图,只返回JSON对象{\"assembly_connections\":[...]}。a和b只能引用给出的assembly ID;" + "每项必须完整包含id、kind、a、b和evidence_ids,禁止用type代替kind。" "每个door或drawer运动总成必须且只能出现一次并放在a端,按图像证据选择hinged或sliding;" - "hinged给axis与angle_limits_deg,sliding给axis与travel_limits_mm。固定模块仅在真实接触时用fixed。" + "hinged必须精确使用{\"id\":\"稳定关系ID\",\"kind\":\"hinged\",\"a\":\"运动总成ID\"," + "\"b\":\"承载总成ID\",\"axis\":\"x|y|z\",\"angle_limits_deg\":[最小,最大]," + "\"evidence_ids\":[\"原图ID\"]};axis只是字符串x/y/z,禁止输出坐标点。" + "sliding同样必须给字符串axis和travel_limits_mm。固定模块仅在真实接触时用fixed。" "不能引用实例ID,不能输出零件、定位或成本,不得引用公司BOM。\n总成摘要:" + json.dumps(relation_context, ensure_ascii=False, separators=(",", ":")) + evidence_context ) @@ -1132,7 +1373,10 @@ def generate_partitioned_initial_graph( fail("assembly_connections", relation_audit, "总成连接必须是对象") connection_id = str(item.get("id") or "") if not connection_id or connection_id in connection_ids: - fail("assembly_connections", relation_audit, "总成连接ID缺失或重复") + fail( + "assembly_connections", relation_audit, + f"总成连接ID缺失或重复: id={connection_id!r}", + ) a_id, b_id = str(item.get("a") or ""), str(item.get("b") or "") if a_id not in assembly_ids or b_id not in assembly_ids or a_id == b_id: fail("assembly_connections", relation_audit, f"总成连接{connection_id}端点非法") @@ -1140,6 +1384,21 @@ def generate_partitioned_initial_graph( moving_counts[a_id] += 1 if item.get("kind") not in {"hinged", "sliding"}: fail("assembly_connections", relation_audit, f"运动总成{a_id}缺少hinged/sliding关系") + if item.get("axis") not in {"x", "y", "z"}: + fail( + "assembly_connections", relation_audit, + f"运动总成{a_id}的axis必须是x/y/z字符串: {item.get('axis')!r}", + ) + limit_field = ( + "angle_limits_deg" if item.get("kind") == "hinged" + else "travel_limits_mm" + ) + limits = item.get(limit_field) + if not isinstance(limits, list) or len(limits) != 2: + fail( + "assembly_connections", relation_audit, + f"运动总成{a_id}缺少两项{limit_field}", + ) elif b_id in moving_counts: fail("assembly_connections", relation_audit, f"运动总成{b_id}必须位于关系a端") connection_ids.add(connection_id) @@ -1467,6 +1726,7 @@ def main(): "material_family": args.material_family, "full_chat_api": False, "company_bom_used_in_inference": False, "rounds": [], "status": "not_validated"} resume_payload = None + resume_partition_stages = {} if args.resume_report: resume_path = pathlib.Path(args.resume_report) if resume_path.is_dir(): @@ -1485,6 +1745,33 @@ def main(): ): raise RuntimeError("resume report is not a compatible no-company-BOM isolated checkpoint") report["source_qualification"] = copy.deepcopy(resume_payload.get("source_qualification") or {}) + for prior_round in reversed(resume_payload.get("rounds") or []): + prior_audit = ( + prior_round.get("initial_graph_partition_audit") + if isinstance(prior_round, dict) else None + ) + if not isinstance(prior_audit, list): + continue + indexed = { + str(item.get("stage") or ""): item + for item in prior_audit if isinstance(item, dict) + } + for expected_stage in ( + "core", "structural_instances", "hardware_instances", + "assembly_connections", + ): + item = indexed.get(expected_stage) or {} + response = item.get("response") + if ( + item.get("status") not in {"accepted", "reused_audited_stage"} + or not partition_response_is_reusable(expected_stage, response) + ): + break + resume_partition_stages[expected_stage] = copy.deepcopy(response) + if resume_partition_stages: + break + if resume_partition_stages: + report["resumed_partition_stage_names"] = list(resume_partition_stages) print(json.dumps({ "case": case, "phase": "resume_audited_isolated_checkpoint", "source_report": str(resume_path), @@ -1610,8 +1897,18 @@ def main(): accepted_compile_issue_count = None accepted_total_blocker_count = None round_number_base = 0 + resume_graph_payload = None if resume_payload: - best = select_resume_checkpoint(resume_payload.get("rounds") or []) + if find_resume_checkpoint(resume_payload.get("rounds") or []) is None: + report["resumed_from"] = { + "selection": "source_and_partition_stage_checkpoint_only", + "compiler_feedback_refreshed": False, + "kernel_feedback_refreshed": False, + } + else: + resume_graph_payload = resume_payload + if resume_graph_payload: + best = select_resume_checkpoint(resume_graph_payload.get("rounds") or []) graph, bound_user_evidence, bound_scope_adjudications, scope_binding_audit = bind_scope_facts( copy.deepcopy(best["graph"]), scope_facts, ) @@ -1883,6 +2180,7 @@ def main(): engine=engine, model=args.model, images=images, evidence_context=evidence_context, hardware_contract=supported_hardware_contract(), + resume_stages=resume_partition_stages, ) row["initial_graph_partition_audit"] = partition_audit if graph: diff --git a/server_patch/ai_3d_model/ai-solid-wood-cost/solid_wood_cost_engine.py b/server_patch/ai_3d_model/ai-solid-wood-cost/solid_wood_cost_engine.py index a2629e7..1021ed1 100644 --- a/server_patch/ai_3d_model/ai-solid-wood-cost/solid_wood_cost_engine.py +++ b/server_patch/ai_3d_model/ai-solid-wood-cost/solid_wood_cost_engine.py @@ -2654,7 +2654,11 @@ class SolidWoodCostEngine: raise SolidWoodCostError( f"{failure_label}:读图模型鉴权失败(HTTP {exc.code})" ) from exc - last_error = exc + response_detail = self._safe_model_http_error_detail(exc) + suffix = f":{response_detail}" if response_detail else "" + last_error = SolidWoodCostError( + f"HTTP {exc.code} {exc.reason or ''}{suffix}".strip() + ) except ( urllib.error.URLError, TimeoutError, @@ -2670,6 +2674,37 @@ class SolidWoodCostEngine: f"{failure_label}:模型连续{bounded_attempts}次未返回可解析JSON({last_error})" ) from last_error + @staticmethod + def _safe_model_http_error_detail(error: urllib.error.HTTPError) -> str: + """Keep provider diagnostics while stripping credentials and noise.""" + try: + raw = error.read(8192) + except Exception: + return "" + text = raw.decode("utf-8", errors="replace").strip() if isinstance(raw, bytes) else str(raw or "").strip() + if not text: + return "" + try: + payload = json.loads(text) + except json.JSONDecodeError: + detail = text + else: + provider_error = payload.get("error") if isinstance(payload, dict) else None + if isinstance(provider_error, dict): + detail = " | ".join( + f"{key}={provider_error.get(key)}" + for key in ("code", "type", "param", "message") + if provider_error.get(key) not in (None, "") + ) + else: + detail = json.dumps(payload, ensure_ascii=False, separators=(",", ":")) + detail = re.sub(r"\bsk-[A-Za-z0-9_.-]{8,}\b", "", detail) + detail = re.sub( + r"(?i)\b(?:api[_ -]?key|authorization|bearer)\b\s*[:=]?\s*[^\s,;|]+", + "credential=", detail, + ) + return re.sub(r"\s+", " ", detail).strip()[:1000] + @classmethod def _model_json_was_truncated( cls, 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 f27bd85..9f23e82 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 @@ -43,6 +43,17 @@ ROLE_PRODUCT_THICKNESS_AXES = { "divider": {"x", "z"}, } ROLE_AXIS_SOLVER = "role_axis_templates_v1" +PANEL_PART_ROLES = frozenset({ + "top", "bottom", "side", "shelf", "door", "back", "drawer_front", + "drawer_left", "drawer_right", "drawer_back", "drawer_bottom", + "drawer_support", "divider", +}) +ALLOWED_PART_ROLES = PANEL_PART_ROLES | frozenset({ + "leg", "rail", "stile", "frame_member", "hardware", "other", +}) +ALLOWED_ASSEMBLY_ROLES = frozenset({ + "carcass", "drawer", "door", "module", "frame", "hardware_group", "other", +}) def build_source_topology_contract(source_structure: dict, *, min_confidence: float = 0.82) -> dict: @@ -1285,12 +1296,8 @@ def compile_graph( 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", - "drawer_left", "drawer_right", "drawer_back", "drawer_bottom", - "drawer_support", "divider", - } - allowed_roles = panel_roles | {"leg", "rail", "stile", "frame_member", "hardware", "other"} + panel_roles = PANEL_PART_ROLES + allowed_roles = ALLOWED_PART_ROLES allowed_material_families = { "engineered_panel", "solid_wood", "metal", "plastic", "glass", "other", } @@ -1354,7 +1361,7 @@ def compile_graph( issue("part_geometry", f"/parts/{index}", str(exc)) assemblies = {} assembly_bounds = {} - allowed_assembly_roles = {"carcass", "drawer", "door", "module", "frame", "hardware_group", "other"} + allowed_assembly_roles = ALLOWED_ASSEMBLY_ROLES if graph.get("assemblies") is not None: for index, assembly in enumerate(graph.get("assemblies") or []): path = f"/assemblies/{index}" 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 7fb7e0d..25960dc 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,12 +17,15 @@ from probe_structure_graph import ( deterministic_dead_end_question, diagnostic_mounting_clearance_cuts, has_minimum_axis_packing_dead_end, enrich_hardware_feedback_with_verified_placements, + find_resume_checkpoint, generate_partitioned_initial_graph, normalize_focused_source_adjudication, normalize_scope_facts, + partition_response_is_reusable, qualified_occupied_scope_adjudication, normalize_visual_review, propose_reference_scope_projection, refresh_compiled_kernel_feedback, - round_blocker_count, select_resume_checkpoint, source_for_role, + round_blocker_count, round_partition_contract_is_current, + select_resume_checkpoint, source_for_role, structured_kernel_feedback, structured_patch_feedback, terminal_dependency_feedback, ) @@ -174,7 +177,42 @@ class ManufacturingOracleTest(unittest.TestCase): content = call["messages"][0]["content"] self.assertEqual(source_images[0], content[0]) self.assertNotIn("COMPANY_BOM_SENTINEL", json.dumps(content, ensure_ascii=False)) + self.assertIn("JSON", content[-1]["text"]) self.assertFalse(call["enable_thinking"]) + structural_text = engine.calls[1]["messages"][0]["content"][-1]["text"] + self.assertIn('合法非五金part_id:["door", "side"]', structural_text) + self.assertIn('合法assembly_id:["carcass", "door-1"]', structural_text) + hardware_text = engine.calls[2]["messages"][0]["content"][-1]["text"] + self.assertIn("必须完整包含id、part_id、assembly_id、placement和mountings", hardware_text) + self.assertIn('合法五金part_id:["knob"]', hardware_text) + self.assertIn('合法assembly_id:["carcass", "door-1"]', hardware_text) + relation_text = engine.calls[3]["messages"][0]["content"][-1]["text"] + self.assertIn("必须完整包含id、kind、a、b和evidence_ids", relation_text) + self.assertIn("禁止用type代替kind", relation_text) + self.assertIn("axis只是字符串x/y/z", relation_text) + core_text = engine.calls[0]["messages"][0]["content"][-1]["text"] + self.assertIn("parts每项必须完整包含id、name、shape='box'、functional_role", core_text) + self.assertIn("observations必须是JSON数组", core_text) + self.assertIn("禁止把裸数值直接放入parameters", core_text) + self.assertIn("envelope_mm必须是恰3项JSON数组", core_text) + self.assertIn("每个hardware part必须逐字复制", core_text) + + def test_stale_object_shaped_observations_invalidate_partition_checkpoint(self): + stale = copy.deepcopy(self._partitioned_graph_responses()[0]) + stale["observations"] = {"image_ids": ["view"]} + self.assertFalse(partition_response_is_reusable("core", stale)) + self.assertTrue(partition_response_is_reusable( + "core", self._partitioned_graph_responses()[0], + )) + stale_round = { + "graph": {"id": "stale"}, "compiled": {"ok": False}, + "initial_graph_partition_audit": [{ + "stage": "core", "status": "accepted", "response": stale, + }], + } + self.assertFalse(round_partition_contract_is_current(stale_round)) + with self.assertRaisesRegex(RuntimeError, "no audited compiled graph checkpoint"): + select_resume_checkpoint([stale_round]) def test_partition_gate_rejects_cross_assembly_target_before_merge(self): class FakeEngine: @@ -195,6 +233,95 @@ class ManufacturingOracleTest(unittest.TestCase): self.assertEqual(2, len(engine.calls)) self.assertEqual("rejected", raised.exception.audit[-1]["status"]) + def test_partition_core_requires_stationary_parent_assembly(self): + class FakeEngine: + def __init__(self, responses): + self.responses = list(responses) + self.calls = [] + + def _request_model_json(self, body, *_args, **_kwargs): + self.calls.append(body) + return copy.deepcopy(self.responses.pop(0)) + + responses = self._partitioned_graph_responses() + responses[0]["assemblies"] = [responses[0]["assemblies"][1]] + engine = FakeEngine(responses) + with self.assertRaisesRegex(PartitionedGraphError, "缺少承载非运动结构件的主总成"): + generate_partitioned_initial_graph( + engine=engine, model="test-vl", images=[], + evidence_context="\nINDEPENDENT_SOURCE_EVIDENCE", hardware_contract={}, + ) + self.assertEqual(1, len(engine.calls)) + + def test_partition_resume_reuses_audited_stage_without_model_call(self): + class FakeEngine: + def __init__(self, responses): + self.responses = list(responses) + self.calls = [] + + def _request_model_json(self, body, *_args, **_kwargs): + self.calls.append(body) + return copy.deepcopy(self.responses.pop(0)) + + responses = self._partitioned_graph_responses() + engine = FakeEngine(responses[1:]) + graph, audit = generate_partitioned_initial_graph( + engine=engine, model="test-vl", images=[], + evidence_context="\nINDEPENDENT_SOURCE_EVIDENCE", hardware_contract={}, + resume_stages={"core": responses[0]}, + ) + self.assertEqual(3, len(engine.calls)) + self.assertEqual("accepted", audit[0]["status"]) + self.assertEqual(responses[0], audit[0]["response"]) + self.assertEqual(4, len(graph["instances"])) + + def test_hardware_scalar_placeholders_are_normalized_without_model_retry(self): + class FakeEngine: + def __init__(self, responses): + self.responses = list(responses) + self.calls = [] + + def _request_model_json(self, body, *_args, **_kwargs): + self.calls.append(body) + return copy.deepcopy(self.responses.pop(0)) + + responses = self._partitioned_graph_responses() + responses[2]["instances"][0]["placement"] = {"x": 0, "y": 0.0, "z": 0} + engine = FakeEngine(responses) + graph, audit = generate_partitioned_initial_graph( + engine=engine, model="test-vl", images=[], + evidence_context="\nINDEPENDENT_SOURCE_EVIDENCE", hardware_contract={}, + ) + knob = next(row for row in graph["instances"] if row["id"] == "knob-1") + self.assertEqual("absolute", knob["placement"]["x"]["mode"]) + self.assertEqual(3, len(audit[2]["deterministic_normalizations"])) + self.assertEqual(4, len(engine.calls)) + + def test_structural_top_level_axes_are_nested_without_changing_rules(self): + class FakeEngine: + def __init__(self, responses): + self.responses = list(responses) + self.calls = [] + + def _request_model_json(self, body, *_args, **_kwargs): + self.calls.append(body) + return copy.deepcopy(self.responses.pop(0)) + + responses = self._partitioned_graph_responses() + expected = copy.deepcopy(responses[1]["instances"][0]["placement"]) + first = responses[1]["instances"][0] + first.update(first.pop("placement")) + engine = FakeEngine(responses) + graph, audit = generate_partitioned_initial_graph( + engine=engine, model="test-vl", images=[], + evidence_context="\nINDEPENDENT_SOURCE_EVIDENCE", hardware_contract={}, + ) + actual = next(row for row in graph["instances"] if row["id"] == "side-left") + self.assertEqual(expected, actual["placement"]) + self.assertNotIn("x", actual) + self.assertEqual(1, len(audit[1]["deterministic_normalizations"])) + self.assertEqual(4, len(engine.calls)) + def test_missing_model_credentials_are_terminal_dependency_not_kernel_feedback(self): feedback = terminal_dependency_feedback(RuntimeError("家具读图模型尚未配置密钥")) self.assertEqual("model_credentials_missing", feedback["code"]) @@ -823,6 +950,15 @@ class ManufacturingOracleTest(unittest.TestCase): ] self.assertEqual("latest", select_resume_checkpoint(rows)["graph"]["id"]) + def test_source_only_resume_does_not_require_compiled_graph(self): + rows = [{ + "round": 1, + "initial_graph_partition_audit": [ + {"stage": "core", "status": "accepted", "response": {"parts": []}}, + ], + }] + self.assertIsNone(find_resume_checkpoint(rows)) + def test_diagnostic_mounting_clearance_is_limited_to_validated_penetration(self): compiled = { "instances": [ diff --git a/server_patch/ai_3d_model/ai-solid-wood-cost/test_solid_wood_cost_engine.py b/server_patch/ai_3d_model/ai-solid-wood-cost/test_solid_wood_cost_engine.py index 770e4ba..8606a6d 100644 --- a/server_patch/ai_3d_model/ai-solid-wood-cost/test_solid_wood_cost_engine.py +++ b/server_patch/ai_3d_model/ai-solid-wood-cost/test_solid_wood_cost_engine.py @@ -54,6 +54,31 @@ class SolidWoodCostEngineTest(unittest.TestCase): self.engine._request_model_json(body, "测试鉴权", max_attempts=3) self.assertEqual(1, opened.call_count) + def test_non_auth_http_error_keeps_safe_provider_diagnostic(self): + error = self.module.urllib.error.HTTPError( + "https://example.test/chat/completions", 400, "Bad Request", None, + io.BytesIO(json.dumps({ + "error": { + "code": "context_length_exceeded", "type": "invalid_request_error", + "message": "input is too long for sk-provider-secret-token", + } + }).encode()), + ) + os.environ["DASHSCOPE_API_KEY"] = "test-key" + body = { + "model": "test-model", + "messages": [{"role": "user", "content": "return json"}], + "response_format": {"type": "json_object"}, + } + with mock.patch.object(self.module.urllib.request, "urlopen", side_effect=error) as opened: + with self.assertRaisesRegex( + self.module.SolidWoodCostError, "context_length_exceeded" + ) as raised: + self.engine._request_model_json(body, "测试请求", max_attempts=1) + self.assertEqual(1, opened.call_count) + self.assertNotIn("sk-provider-secret-token", str(raised.exception)) + self.assertIn("", str(raised.exception)) + def test_silent_provider_output_cap_is_terminal_and_not_retried(self): raw = '{"parts":[{"name":"侧板","quantity":2,"evidence":"原图"}' + (" " * 900) payload = { diff --git a/server_patch/aliyun_key_hot_reload/README.md b/server_patch/aliyun_key_hot_reload/README.md new file mode 100644 index 0000000..e897283 --- /dev/null +++ b/server_patch/aliyun_key_hot_reload/README.md @@ -0,0 +1,34 @@ +# Aliyun key hot reload + +This patch closes two key-rotation gaps in the production Chat API without +storing any credential in Git: + +1. `video_gateway_api_key()` prefers a protected `*_API_KEY_FILE` on every + call, so the AI skill router cannot keep using the startup-cached value. +2. `CodexAppServerProcess` fingerprints the protected provider key before + every request. When the file changes and no request is pending, it replaces + only the persistent Codex child and resumes the existing thread. + +Production base hashes used by this patch: + +- `chat_api.py`: `de81004c50a96fdf2e0064340edab213139da6ef304014c1586a4059871a8a1b` +- `codex_app_server_runtime.py`: `13c4463c6226840ae1a751279b4ec830796fb8cb46e2491f245bbdfebc71dfb9` + +Deployed hashes on 2026-09-08: + +- `chat_api.py`: `878e4e0d4c7e5b91190f53b64dccca3154fa3a189ea006d2182d0930a9079c4f` +- `codex_app_server_runtime.py`: `1ba04cbd15842114c77e89e71c7f36a43a24bc77bac22fd88eeb5d714b573cb3` + +The deployment was gated on zero queued/running durable tasks and zero real +Chat API requests in the previous five minutes. A backup was created at +`/opt/dalongxia-chat-api/backups/aliyun-key-hot-reload-mLOvGl` before the +service restart. + +Verification: + +- the supplied key file fingerprint matched the protected server key file; +- a direct `qwen3-vl-plus` request returned HTTP 200; +- the production health endpoint returned HTTP 200 after restart; +- the next two Codex shadow records completed successfully, while the three + immediately preceding records had failed with `CodexAppServerError`; +- no post-deployment `InvalidApiKey` or `API-key is blocked` error appeared. diff --git a/server_patch/aliyun_key_hot_reload/chat_api.patch b/server_patch/aliyun_key_hot_reload/chat_api.patch new file mode 100644 index 0000000..a2b535b --- /dev/null +++ b/server_patch/aliyun_key_hot_reload/chat_api.patch @@ -0,0 +1,42 @@ +--- chat_api.py ++++ chat_api.py +@@ -2157,7 +2157,12 @@ + def video_gateway_api_key() -> str: + env = build_tool_env() + for key in ("DALONGXIA_VIDEO_GATEWAY_API_KEY", "DASHSCOPE_API_KEY", "BAILIAN_API_KEY", "QWEN_API_KEY"): +- value = (os.getenv(key) or env.get(key) or "").strip() ++ file_name = f"{key}_FILE" ++ configured_path = (os.getenv(file_name) or env.get(file_name) or "").strip() ++ if configured_path: ++ value = read_protected_secret_file(file_name, configured_path).strip() ++ else: ++ value = (os.getenv(key) or env.get(key) or "").strip() + if value: + return value + return "" +@@ -2165,11 +2170,22 @@ + + def ai_skill_router_api_config() -> dict[str, str]: + env = build_tool_env() +- explicit_key = ( +- os.getenv("DALONGXIA_AI_SKILL_ROUTER_API_KEY") +- or env.get("DALONGXIA_AI_SKILL_ROUTER_API_KEY") ++ explicit_key_file = ( ++ os.getenv("DALONGXIA_AI_SKILL_ROUTER_API_KEY_FILE") ++ or env.get("DALONGXIA_AI_SKILL_ROUTER_API_KEY_FILE") + or "" + ).strip() ++ explicit_key = ( ++ read_protected_secret_file( ++ "DALONGXIA_AI_SKILL_ROUTER_API_KEY_FILE", explicit_key_file, ++ ).strip() ++ if explicit_key_file ++ else ( ++ os.getenv("DALONGXIA_AI_SKILL_ROUTER_API_KEY") ++ or env.get("DALONGXIA_AI_SKILL_ROUTER_API_KEY") ++ or "" ++ ).strip() ++ ) + if explicit_key: + return { + "api_key": explicit_key, diff --git a/server_patch/aliyun_key_hot_reload/codex_app_server_runtime.patch b/server_patch/aliyun_key_hot_reload/codex_app_server_runtime.patch new file mode 100644 index 0000000..4a38756 --- /dev/null +++ b/server_patch/aliyun_key_hot_reload/codex_app_server_runtime.patch @@ -0,0 +1,221 @@ +--- codex_app_server_runtime.py ++++ codex_app_server_runtime.py +@@ -546,12 +546,36 @@ + self._notifications: deque[tuple[int, dict[str, Any]]] = deque(maxlen=10000) + self._notification_sequence = 0 + self._condition = threading.Condition() ++ self._lifecycle_lock = threading.RLock() + self._closed = False + self._stderr_tail: deque[str] = deque(maxlen=30) ++ self._started_provider_key_fingerprint = "" + + @property + def running(self) -> bool: + return bool(self._process is not None and self._process.poll() is None) ++ ++ def _provider_key_value(self) -> str: ++ if self.config.provider_api_key_file: ++ key_path = Path(self.config.provider_api_key_file).expanduser() ++ try: ++ key_value = key_path.read_text(encoding="utf-8").strip() ++ except OSError as exc: ++ raise CodexAppServerUnavailable( ++ "Codex provider API key file is unavailable" ++ ) from exc ++ if not key_value: ++ raise CodexAppServerUnavailable( ++ "Codex provider API key file is empty" ++ ) ++ return key_value ++ return str( ++ os.environ.get(self.config.provider_api_key_env or "OPENAI_API_KEY") or "" ++ ).strip() ++ ++ def _provider_key_fingerprint(self) -> str: ++ value = self._provider_key_value() ++ return hashlib.sha256(value.encode("utf-8")).hexdigest() if value else "" + + def _process_env(self) -> dict[str, str]: + allowed_names = { +@@ -573,81 +597,79 @@ + allowed_names.update(self.config.forward_env) + env = {name: value for name, value in os.environ.items() if name in allowed_names} + if self.config.provider_api_key_file: +- key_path = Path(self.config.provider_api_key_file).expanduser() +- try: +- key_value = key_path.read_text(encoding="utf-8").strip() +- except OSError as exc: +- raise CodexAppServerUnavailable( +- "Codex provider API key file is unavailable" +- ) from exc +- if not key_value: +- raise CodexAppServerUnavailable( +- "Codex provider API key file is empty" +- ) ++ key_value = self._provider_key_value() + env[self.config.provider_api_key_env or "OPENAI_API_KEY"] = key_value + env["CODEX_HOME"] = self.config.codex_home + env["CODEX_ANALYTICS_ENABLED"] = "0" + return env + + def start(self) -> None: +- if self.running: +- return +- if self._closed: +- raise CodexAppServerUnavailable("Codex App Server process is closed") +- Path(self.config.codex_home).mkdir(parents=True, exist_ok=True) +- Path(self.config.cwd).mkdir(parents=True, exist_ok=True) +- try: +- process = self.popen_factory( +- self.config.command, +- stdin=subprocess.PIPE, +- stdout=subprocess.PIPE, +- stderr=subprocess.PIPE, +- text=True, +- encoding="utf-8", +- errors="replace", +- bufsize=1, +- cwd=self.config.cwd, +- env=self._process_env(), +- ) +- except (FileNotFoundError, PermissionError, OSError) as exc: +- raise CodexAppServerUnavailable( +- f"unable to start Codex App Server: {type(exc).__name__}" +- ) from exc +- self._process = process +- self._reader = threading.Thread( +- target=self._read_stdout, +- name="codex-app-server-stdout", +- daemon=True, +- ) +- self._stderr_reader = threading.Thread( +- target=self._read_stderr, +- name="codex-app-server-stderr", +- daemon=True, +- ) +- self._reader.start() +- self._stderr_reader.start() +- try: +- self.request( +- "initialize", +- { +- "clientInfo": { +- "name": "dalongxia_claw", +- "title": "Dalongxia Claw", +- "version": "1.0.0", +- }, +- "capabilities": { +- "optOutNotificationMethods": [ +- "item/reasoning/textDelta", +- "rawResponse/completed", +- ] +- }, +- }, +- timeout=self.config.request_timeout_seconds, ++ with self._lifecycle_lock: ++ current_fingerprint = self._provider_key_fingerprint() ++ if self.running and current_fingerprint == self._started_provider_key_fingerprint: ++ return ++ if self.running: ++ with self._pending_lock: ++ if self._pending: ++ return ++ self._terminate_process() ++ if self._closed: ++ raise CodexAppServerUnavailable("Codex App Server process is closed") ++ Path(self.config.codex_home).mkdir(parents=True, exist_ok=True) ++ Path(self.config.cwd).mkdir(parents=True, exist_ok=True) ++ try: ++ process = self.popen_factory( ++ self.config.command, ++ stdin=subprocess.PIPE, ++ stdout=subprocess.PIPE, ++ stderr=subprocess.PIPE, ++ text=True, ++ encoding="utf-8", ++ errors="replace", ++ bufsize=1, ++ cwd=self.config.cwd, ++ env=self._process_env(), ++ ) ++ except (FileNotFoundError, PermissionError, OSError) as exc: ++ raise CodexAppServerUnavailable( ++ f"unable to start Codex App Server: {type(exc).__name__}" ++ ) from exc ++ self._process = process ++ self._started_provider_key_fingerprint = current_fingerprint ++ self._reader = threading.Thread( ++ target=self._read_stdout, ++ name="codex-app-server-stdout", ++ daemon=True, + ) +- self.notify("initialized", {}) +- except Exception: +- self.close() +- raise ++ self._stderr_reader = threading.Thread( ++ target=self._read_stderr, ++ name="codex-app-server-stderr", ++ daemon=True, ++ ) ++ self._reader.start() ++ self._stderr_reader.start() ++ try: ++ self.request( ++ "initialize", ++ { ++ "clientInfo": { ++ "name": "dalongxia_claw", ++ "title": "Dalongxia Claw", ++ "version": "1.0.0", ++ }, ++ "capabilities": { ++ "optOutNotificationMethods": [ ++ "item/reasoning/textDelta", ++ "rawResponse/completed", ++ ] ++ }, ++ }, ++ timeout=self.config.request_timeout_seconds, ++ ) ++ self.notify("initialized", {}) ++ except Exception: ++ self.close() ++ raise + + def _read_stdout(self) -> None: + process = self._process +@@ -731,7 +753,7 @@ + *, + timeout: float | None = None, + ) -> dict[str, Any]: +- if not self.running and method != "initialize": ++ if method != "initialize": + self.start() + with self._pending_lock: + self._request_id += 1 +@@ -885,10 +907,10 @@ + duration_ms=int((time.monotonic() - started_at) * 1000), + ) + +- def close(self) -> None: +- self._closed = True ++ def _terminate_process(self) -> None: + process = self._process + self._process = None ++ self._started_provider_key_fingerprint = "" + if process is None: + return + try: +@@ -911,7 +933,12 @@ + except OSError: + pass + ++ def close(self) -> None: ++ with self._lifecycle_lock: ++ self._closed = True ++ self._terminate_process() + ++ + def codex_plan_output_schema() -> dict[str, Any]: + return {