fix: hot-reload model credentials and harden CAD partitions

This commit is contained in:
chenpei 2026-09-08 17:10:52 +08:00
parent 6ca9e69062
commit 2471105cba
9 changed files with 855 additions and 33 deletions

View File

@ -334,3 +334,27 @@
- 定位求解现按实例依赖树隔离失败:非法实例及依赖它的实例返回稳定 ID 路径,其余合法坐标、审计和 BOM 实例继续保留;对已知定位失败的零件、角色和总成暂缓派生数量/边界级联检查。新增测试证明一个跨总成引用只阻断受影响实例,不再把 `/instances` 整体清空。 - 定位求解现按实例依赖树隔离失败:非法实例及依赖它的实例返回稳定 ID 路径,其余合法坐标、审计和 BOM 实例继续保留;对已知定位失败的零件、角色和总成暂缓派生数量/边界级联检查。新增测试证明一个跨总成引用只阻断受影响实例,不再把 `/instances` 整体清空。
- 初始图谱由一次大请求改为四个有边界的多模态阶段:核心证据/参数/总成/零件、非五金物理实例、五金模板安装、总成运动关系。每一段都重新获得全部原图;程序在合并前强制校验稳定 ID、逐零件实例数量、三轴定位结构、五金宿主和总成连接端点。初始结构实例只允许锚定整体或自身总成,禁止跨总成和实例依赖;失败阶段会保存审计并停止合并,不把半份图谱送入 CAD。 - 初始图谱由一次大请求改为四个有边界的多模态阶段:核心证据/参数/总成/零件、非五金物理实例、五金模板安装、总成运动关系。每一段都重新获得全部原图;程序在合并前强制校验稳定 ID、逐零件实例数量、三轴定位结构、五金宿主和总成连接端点。初始结构实例只允许锚定整体或自身总成,禁止跨总成和实例依赖;失败阶段会保存审计并停止合并,不把半份图谱送入 CAD。
- 本地完整回归 236 项通过(238.562 秒),其中 1 项真实 OCCT 内核测试按设计仅在服务器开关启用时跳过;Python 语法检查通过。分阶段通用算法尚未做新的服务器原图实跑,也未部署生产;不同公司产品冻结后 BOM/成本盲测与总成本误差不超过 5% 仍未完成。 - 本地完整回归 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% 仍未完成,产品成本评估继续保持测试失败/继续优化。

View File

@ -18,7 +18,8 @@ import subprocess
import time import time
from structure_graph import ( 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, build_source_topology_contract, compile_graph, normalize_graph_patch_payload,
) )
from manufacturing_oracle import plan_manufacturing_features from manufacturing_oracle import plan_manufacturing_features
@ -783,19 +784,84 @@ def propose_reference_scope_projection(
return (result if changed else None), audit return (result if changed else None), audit
def select_resume_checkpoint(rows: list[dict]) -> dict: def find_resume_checkpoint(rows: list[dict]) -> dict | None:
"""Select only an accepted, internally consistent audited checkpoint.""" """Find an accepted compiled checkpoint, or allow a source/stage-only resume."""
candidates = [ candidates = [
row for row in rows row for row in rows
if ( if (
isinstance(row.get("graph"), dict) isinstance(row.get("graph"), dict)
and isinstance(row.get("compiled"), dict) and isinstance(row.get("compiled"), dict)
and not row.get("patch_error") 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") 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]: def structured_patch_feedback(graph: dict, patches, error: Exception | str) -> list[dict]:
@ -899,7 +965,7 @@ mounting_center_mm是孔型中心,必须在宿主安装平面上且完整孔
def generate_partitioned_initial_graph( def generate_partitioned_initial_graph(
*, engine, model: str, images: list[dict], evidence_context: str, *, 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]]: ) -> tuple[dict, list[dict]]:
"""Generate one graph through bounded, independently validated AI passes. """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. Company BOM data is deliberately absent from this interface.
""" """
audit = [] audit = []
resume_stages = resume_stages or {}
def request(stage: str, instruction: str, *, max_tokens: int, timeout_seconds: int): def request(stage: str, instruction: str, *, max_tokens: int, timeout_seconds: int):
row = { row = {
@ -917,6 +984,21 @@ def generate_partitioned_initial_graph(
"company_bom_used_in_inference": False, "company_bom_used_in_inference": False,
} }
audit.append(row) 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() started = time.time()
try: try:
result = engine._request_model_json({ result = engine._request_model_json({
@ -934,16 +1016,29 @@ def generate_partitioned_initial_graph(
"status": "failed", "elapsed_seconds": round(time.time() - started, 3), "status": "failed", "elapsed_seconds": round(time.time() - started, 3),
"error": f"{type(exc).__name__}: {exc}", "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 raise PartitionedGraphError(f"{stage}: {exc}", audit) from exc
row.update({"status": "received", "elapsed_seconds": round(time.time() - started, 3)}) 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): if not isinstance(result, dict):
row["status"] = "rejected" row["status"] = "rejected"
row["error"] = "response_not_object" row["error"] = "response_not_object"
raise PartitionedGraphError(f"{stage}: 模型返回必须是JSON对象", audit) raise PartitionedGraphError(f"{stage}: 模型返回必须是JSON对象", audit)
row["response"] = copy.deepcopy(result)
return result, row return result, row
def fail(stage: str, row: dict, detail: str): def fail(stage: str, row: dict, detail: str):
row.update({"status": "rejected", "error": detail}) 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) raise PartitionedGraphError(f"{stage}: {detail}", audit)
core_prompt = ( core_prompt = (
@ -953,10 +1048,32 @@ def generate_partitioned_initial_graph(
"不能用常见家具经验补件。固定schema_version=furniture-assembly-graph/2、" "不能用常见家具经验补件。固定schema_version=furniture-assembly-graph/2、"
"placement_solver=anchored_axes_v1、orientation_solver=role_axis_templates_v1、" "placement_solver=anchored_axes_v1、orientation_solver=role_axis_templates_v1、"
"contact_derivation=aabb_within_assembly_v1。零件size_mm直接使用产品X/Y/Z坐标;" "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、" "只返回一份图谱核心JSON,字段为schema_version、placement_solver、orientation_solver、"
"contact_derivation、observations、parameters、envelope_mm、可选reference_bounds_mm、assemblies、" "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=(",", ":")) + json.dumps(hardware_contract, ensure_ascii=False, separators=(",", ":"))
+ evidence_context + evidence_context
) )
@ -975,13 +1092,79 @@ def generate_partitioned_initial_graph(
fail("core", core_audit, f"第1段越权输出{forbidden}") fail("core", core_audit, f"第1段越权输出{forbidden}")
if not isinstance(core.get("assemblies"), list) or not isinstance(core.get("parts"), list): if not isinstance(core.get("assemblies"), list) or not isinstance(core.get("parts"), list):
fail("core", core_audit, "assemblies和parts必须是数组") 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)] 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)] 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): if not assembly_ids or "" in assembly_ids or len(set(assembly_ids)) != len(assembly_ids):
fail("core", core_audit, "总成ID缺失或重复") fail("core", core_audit, "总成ID缺失或重复")
if not part_ids or "" in part_ids or len(set(part_ids)) != len(part_ids): if not part_ids or "" in part_ids or len(set(part_ids)) != len(part_ids):
fail("core", core_audit, "零件ID缺失或重复") 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"]: 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 quantity = part.get("observed_quantity") if isinstance(part, dict) else None
if isinstance(quantity, bool) or not isinstance(quantity, int) or quantity < 1: 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") 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 = ( structural_prompt = (
"你正在执行家具CAD结构图谱的第2/4段:根据全部原图和已锁定核心,只输出每个非五金物理实例。" "你正在执行家具CAD结构图谱的第2/4段:根据全部原图和已锁定核心,只输出每个非五金物理实例。"
"返回{\"instances\":[...]},数量必须逐part精确等于observed_quantity。每个实例必须有唯一id、" "只返回JSON对象{\"instances\":[...]},数量必须逐part精确等于observed_quantity。每个实例必须有唯一id、"
"part_id、assembly_id和x/y/z三轴placement。每轴只允许absolute(value_mm),或" "part_id、assembly_id和x/y/z三轴placement。每轴只能使用两种精确JSON结构:"
"align(self_anchor,target,target_anchor,offset_mm)。为消除跨总成歧义,本段align的target" "{\"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;禁止引用其他总成或其他实例。" "只允许envelope、字面量assembly或该实例自己的assembly_id;禁止引用其他总成或其他实例。"
"门板/抽屉部件必须在自己的紧边界assembly内定位;不得输出五金、mountings、connections或成本。" "门板/抽屉部件必须在自己的紧边界assembly内定位;不得输出五金、mountings、connections或成本。"
"不得引用公司BOM。\n已锁定核心:" "不得引用公司BOM。part_id与assembly_id必须逐字符复制下列合法ID,不得翻译、加前缀或另造ID。"
+ json.dumps(core, ensure_ascii=False, separators=(",", ":")) + evidence_context "\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_response, structural_audit = request(
"structural_instances", structural_prompt, max_tokens=5000, timeout_seconds=180, "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数组") fail("structural_instances", structural_audit, "缺少instances数组")
seen_instance_ids = set() seen_instance_ids = set()
structural_counts = {} structural_counts = {}
structural_normalizations = []
for item in structural_instances: for item in structural_instances:
if not isinstance(item, dict): if not isinstance(item, dict):
fail("structural_instances", structural_audit, "结构实例必须是对象") 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: if not instance_id or instance_id in seen_instance_ids:
fail("structural_instances", structural_audit, "结构实例ID缺失或重复") fail("structural_instances", structural_audit, "结构实例ID缺失或重复")
if part_id not in structural_parts or assembly_id not in assembly_ids: 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") 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"}: if not isinstance(placement, dict) or set(placement) != {"x", "y", "z"}:
fail("structural_instances", structural_audit, f"实例{instance_id}必须声明三轴placement") fail("structural_instances", structural_audit, f"实例{instance_id}必须声明三轴placement")
for axis in "xyz": for axis in "xyz":
rule = placement[axis] rule = placement[axis]
if not isinstance(rule, dict) or rule.get("mode") not in {"absolute", "align"}: 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}: if rule.get("mode") == "align" and rule.get("target") not in {"envelope", "assembly", assembly_id}:
fail("structural_instances", structural_audit, f"实例{instance_id}的{axis}轴发生跨总成或实例引用") fail("structural_instances", structural_audit, f"实例{instance_id}的{axis}轴发生跨总成或实例引用")
seen_instance_ids.add(instance_id) seen_instance_ids.add(instance_id)
@ -1043,18 +1251,28 @@ def generate_partitioned_initial_graph(
"structural_instances", structural_audit, "structural_instances", structural_audit,
f"零件{part_id}实例数{structural_counts.get(part_id, 0)}不等于证据数量{part['observed_quantity']}", 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 = [] hardware_instances = []
if hardware_parts: if hardware_parts:
hardware_prompt = ( hardware_prompt = (
"你正在执行家具CAD结构图谱的第3/4段:只生成五金实例和安装关系。必须回看全部原图。" "你正在执行家具CAD结构图谱的第3/4段:只生成五金实例和安装关系。必须回看全部原图。"
"返回{\"instances\":[...]},每个hardware part的实例数必须等于observed_quantity。" "只返回JSON对象{\"instances\":[...]},每个hardware part的实例数必须等于observed_quantity。"
"placement仅为1×1×1占位,x/y/z都必须使用absolute;真实几何完全由模板mountings决定。" "每个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、" "每个mounting的host_instance_id只能引用给出的结构实例ID,slot、kind、face、"
"embed_depth_mm、mounting_center_mm和evidence_ids必须齐全并服从模板合同。" "embed_depth_mm、mounting_center_mm和evidence_ids必须齐全并服从模板合同。"
"没有图像证据不得增加五金。不得输出结构实例、总成连接或成本,不得引用公司BOM。\n核心:" "没有图像证据不得增加五金。不得输出结构实例、总成连接或成本,不得引用公司BOM。\n核心:"
+ json.dumps(core, ensure_ascii=False, separators=(",", ":")) + 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合法结构实例ID:" + json.dumps(sorted(seen_instance_ids), ensure_ascii=False)
+ "\n五金模板合同:" + json.dumps(hardware_contract, ensure_ascii=False, separators=(",", ":")) + "\n五金模板合同:" + json.dumps(hardware_contract, ensure_ascii=False, separators=(",", ":"))
+ evidence_context + evidence_context
@ -1066,6 +1284,7 @@ def generate_partitioned_initial_graph(
if not isinstance(hardware_instances, list): if not isinstance(hardware_instances, list):
fail("hardware_instances", hardware_audit, "缺少instances数组") fail("hardware_instances", hardware_audit, "缺少instances数组")
hardware_counts = {} hardware_counts = {}
hardware_normalizations = []
for item in hardware_instances: for item in hardware_instances:
if not isinstance(item, dict): if not isinstance(item, dict):
fail("hardware_instances", hardware_audit, "五金实例必须是对象") 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: if not instance_id or instance_id in seen_instance_ids:
fail("hardware_instances", hardware_audit, "五金实例ID缺失或与结构实例重复") fail("hardware_instances", hardware_audit, "五金实例ID缺失或与结构实例重复")
if part_id not in hardware_parts or assembly_id not in assembly_ids: 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") 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( if not isinstance(placement, dict) or set(placement) != {"x", "y", "z"} or any(
not isinstance(placement[axis], dict) or placement[axis].get("mode") != "absolute" not isinstance(placement[axis], dict) or placement[axis].get("mode") != "absolute"
for axis in "xyz" for axis in "xyz"
@ -1095,7 +1329,10 @@ def generate_partitioned_initial_graph(
"hardware_instances", hardware_audit, "hardware_instances", hardware_audit,
f"五金{part_id}实例数{hardware_counts.get(part_id, 0)}不等于证据数量{part['observed_quantity']}", 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: else:
audit.append({ audit.append({
"stage": "hardware_instances", "status": "skipped_no_hardware_parts", "stage": "hardware_instances", "status": "skipped_no_hardware_parts",
@ -1113,9 +1350,13 @@ def generate_partitioned_initial_graph(
} for row in core["assemblies"]] } for row in core["assemblies"]]
relation_prompt = ( relation_prompt = (
"你正在执行家具CAD结构图谱的第4/4段:只生成总成之间的真实装配/运动关系。" "你正在执行家具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;" "每个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总成摘要:" "不能引用实例ID,不能输出零件、定位或成本,不得引用公司BOM。\n总成摘要:"
+ json.dumps(relation_context, ensure_ascii=False, separators=(",", ":")) + evidence_context + json.dumps(relation_context, ensure_ascii=False, separators=(",", ":")) + evidence_context
) )
@ -1132,7 +1373,10 @@ def generate_partitioned_initial_graph(
fail("assembly_connections", relation_audit, "总成连接必须是对象") fail("assembly_connections", relation_audit, "总成连接必须是对象")
connection_id = str(item.get("id") or "") connection_id = str(item.get("id") or "")
if not connection_id or connection_id in connection_ids: 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 "") 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: 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}端点非法") fail("assembly_connections", relation_audit, f"总成连接{connection_id}端点非法")
@ -1140,6 +1384,21 @@ def generate_partitioned_initial_graph(
moving_counts[a_id] += 1 moving_counts[a_id] += 1
if item.get("kind") not in {"hinged", "sliding"}: if item.get("kind") not in {"hinged", "sliding"}:
fail("assembly_connections", relation_audit, f"运动总成{a_id}缺少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: elif b_id in moving_counts:
fail("assembly_connections", relation_audit, f"运动总成{b_id}必须位于关系a端") fail("assembly_connections", relation_audit, f"运动总成{b_id}必须位于关系a端")
connection_ids.add(connection_id) connection_ids.add(connection_id)
@ -1467,6 +1726,7 @@ def main():
"material_family": args.material_family, "full_chat_api": False, "material_family": args.material_family, "full_chat_api": False,
"company_bom_used_in_inference": False, "rounds": [], "status": "not_validated"} "company_bom_used_in_inference": False, "rounds": [], "status": "not_validated"}
resume_payload = None resume_payload = None
resume_partition_stages = {}
if args.resume_report: if args.resume_report:
resume_path = pathlib.Path(args.resume_report) resume_path = pathlib.Path(args.resume_report)
if resume_path.is_dir(): if resume_path.is_dir():
@ -1485,6 +1745,33 @@ def main():
): ):
raise RuntimeError("resume report is not a compatible no-company-BOM isolated checkpoint") raise RuntimeError("resume report is not a compatible no-company-BOM isolated checkpoint")
report["source_qualification"] = copy.deepcopy(resume_payload.get("source_qualification") or {}) 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({ print(json.dumps({
"case": case, "phase": "resume_audited_isolated_checkpoint", "case": case, "phase": "resume_audited_isolated_checkpoint",
"source_report": str(resume_path), "source_report": str(resume_path),
@ -1610,8 +1897,18 @@ def main():
accepted_compile_issue_count = None accepted_compile_issue_count = None
accepted_total_blocker_count = None accepted_total_blocker_count = None
round_number_base = 0 round_number_base = 0
resume_graph_payload = None
if resume_payload: 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( graph, bound_user_evidence, bound_scope_adjudications, scope_binding_audit = bind_scope_facts(
copy.deepcopy(best["graph"]), scope_facts, copy.deepcopy(best["graph"]), scope_facts,
) )
@ -1883,6 +2180,7 @@ def main():
engine=engine, model=args.model, images=images, engine=engine, model=args.model, images=images,
evidence_context=evidence_context, evidence_context=evidence_context,
hardware_contract=supported_hardware_contract(), hardware_contract=supported_hardware_contract(),
resume_stages=resume_partition_stages,
) )
row["initial_graph_partition_audit"] = partition_audit row["initial_graph_partition_audit"] = partition_audit
if graph: if graph:

View File

@ -2654,7 +2654,11 @@ class SolidWoodCostEngine:
raise SolidWoodCostError( raise SolidWoodCostError(
f"{failure_label}:读图模型鉴权失败(HTTP {exc.code})" f"{failure_label}:读图模型鉴权失败(HTTP {exc.code})"
) from exc ) 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 ( except (
urllib.error.URLError, urllib.error.URLError,
TimeoutError, TimeoutError,
@ -2670,6 +2674,37 @@ class SolidWoodCostEngine:
f"{failure_label}:模型连续{bounded_attempts}次未返回可解析JSON({last_error})" f"{failure_label}:模型连续{bounded_attempts}次未返回可解析JSON({last_error})"
) from 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", "<redacted-key>", detail)
detail = re.sub(
r"(?i)\b(?:api[_ -]?key|authorization|bearer)\b\s*[:=]?\s*[^\s,;|]+",
"credential=<redacted>", detail,
)
return re.sub(r"\s+", " ", detail).strip()[:1000]
@classmethod @classmethod
def _model_json_was_truncated( def _model_json_was_truncated(
cls, cls,

View File

@ -43,6 +43,17 @@ ROLE_PRODUCT_THICKNESS_AXES = {
"divider": {"x", "z"}, "divider": {"x", "z"},
} }
ROLE_AXIS_SOLVER = "role_axis_templates_v1" 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: 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: except (GraphError, KeyError, TypeError) as exc:
issue("reference_bounds", "/reference_bounds_mm", str(exc)) issue("reference_bounds", "/reference_bounds_mm", str(exc))
parts = {} parts = {}
panel_roles = { panel_roles = PANEL_PART_ROLES
"top", "bottom", "side", "shelf", "door", "back", "drawer_front", allowed_roles = ALLOWED_PART_ROLES
"drawer_left", "drawer_right", "drawer_back", "drawer_bottom",
"drawer_support", "divider",
}
allowed_roles = panel_roles | {"leg", "rail", "stile", "frame_member", "hardware", "other"}
allowed_material_families = { allowed_material_families = {
"engineered_panel", "solid_wood", "metal", "plastic", "glass", "other", "engineered_panel", "solid_wood", "metal", "plastic", "glass", "other",
} }
@ -1354,7 +1361,7 @@ def compile_graph(
issue("part_geometry", f"/parts/{index}", str(exc)) issue("part_geometry", f"/parts/{index}", str(exc))
assemblies = {} assemblies = {}
assembly_bounds = {} 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: if graph.get("assemblies") is not None:
for index, assembly in enumerate(graph.get("assemblies") or []): for index, assembly in enumerate(graph.get("assemblies") or []):
path = f"/assemblies/{index}" path = f"/assemblies/{index}"

View File

@ -17,12 +17,15 @@ from probe_structure_graph import (
deterministic_dead_end_question, deterministic_dead_end_question,
diagnostic_mounting_clearance_cuts, has_minimum_axis_packing_dead_end, diagnostic_mounting_clearance_cuts, has_minimum_axis_packing_dead_end,
enrich_hardware_feedback_with_verified_placements, enrich_hardware_feedback_with_verified_placements,
find_resume_checkpoint,
generate_partitioned_initial_graph, generate_partitioned_initial_graph,
normalize_focused_source_adjudication, normalize_scope_facts, normalize_focused_source_adjudication, normalize_scope_facts,
partition_response_is_reusable,
qualified_occupied_scope_adjudication, qualified_occupied_scope_adjudication,
normalize_visual_review, normalize_visual_review,
propose_reference_scope_projection, refresh_compiled_kernel_feedback, 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, structured_kernel_feedback, structured_patch_feedback,
terminal_dependency_feedback, terminal_dependency_feedback,
) )
@ -174,7 +177,42 @@ class ManufacturingOracleTest(unittest.TestCase):
content = call["messages"][0]["content"] content = call["messages"][0]["content"]
self.assertEqual(source_images[0], content[0]) self.assertEqual(source_images[0], content[0])
self.assertNotIn("COMPANY_BOM_SENTINEL", json.dumps(content, ensure_ascii=False)) self.assertNotIn("COMPANY_BOM_SENTINEL", json.dumps(content, ensure_ascii=False))
self.assertIn("JSON", content[-1]["text"])
self.assertFalse(call["enable_thinking"]) 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): def test_partition_gate_rejects_cross_assembly_target_before_merge(self):
class FakeEngine: class FakeEngine:
@ -195,6 +233,95 @@ class ManufacturingOracleTest(unittest.TestCase):
self.assertEqual(2, len(engine.calls)) self.assertEqual(2, len(engine.calls))
self.assertEqual("rejected", raised.exception.audit[-1]["status"]) 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): def test_missing_model_credentials_are_terminal_dependency_not_kernel_feedback(self):
feedback = terminal_dependency_feedback(RuntimeError("家具读图模型尚未配置密钥")) feedback = terminal_dependency_feedback(RuntimeError("家具读图模型尚未配置密钥"))
self.assertEqual("model_credentials_missing", feedback["code"]) self.assertEqual("model_credentials_missing", feedback["code"])
@ -823,6 +950,15 @@ class ManufacturingOracleTest(unittest.TestCase):
] ]
self.assertEqual("latest", select_resume_checkpoint(rows)["graph"]["id"]) 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): def test_diagnostic_mounting_clearance_is_limited_to_validated_penetration(self):
compiled = { compiled = {
"instances": [ "instances": [

View File

@ -54,6 +54,31 @@ class SolidWoodCostEngineTest(unittest.TestCase):
self.engine._request_model_json(body, "测试鉴权", max_attempts=3) self.engine._request_model_json(body, "测试鉴权", max_attempts=3)
self.assertEqual(1, opened.call_count) 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("<redacted-key>", str(raised.exception))
def test_silent_provider_output_cap_is_terminal_and_not_retried(self): def test_silent_provider_output_cap_is_terminal_and_not_retried(self):
raw = '{"parts":[{"name":"侧板","quantity":2,"evidence":"原图"}' + (" " * 900) raw = '{"parts":[{"name":"侧板","quantity":2,"evidence":"原图"}' + (" " * 900)
payload = { payload = {

View File

@ -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.

View File

@ -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,

View File

@ -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 {