From 61995674e952cf3a73dbad67694590d7e82efcdc Mon Sep 17 00:00:00 2001 From: chenpei Date: Tue, 8 Sep 2026 14:09:37 +0800 Subject: [PATCH] fix: split CAD vision facts into bounded passes --- .../solid_wood_cost_engine.py | 374 ++++++++++++------ .../test_solid_wood_cost_engine.py | 121 +++++- 2 files changed, 374 insertions(+), 121 deletions(-) diff --git a/server_patch/ai_3d_model/ai-solid-wood-cost/solid_wood_cost_engine.py b/server_patch/ai_3d_model/ai-solid-wood-cost/solid_wood_cost_engine.py index c0fc335..ef826aa 100644 --- a/server_patch/ai_3d_model/ai-solid-wood-cost/solid_wood_cost_engine.py +++ b/server_patch/ai_3d_model/ai-solid-wood-cost/solid_wood_cost_engine.py @@ -45,7 +45,7 @@ class SolidWoodCostEngine: DISPLAY_NAME = "产品成本评估" MODULE_NAME = "家具成本评估" - COST_MODEL_VERSION = "2026-09-07-constraint-vision-annotation-v83" + COST_MODEL_VERSION = "2026-09-08-bounded-visual-fact-shards-v120" DEFAULT_BATCH_QTY = 50 PURE_SOLID_WOOD_FACTORY_PROFIT_RATE = 0.10 NON_PURE_SOLID_WOOD_FACTORY_PROFIT_RATE = 0.08 @@ -3458,41 +3458,59 @@ class SolidWoodCostEngine: tasks = list(adjudication.get("machine_followups") or [])[:6] if not tasks: return {"view_findings": []} - content = [{ - "type": "text", - "text": ( - "这是家具原图结构的第二次聚焦复读。上一轮只确定了以下可见冲突/缺口:" - + ";".join(tasks) - + "。必须重新看像素,不得复述上一轮结论,也不得询问用户。逐张图独立输出;" - "数抽屉时按独立抽面边界,判断侧板分段时只沿左右外侧边追踪真实断缝," - "排除层板前沿、颜色边界、阴影和标注线。看不全就降低side_edge_visibility,不能写高置信连续。" - "side_seam_z_ratios必须从成品底部向上递增,逐条列出每一个候选物理断缝;不能只写最显眼的一条。" - "每个ratio必须有同序side_seam_evidence,只有沿侧板外轮廓看到实体终止/重新起始时physical_break_visible=true;" - "层板与侧板相交、颜色变化、尺寸数字或产品顶/底外边界都不是侧板断缝。" - "只返回JSON:{\"view_findings\":[{\"image_id\":1,\"observed_role\":\"front/side/third/dimension/unknown\"," - "\"complete_product_visible\":false,\"side_edge_visibility\":0," - "\"visible_counts\":{\"drawers\":0,\"doors\":0,\"shelves\":0,\"dividers\":0}," - "\"drawer_layout\":\"horizontal/vertical/mixed/unknown\"," - "\"side_segments_per_side\":null,\"side_seam_z_ratios\":[]," - "\"side_seam_evidence\":[{\"ratio_from_bottom\":0.0,\"physical_break_visible\":false,\"boundary_description\":\"\"}]," - "\"confidence\":0,\"evidence\":\"\"}]}。" - ), - }] - for index, asset in enumerate(assets[:8], 1): - content.extend([ - {"type": "text", "text": f"原始图 image_id={index};候选角色={asset.get('role') or '未知'},请自行复核。"}, - {"type": "image_url", "image_url": {"url": self._asset_review_data_url(asset)}}, - ]) - return self._request_model_json({ - "model": self.model, - "messages": [{"role": "user", "content": content}], - "temperature": 0.0, - "response_format": {"type": "json_object"}, - "max_tokens": 3072, - "enable_thinking": True, - "thinking_budget": 1536, - "vl_high_resolution_images": True, - }, "原图结构冲突聚焦复读失败", max_attempts=1, timeout_seconds=150) + # Keep each visual answer deliberately small. The provider can return + # a 200 response whose JSON is cut at its completion-token ceiling; a + # single eight-image seam report therefore is not a reliable unit of + # work. Image ids stay global while the calls are split in pairs. + findings = [] + errors = [] + indexed = list(enumerate(assets[:8], 1)) + for offset in range(0, len(indexed), 2): + batch = indexed[offset:offset + 2] + content = [{ + "type": "text", + "text": ( + "这是家具原图结构的独立逐视角复读。本轮待核对:" + + ";".join(tasks) + + "。只看本批原始像素,不得引用旧结论,不得询问用户。按独立抽面边界数抽屉;" + "侧板分段只认沿左右外侧轮廓可见的实体终止和重新起始,层板前沿、颜色、阴影、尺寸线都不算。" + "看不全就降低side_edge_visibility和confidence,不能写高置信连续。" + "只返回JSON:{\"view_findings\":[{\"image_id\":1,\"observed_role\":\"front/side/third/dimension/unknown\"," + "\"complete_product_visible\":false,\"side_edge_visibility\":0," + "\"visible_counts\":{\"drawers\":0,\"doors\":0,\"shelves\":0,\"dividers\":0}," + "\"drawer_layout\":\"horizontal/vertical/mixed/unknown\"," + "\"side_segments_per_side\":null,\"side_seam_z_ratios\":[]," + "\"side_seam_evidence\":[{\"ratio_from_bottom\":0.0,\"physical_break_visible\":false,\"boundary_description\":\"\"}]," + "\"confidence\":0,\"evidence\":\"\"}]}。每张输入图最多一项。" + ), + }] + for image_id, asset in batch: + content.extend([ + {"type": "text", "text": f"原始图 image_id={image_id};候选角色={asset.get('role') or '未知'},请自行复核。"}, + {"type": "image_url", "image_url": {"url": self._asset_review_data_url(asset)}}, + ]) + try: + raw = self._request_model_json({ + "model": self.model, + "messages": [{"role": "user", "content": content}], + "temperature": 0.0, + "response_format": {"type": "json_object"}, + "max_tokens": 900, + "enable_thinking": False, + "vl_high_resolution_images": True, + }, "原图结构逐视角复读失败", max_attempts=1, timeout_seconds=120) + if isinstance(raw.get("view_findings"), list): + allowed_ids = {image_id for image_id, _ in batch} + findings.extend( + item for item in raw["view_findings"][:len(batch)] + if isinstance(item, dict) and item.get("image_id") in allowed_ids + ) + except SolidWoodCostError as exc: + errors.append({ + "image_ids": [image_id for image_id, _ in batch], + "error": re.sub(r"\s+", " ", str(exc)).strip()[-300:], + }) + return {"view_findings": findings, "batch_errors": errors} def _read_source_structure(self, assets: list[dict]) -> dict: """Read assembly relationships before asking for numeric CAD geometry. @@ -3501,71 +3519,137 @@ class SolidWoodCostEngine: model or numerical fixture is sent. The later CAD review remains the independent validator; this graph is evidence, not ground truth. """ - content = [] - valid_ids = set() - for index, asset in enumerate(assets[:8], 1): - source_id = index - valid_ids.add(source_id) - content.extend([ - {"type": "text", "text": f"原始图 image_id={source_id},候选角色={asset.get('role') or '未知'};按画面自行判断视角。"}, - {"type": "image_url", "image_url": {"url": self._asset_review_data_url(asset)}}, - ]) - content.append({"type": "text", "text": ( - "本轮只识别家具部件及装配关系,不计算尺寸、孔槽或价格。先建立正面、侧面、上下模块关系," - "再逐图核对可见板件;每个抽屉分抽面、左帮、右帮、后帮、底板,不能拿固定柜体横隔板充当移动抽底。" - "侧板分段要沿外沿找真实断开接缝,颜色边界和搁板横线不等于侧板断开;同一种颜色也可能由上下模块的两块侧板组成," - "不得按颜色数量代替物理段数。标注链可辅助定位候选接缝,必须再核对该高度外轮廓是否断开。" - "看不见背部时present=null,不能把未显示背板当作无背板。视图互相冲突必须指出各图支持什么,不能凭多数默认一张错误。" - "这一轮只做部件清单和功能总成识别,不同时承担逐图接缝定位,以免一个请求过载。" - "只返回JSON,全部字段必须完整:" - '{"drawers":{"count":null,"layout":"horizontal/vertical/mixed/unknown","confidence":0,"evidence":"","image_ids":[]},' - '"doors":{"count":null,"mechanism":"sliding/hinged/none/unknown","confidence":0,"evidence":"","image_ids":[]},' - '"back_panel":{"present":null,"confidence":0,"evidence":"","image_ids":[]},' - '"parts":[{"name":"","quantity":0,"component_role":"","assembly_group":"carcass/drawer/other",' - '"confidence":0,"evidence":"","image_ids":[]}],' - '"view_conflicts":[],"genuinely_invisible":[]}。' - "component_role只允许top/bottom/side/shelf/door/back/drawer_front/drawer_left/drawer_right/drawer_back/drawer_bottom/drawer_support/divider/leg/other。" - "drawer_support属于carcass;其余drawer_*属于drawer。上下排列vertical,左右并排horizontal,混合mixed;" - "不能由抽屉数量推断排列。相同规格零件合并数量,parts最多32项。证据一句话,image_ids必须用本轮原图编号。" - "不可见的结构只列genuinely_invisible;" - "不要编造部件,更不要请求用户重新数已清晰可见的部件。低置信先交给后续AI局部复读。" - )}) - raw = self._request_model_json({ - "model": self.model, "messages": [{"role": "user", "content": content}], - "temperature": 0.0, "response_format": {"type": "json_object"}, "max_tokens": 3072, - "enable_thinking": False, "vl_high_resolution_images": True, - }, "多视角结构关系识别失败", max_attempts=1, timeout_seconds=150) - if not isinstance(raw.get("parts"), list) or not raw["parts"]: - raise SolidWoodCostError("多视角结构关系结果不完整") - allowed_roles = {"top", "bottom", "side", "shelf", "door", "back", "drawer_front", "drawer_left", "drawer_right", "drawer_back", "drawer_bottom", "drawer_support", "divider", "leg", "other"} + indexed_assets = list(enumerate(assets[:8], 1)) + valid_ids = {image_id for image_id, _ in indexed_assets} + if not indexed_assets: + raise SolidWoodCostError("没有可用于结构识别的原始图片") + + def source_content(instruction: str) -> list[dict]: + content = [{"type": "text", "text": instruction}] + for image_id, asset in indexed_assets: + content.extend([ + {"type": "text", "text": f"原始图 image_id={image_id},候选角色={asset.get('role') or '未知'};按画面自行判断。"}, + {"type": "image_url", "image_url": {"url": self._asset_review_data_url(asset)}}, + ]) + return content + + def request_shard(name: str, instruction: str, max_tokens: int) -> dict: + raw = self._request_model_json({ + "model": self.model, + "messages": [{"role": "user", "content": source_content(instruction)}], + "temperature": 0.0, + "response_format": {"type": "json_object"}, + "max_tokens": max_tokens, + "enable_thinking": False, + "vl_high_resolution_images": True, + }, f"多视角结构{name}识别失败", max_attempts=1, timeout_seconds=150) + if not isinstance(raw, dict): + raise SolidWoodCostError(f"多视角结构{name}结果不完整") + return raw + + # Do not ask one completion to serialize a whole structure graph. Each + # shard is independently checkable and the program, not the model, + # performs the final merge. + overview = request_shard( + "总成概览", + ( + "只判断家具功能总成,不列任何零件、不读尺寸、不算成本。抽屉按独立抽面计数;门要区分移门和铰链门;" + "背部看不见时present=null。只返回JSON:" + '{"drawers":{"count":null,"layout":"horizontal/vertical/mixed/unknown","confidence":0,"evidence":"","image_ids":[]},' + '"doors":{"count":null,"mechanism":"sliding/hinged/none/unknown","confidence":0,"evidence":"","image_ids":[]},' + '"back_panel":{"present":null,"confidence":0,"evidence":"","image_ids":[]},' + '"view_conflicts":[],"genuinely_invisible":[]}。证据必须引用本轮image_id;看不见就unknown/null,不得猜。' + ), + 900, + ) + carcass = request_shard( + "固定柜体板件", + ( + "只列固定柜体和承重结构零件,不列门、抽屉箱零件、五金、尺寸、孔槽或价格。" + "侧板是否上下分块必须沿外轮廓找到真实断缝,层板横线、阴影和颜色边界都不算。" + "只返回JSON:{\"parts\":[{\"name\":\"\",\"quantity\":1,\"component_role\":\"top/bottom/side/shelf/back/drawer_support/divider/leg\"," + "\"assembly_group\":\"carcass\",\"confidence\":0,\"evidence\":\"\",\"image_ids\":[]}],\"genuinely_invisible\":[]}。" + "相同名称和作用的等规格件合并数量,最多16项;无原图证据不输出。" + ), + 1400, + ) + moving = request_shard( + "门抽移动件", + ( + "只列门、抽屉箱和其他可见移动结构,不列固定柜体板件、尺寸、孔槽或价格。" + "每个抽屉的结构角色必须分别检查抽面、左帮板、右帮板、后帮板、底板;看不见的角色写入genuinely_invisible,不能省略后假装完整。" + "移门只能列door,不能凭常见做法生成铰链。只返回JSON:" + '{"parts":[{"name":"","quantity":1,"component_role":"door/drawer_front/drawer_left/drawer_right/drawer_back/drawer_bottom/other",' + '"assembly_group":"drawer/door/other","confidence":0,"evidence":"","image_ids":[]}],"genuinely_invisible":[]}。' + "相同名称和作用的等规格件合并数量,最多16项;无原图证据不输出。" + ), + 1500, + ) + view_raw = self._reread_source_structure_conflicts(assets, { + "machine_followups": [ + "逐图独立核对抽屉和门的可见数量与排列", + "沿左右外侧轮廓核对侧板真实分段与接缝高度", + ], + }) + + allowed_carcass_roles = {"top", "bottom", "side", "shelf", "back", "drawer_support", "divider", "leg"} + allowed_moving_roles = {"door", "drawer_front", "drawer_left", "drawer_right", "drawer_back", "drawer_bottom", "other"} def evidence_ids(value): return sorted({int(x) for x in (value or []) if isinstance(x, int) and not isinstance(x, bool) and x in valid_ids}) + def normalized_parts(raw: dict, allowed_roles: set[str], default_group: str) -> list[dict]: + rows = [] + for part in (raw.get("parts") or [])[:16]: + if not isinstance(part, dict): + continue + ids = evidence_ids(part.get("image_ids")) + role = str(part.get("component_role") or "").strip().lower() + count = int(self._float(part.get("quantity"), 0) or 0) + name = re.sub(r"\s+", " ", str(part.get("name") or "")).strip()[:80] + evidence = re.sub(r"\s+", " ", str(part.get("evidence") or "")).strip()[:240] + if not ids or not name or not evidence or role not in allowed_roles or not 1 <= count <= 50: + continue + group = re.sub(r"[^0-9A-Za-z_\-\u4e00-\u9fff]+", "_", str(part.get("assembly_group") or default_group))[:40] + rows.append({ + "name": name, + "quantity": count, + "component_role": role, + "assembly_group": group or default_group, + "confidence": min(1.0, max(0.0, self._float(part.get("confidence"), 0) or 0)), + "evidence": evidence, + "image_ids": ids, + }) + return rows + + merged = {} + for part in [ + *normalized_parts(carcass, allowed_carcass_roles, "carcass"), + *normalized_parts(moving, allowed_moving_roles, "other"), + ]: + key = ( + part["component_role"], + re.sub(r"[\s_\-]+", "", part["name"]).lower(), + part["assembly_group"].lower(), + ) + old = merged.get(key) + if old is None: + merged[key] = part + continue + # A duplicate observation is corroboration, not another physical + # part. Never sum it and silently double the BOM. + old["quantity"] = max(old["quantity"], part["quantity"]) + old["confidence"] = max(old["confidence"], part["confidence"]) + old["image_ids"] = sorted(set(old["image_ids"]) | set(part["image_ids"])) + if part["evidence"] not in old["evidence"]: + old["evidence"] = (old["evidence"] + ";" + part["evidence"])[:240] parts = [] - for part in raw["parts"][:32]: - if not isinstance(part, dict): - continue - ids = evidence_ids(part.get("image_ids")) - role = str(part.get("component_role") or "") - count = int(self._float(part.get("quantity"), 0) or 0) - if not ids or not str(part.get("evidence") or "").strip() or role not in allowed_roles or not 1 <= count <= 50: - continue - parts.append({ - "source_structure_id": f"SOURCE-{len(parts) + 1:03d}", - "name": str(part.get("name") or "")[:80], "quantity": count, - "component_role": role, "assembly_group": str(part.get("assembly_group") or "other")[:40], - "confidence": min(1.0, max(0.0, self._float(part.get("confidence"), 0) or 0)), - "evidence": str(part.get("evidence") or "")[:240], "image_ids": ids, - }) + for part in merged.values(): + parts.append({"source_structure_id": f"SOURCE-{len(parts) + 1:03d}", **part}) if not parts: raise SolidWoodCostError("结构关系没有可回溯的原图证据") - adjudication = self._adjudicate_source_structure_views(raw, valid_ids) + adjudication = self._adjudicate_source_structure_views(view_raw, valid_ids) focused_tasks = list(adjudication.get("machine_followups") or []) - focused_tasks.extend([ - "再次逐图确认同色上下模块之间是否仍有真实侧板接缝,不能按颜色数量计段", - "再次核对每个抽屉的独立抽面数量、上下/左右排列及箱体必需板件", - ]) if focused_tasks: try: reread = self._reread_source_structure_conflicts( @@ -3575,7 +3659,7 @@ class SolidWoodCostEngine: # image. It is not counted as an independent vote. by_image = { item.get("image_id"): item - for item in (raw.get("view_findings") or []) + for item in (view_raw.get("view_findings") or []) if isinstance(item, dict) and item.get("image_id") in valid_ids } for item in (reread.get("view_findings") or [])[:16]: @@ -3590,12 +3674,53 @@ class SolidWoodCostEngine: except SolidWoodCostError as exc: adjudication["reread_performed"] = False adjudication["reread_error"] = re.sub(r"\s+", " ", str(exc)).strip()[-300:] - result = {"parts": parts, "view_conflicts": raw.get("view_conflicts") or [], - "genuinely_invisible": raw.get("genuinely_invisible") or [], - "side_panel_seams": raw.get("side_panel_seams") or [], - "view_adjudication": adjudication} + completeness_issues = [] + drawer_fact = overview.get("drawers") if isinstance(overview.get("drawers"), dict) else {} + drawer_count = drawer_fact.get("count") + if isinstance(drawer_count, int) and not isinstance(drawer_count, bool) and 1 <= drawer_count <= 20: + observed_by_role = {} + for part in parts: + observed_by_role[part["component_role"]] = observed_by_role.get(part["component_role"], 0) + part["quantity"] + for role in ("drawer_front", "drawer_left", "drawer_right", "drawer_back", "drawer_bottom"): + observed = observed_by_role.get(role, 0) + if observed < drawer_count: + completeness_issues.append({ + "assembly": "drawer", + "component_role": role, + "expected_minimum": drawer_count, + "observed": observed, + "resolution": "cad_geometry_or_targeted_visual_review_required", + }) + invisible = [] + for value in [ + *(overview.get("genuinely_invisible") or []), + *(carcass.get("genuinely_invisible") or []), + *(moving.get("genuinely_invisible") or []), + ]: + text = re.sub(r"\s+", " ", str(value)).strip()[:240] + if text and text not in invisible: + invisible.append(text) + result = { + "parts": parts, + "view_conflicts": overview.get("view_conflicts") or [], + "genuinely_invisible": invisible, + "side_panel_seams": (adjudication.get("accepted", {}).get("side_panels") or {}).get("seam_z_ratios", []), + "view_adjudication": adjudication, + "source_completeness_issues": completeness_issues, + "fact_shard_audit": { + "strategy": "independent_visual_facts_then_deterministic_merge", + "company_bom_used": False, + "shards": [ + {"name": "assembly_overview", "max_tokens": 900}, + {"name": "carcass_parts", "max_tokens": 1400}, + {"name": "moving_parts", "max_tokens": 1500}, + {"name": "per_view_evidence", "max_tokens": 900, "batch_size": 2}, + ], + "view_batch_errors": view_raw.get("batch_errors") or [], + }, + } for key in ("drawers", "doors", "back_panel"): - fact = raw.get(key) if isinstance(raw.get(key), dict) else {} + fact = overview.get(key) if isinstance(overview.get(key), dict) else {} result[key] = {**fact, "image_ids": evidence_ids(fact.get("image_ids"))} return result @@ -3946,27 +4071,25 @@ class SolidWoodCostEngine: first = self._request_model_json({ "model": self.model, "messages": [{"role": "user", "content": context_content + [ - {"type": "text", "text": "下面是需要逐条读取的详细尺寸图:"}, + {"type": "text", "text": "下面是只用于读取成品整体外形尺寸的详细尺寸图:"}, {"type": "image_url", "image_url": {"url": review_url}}, {"type": "text", "text": ( - "本次只读尺寸箭头,不拆BOM、不生成配件、不计算成本。先观察产品正面与侧面方向," - "X为左右宽度,Y为前后深度,Z为上下高度。逐条读取图上所有直接尺寸标注,最多20条;" - "不仅要读整体长宽高,还必须读从底到顶连续排列的模块高度链、层间净空、抽面宽高和局部板宽。" + "本次只读跨越整个成品最大外轮廓的宽、深、高三条尺寸,不拆BOM、不读局部尺寸、不计算成本。" + "X为左右宽度,Y为前后深度,Z为上下高度。" "先建立图上的三条投影基准:带抽屉/门面的一面是正面,沿该面的水平长边是X;" "从正面退向后沿、平行侧板水平边的是Y;竖向立边是Z。逐条比较尺寸线与这三条基准的平行关系," "不能把纸面上斜着的线都当深度,也不能把抽屉面板的左右长边说成前后深度。" - "每条必须按两端引出线说明量的是哪里。局部值必须保留并正确写scope=module或part," - "不能因为本次最终只强制整体外形就省略;同时不要把模块高度、净空宽、门片宽当成整体外形。" + "每条必须按两端引出线说明量的是哪里;模块高度、净空宽、门片宽和板件尺寸一律不要输出。" "用其他原图验证产品正面方向和单位,同一整体尺寸在照片和线稿的数字比例可用于核验单位;" "若采用其他图确定单位,location中明确引用那张图的原标注。不能按常见家具尺寸猜单位。" "只返回JSON:{\"overall_dimension_labels\":[],\"missing_overall_axes\":[]}。" - "数组中的每条对象有axis(x/y/z)、value(图上原数值)、unit(mm/cm/m)、scope(overall/part/module/unknown)、" + "数组中的每条对象有axis(x/y/z)、value(图上原数值)、unit(mm/cm/m)、scope(overall)、" "text(标注原文)、location(两端在产品上的位置)、confidence(0到1)。整体轴未直接标注就写入missing_overall_axes," "不能按比例猜值。单位不明确时unit=unknown并降低置信度。" )}, - ]}], "temperature": 0.0, "max_tokens": 2300, "response_format": {"type": "json_object"}, + ]}], "temperature": 0.0, "max_tokens": 900, "response_format": {"type": "json_object"}, "enable_thinking": False, "vl_high_resolution_images": True, - }, "尺寸箭头独立复读失败", decoder=decode, max_attempts=1, timeout_seconds=150) + }, "整体尺寸箭头独立复读失败", decoder=decode, max_attempts=1, timeout_seconds=120) # A single vision response is not stable enough to become a machining # constraint. Run a second, deliberately narrower transcription from # the original pixels. Equal readings corroborate each other; a @@ -3997,13 +4120,41 @@ class SolidWoodCostEngine: "每条含axis、value、unit、scope、text、location、confidence。" )}, ]}], - "temperature": 0.0, "max_tokens": 1400, + "temperature": 0.0, "max_tokens": 900, "response_format": {"type": "json_object"}, "enable_thinking": False, "vl_high_resolution_images": True, }, "尺寸箭头一致性复读失败", decoder=decode, max_attempts=1, timeout_seconds=120) except SolidWoodCostError as exc: second = {"overall_dimension_labels": [], "missing_overall_axes": list("xyz")} second_error = re.sub(r"\s+", " ", str(exc)).strip()[-300:] + + # Local/module labels are useful for topology, but must not overload or + # invalidate the independent overall-dimension reads. This optional + # bounded shard can fail without discarding verified X/Y/Z evidence. + detail_error = "" + try: + detail = self._request_model_json({ + "model": self.model, + "messages": [{"role": "user", "content": compact_context + [ + {"type": "image_url", "image_url": {"url": review_url}}, + {"type": "text", "text": ( + "只转录这张图中的局部和模块尺寸,不再输出成品整体宽深高,不拆BOM、不推算缺失值。" + "优先读取从底到顶的模块高度链、层间净空、门片或抽面宽高、板件局部宽度,最多12条。" + "每条根据尺寸线两端判断axis,并将scope严格写为part、module或unknown;看不清就省略。" + "只返回JSON:{\"overall_dimension_labels\":[],\"missing_overall_axes\":[\"x\",\"y\",\"z\"]}。" + "overall_dimension_labels每条含axis、value、unit、scope、text、location、confidence。" + )}, + ]}], + "temperature": 0.0, + "max_tokens": 1400, + "response_format": {"type": "json_object"}, + "enable_thinking": False, + "vl_high_resolution_images": True, + }, "局部尺寸标注独立转录失败", decoder=decode, max_attempts=1, timeout_seconds=120) + except SolidWoodCostError as exc: + detail = {"overall_dimension_labels": [], "missing_overall_axes": list("xyz")} + detail_error = re.sub(r"\s+", " ", str(exc)).strip()[-300:] + def normalize_label(row, source_pass="unknown"): if not isinstance(row, dict): return row @@ -4015,8 +4166,9 @@ class SolidWoodCostEngine: return normalized labels = [ - *[normalize_label(row, "full_transcription") for row in list(first.get("overall_dimension_labels") or [])], + *[normalize_label(row, "primary_overall") for row in list(first.get("overall_dimension_labels") or [])], *[normalize_label(row, "independent_overall") for row in list(second.get("overall_dimension_labels") or [])], + *[normalize_label(row, "local_detail") for row in list(detail.get("overall_dimension_labels") or [])], ] readable_axes = { row.get("axis") for row in labels @@ -4125,6 +4277,8 @@ class SolidWoodCostEngine: "missing_overall_axes": missing_axes, "independent_read_count": 1 if second_error else 2, "independent_reread_error": second_error or None, + "detail_transcription_used": not bool(detail_error), + "detail_transcription_error": detail_error or None, "focused_missing_axis_read": focused_used, "focused_missing_axis_error": focused_error or None, "annotation_reread_used": annotation_reread_used, diff --git a/server_patch/ai_3d_model/ai-solid-wood-cost/test_solid_wood_cost_engine.py b/server_patch/ai_3d_model/ai-solid-wood-cost/test_solid_wood_cost_engine.py index b2b8f07..770e4ba 100644 --- a/server_patch/ai_3d_model/ai-solid-wood-cost/test_solid_wood_cost_engine.py +++ b/server_patch/ai_3d_model/ai-solid-wood-cost/test_solid_wood_cost_engine.py @@ -4615,13 +4615,19 @@ class SolidWoodCostEngineTest(unittest.TestCase): with mock.patch.object(self.engine, "_asset_review_data_url", side_effect=lambda asset: asset["value"]), \ mock.patch.object(self.engine, "_request_model_json", return_value={"overall_dimension_labels": [], "missing_overall_axes": []}) as call: self.engine._read_source_dimension_labels(assets[0], assets) - self.assertEqual(2, call.call_count) + self.assertEqual(3, call.call_count) content = call.call_args_list[0].args[0]["messages"][0]["content"] self.assertEqual({"image-1", "image-2", "image-3"}, {item["image_url"]["url"] for item in content if item["type"] == "image_url"}) compact = call.call_args_list[1].args[0]["messages"][0]["content"] self.assertEqual({"image-1", "image-2"}, {item["image_url"]["url"] for item in compact if item["type"] == "image_url"}) + detail = call.call_args_list[2].args[0]["messages"][0]["content"] + self.assertEqual({"image-1", "image-2"}, + {item["image_url"]["url"] for item in detail if item["type"] == "image_url"}) + self.assertEqual([900, 900, 1400], [ + request.args[0]["max_tokens"] for request in call.call_args_list + ]) for request in call.call_args_list: self.assertEqual(1, request.kwargs["max_attempts"]) self.assertFalse(request.args[0]["enable_thinking"]) @@ -4636,7 +4642,9 @@ class SolidWoodCostEngineTest(unittest.TestCase): {"axis": "y", "value": 30, "unit": "cm", "scope": "overall", "confidence": 0.94}, ], "missing_overall_axes": ["z"]} with mock.patch.object(self.engine, "_asset_review_data_url", side_effect=lambda asset: asset["value"]), \ - mock.patch.object(self.engine, "_request_model_json", side_effect=[first, second]): + mock.patch.object(self.engine, "_request_model_json", side_effect=[ + first, second, {"overall_dimension_labels": [], "missing_overall_axes": list("xyz")}, + ]): result = self.engine._read_source_dimension_labels(assets[0], assets) self.assertEqual([], result["missing_overall_axes"]) self.assertEqual({"x", "y", "z"}, {row["axis"] for row in result["overall_dimension_labels"]}) @@ -4648,7 +4656,10 @@ class SolidWoodCostEngineTest(unittest.TestCase): {"axis": "z", "value": 160, "unit": "cm", "scope": "overall", "confidence": 0.95}, ], "missing_overall_axes": ["x", "y"]} with mock.patch.object(self.engine, "_asset_review_data_url", side_effect=lambda asset: asset["value"]), \ - mock.patch.object(self.engine, "_request_model_json", side_effect=[first, self.module.SolidWoodCostError("timeout")]): + mock.patch.object(self.engine, "_request_model_json", side_effect=[ + first, self.module.SolidWoodCostError("timeout"), + {"overall_dimension_labels": [], "missing_overall_axes": list("xyz")}, + ]): result = self.engine._read_source_dimension_labels(assets[0], assets) self.assertEqual(1, result["independent_read_count"]) self.assertIn("timeout", result["independent_reread_error"]) @@ -4667,13 +4678,15 @@ class SolidWoodCostEngineTest(unittest.TestCase): ], "missing_overall_axes": []} with mock.patch.object(self.engine, "_asset_review_data_url", side_effect=lambda asset: asset["value"]), \ mock.patch.object(self.engine, "_dimension_focus_sheet_data_url", return_value="data:image/jpeg;base64,AA=="), \ - mock.patch.object(self.engine, "_request_model_json", side_effect=[first, second, focused]) as request: + mock.patch.object(self.engine, "_request_model_json", side_effect=[ + first, second, {"overall_dimension_labels": [], "missing_overall_axes": list("xyz")}, focused, + ]) as request: result = self.engine._read_source_dimension_labels(assets[0], assets) - self.assertEqual(3, request.call_count) + self.assertEqual(4, request.call_count) self.assertEqual([], result["missing_overall_axes"]) self.assertTrue(result["focused_missing_axis_read"]) self.assertEqual({"x", "y", "z"}, {row["axis"] for row in result["overall_dimension_labels"]}) - focused_prompt = request.call_args_list[2].args[0]["messages"][0]["content"][0]["text"] + focused_prompt = request.call_args_list[3].args[0]["messages"][0]["content"][0]["text"] self.assertNotIn("39.8", focused_prompt) def test_v81_red_annotation_focus_is_pixel_only_and_contains_no_ocr_answer(self): @@ -4710,9 +4723,11 @@ class SolidWoodCostEngineTest(unittest.TestCase): with mock.patch.object(self.engine, "_asset_review_data_url", side_effect=lambda asset: asset["value"]), \ mock.patch.object(self.engine, "_dimension_focus_sheet_data_url", return_value="data:image/jpeg;base64,AA=="), \ mock.patch.object(self.engine, "_dimension_annotation_focus_data_url", return_value="data:image/png;base64,AA=="), \ - mock.patch.object(self.engine, "_request_model_json", side_effect=[first, second, focused, annotation]) as request: + mock.patch.object(self.engine, "_request_model_json", side_effect=[ + first, second, {"overall_dimension_labels": [], "missing_overall_axes": list("xyz")}, focused, annotation, + ]) as request: result = self.engine._read_source_dimension_labels(assets[0], assets) - self.assertEqual(4, request.call_count) + self.assertEqual(5, request.call_count) self.assertEqual([], result["missing_overall_axes"]) self.assertTrue(result["annotation_reread_used"]) self.assertEqual({"x", "y", "z"}, { @@ -4745,10 +4760,13 @@ class SolidWoodCostEngineTest(unittest.TestCase): mock.patch.object(self.engine, "_dimension_annotation_focus_data_url", return_value="data:image/png;base64,AA=="), \ mock.patch.object( self.engine, "_request_model_json", - side_effect=[first, second, focused, annotation, adjudicated], + side_effect=[ + first, second, {"overall_dimension_labels": [], "missing_overall_axes": list("xyz")}, + focused, annotation, adjudicated, + ], ) as request: result = self.engine._read_source_dimension_labels(assets[0], assets) - self.assertEqual(5, request.call_count) + self.assertEqual(6, request.call_count) self.assertEqual([], result["missing_overall_axes"]) self.assertTrue(result["scope_adjudication_used"]) promoted = result["overall_dimension_labels"][2] @@ -4911,13 +4929,94 @@ class SolidWoodCostEngineTest(unittest.TestCase): self.engine._reread_source_structure_conflicts( assets, {"machine_followups": ["对抽屉区域做局部放大并重新计数"]} ) - content = call.call_args.args[0]["messages"][0]["content"] + self.assertEqual(2, call.call_count) + content = [ + item + for request in call.call_args_list + for item in request.args[0]["messages"][0]["content"] + ] self.assertEqual( {f"image-{index}" for index in range(1, 5)}, {item["image_url"]["url"] for item in content if item["type"] == "image_url"}, ) prompt = " ".join(item.get("text", "") for item in content if item["type"] == "text") self.assertIn("不得询问用户", prompt) + self.assertTrue(all(request.args[0]["max_tokens"] == 900 for request in call.call_args_list)) + + def test_v120_source_structure_uses_bounded_fact_shards_and_reports_missing_drawer_role(self): + assets = [{"role": role, "value": f"image-{index}"} + for index, role in enumerate(("front", "side", "open_or_third", "dimension"), 1)] + overview = { + "drawers": {"count": 2, "layout": "vertical", "confidence": 0.96, + "evidence": "两张抽面上下排列", "image_ids": [1, 3]}, + "doors": {"count": 1, "mechanism": "sliding", "confidence": 0.94, + "evidence": "门片沿轨道移动", "image_ids": [1, 3]}, + "back_panel": {"present": None, "confidence": 0.4, + "evidence": "背面不可见", "image_ids": [2]}, + "view_conflicts": [], "genuinely_invisible": ["背板连接方式"], + } + carcass = {"parts": [ + {"name": "顶板", "quantity": 1, "component_role": "top", "assembly_group": "carcass", + "confidence": 0.95, "evidence": "顶部外轮廓", "image_ids": [1, 2]}, + {"name": "左右侧板", "quantity": 2, "component_role": "side", "assembly_group": "carcass", + "confidence": 0.93, "evidence": "左右外侧轮廓", "image_ids": [1, 2]}, + ], "genuinely_invisible": []} + moving = {"parts": [ + {"name": "抽屉面", "quantity": 2, "component_role": "drawer_front", "assembly_group": "drawer", + "confidence": 0.96, "evidence": "两张独立抽面", "image_ids": [1, 3]}, + {"name": "抽屉左帮板", "quantity": 1, "component_role": "drawer_left", "assembly_group": "drawer", + "confidence": 0.72, "evidence": "开抽视角只见一块", "image_ids": [3]}, + {"name": "抽屉右帮板", "quantity": 2, "component_role": "drawer_right", "assembly_group": "drawer", + "confidence": 0.82, "evidence": "开抽视角右侧", "image_ids": [3]}, + {"name": "抽屉后帮板", "quantity": 2, "component_role": "drawer_back", "assembly_group": "drawer", + "confidence": 0.80, "evidence": "开抽视角后沿", "image_ids": [3]}, + {"name": "抽屉底板", "quantity": 2, "component_role": "drawer_bottom", "assembly_group": "drawer", + "confidence": 0.84, "evidence": "开抽视角底面", "image_ids": [3]}, + {"name": "移门", "quantity": 1, "component_role": "door", "assembly_group": "door", + "confidence": 0.94, "evidence": "门片沿轨道移动", "image_ids": [1, 3]}, + ], "genuinely_invisible": ["抽屉左侧被遮挡"]} + def view_rows(ids): + return {"view_findings": [{ + "image_id": image_id, "observed_role": "front", "complete_product_visible": True, + "side_edge_visibility": 0.9, "visible_counts": {"drawers": 2, "doors": 1}, + "drawer_layout": "vertical", "side_segments_per_side": 1, + "side_seam_z_ratios": [], "side_seam_evidence": [], + "confidence": 0.95, "evidence": "完整产品轮廓", + } for image_id in ids]} + with mock.patch.object(self.engine, "_asset_review_data_url", side_effect=lambda asset: asset["value"]), \ + mock.patch.object(self.engine, "_request_model_json", side_effect=[ + overview, carcass, moving, view_rows([1, 2]), view_rows([3, 4]), + ]) as request: + result = self.engine._read_source_structure(assets) + self.assertEqual(5, request.call_count) + self.assertEqual([900, 1400, 1500, 900, 900], [ + call.args[0]["max_tokens"] for call in request.call_args_list + ]) + self.assertNotIn('"parts"', request.call_args_list[0].args[0]["messages"][0]["content"][0]["text"]) + self.assertNotIn("drawer_front", request.call_args_list[1].args[0]["messages"][0]["content"][0]["text"]) + self.assertNotIn("top/bottom/side", request.call_args_list[2].args[0]["messages"][0]["content"][0]["text"]) + self.assertEqual("sliding", result["doors"]["mechanism"]) + self.assertEqual("independent_visual_facts_then_deterministic_merge", + result["fact_shard_audit"]["strategy"]) + issue = next(row for row in result["source_completeness_issues"] + if row["component_role"] == "drawer_left") + self.assertEqual((2, 1), (issue["expected_minimum"], issue["observed"])) + + def test_v120_local_dimension_truncation_does_not_discard_overall_dimensions(self): + assets = [{"role": "dimension", "value": "dimension"}, {"role": "front", "value": "front"}] + overall = {"overall_dimension_labels": [ + {"axis": axis, "value": value, "unit": "mm", "scope": "overall", "confidence": 0.96} + for axis, value in (("x", 1200), ("y", 400), ("z", 850)) + ], "missing_overall_axes": []} + with mock.patch.object(self.engine, "_asset_review_data_url", side_effect=lambda asset: asset["value"]), \ + mock.patch.object(self.engine, "_request_model_json", side_effect=[ + overall, overall, self.module.ModelOutputTruncatedError("detail capped"), + ]): + result = self.engine._read_source_dimension_labels(assets[0], assets) + self.assertEqual([], result["missing_overall_axes"]) + self.assertFalse(result["detail_transcription_used"]) + self.assertIn("detail capped", result["detail_transcription_error"]) + self.assertEqual(6, len(result["overall_dimension_labels"])) if __name__ == "__main__":