fix: split CAD vision facts into bounded passes
This commit is contained in:
parent
d0d0eb0294
commit
61995674e9
@ -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,
|
||||
|
||||
@ -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__":
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user