fix: hot-reload model credentials and harden CAD partitions
This commit is contained in:
parent
6ca9e69062
commit
2471105cba
@ -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% 仍未完成,产品成本评估继续保持测试失败/继续优化。
|
||||||
|
|||||||
@ -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:
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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}"
|
||||||
|
|||||||
@ -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": [
|
||||||
|
|||||||
@ -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 = {
|
||||||
|
|||||||
34
server_patch/aliyun_key_hot_reload/README.md
Normal file
34
server_patch/aliyun_key_hot_reload/README.md
Normal 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.
|
||||||
42
server_patch/aliyun_key_hot_reload/chat_api.patch
Normal file
42
server_patch/aliyun_key_hot_reload/chat_api.patch
Normal 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,
|
||||||
@ -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 {
|
||||||
Loading…
x
Reference in New Issue
Block a user