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 = "产品成本评估"
|
DISPLAY_NAME = "产品成本评估"
|
||||||
MODULE_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
|
DEFAULT_BATCH_QTY = 50
|
||||||
PURE_SOLID_WOOD_FACTORY_PROFIT_RATE = 0.10
|
PURE_SOLID_WOOD_FACTORY_PROFIT_RATE = 0.10
|
||||||
NON_PURE_SOLID_WOOD_FACTORY_PROFIT_RATE = 0.08
|
NON_PURE_SOLID_WOOD_FACTORY_PROFIT_RATE = 0.08
|
||||||
@ -3458,41 +3458,59 @@ class SolidWoodCostEngine:
|
|||||||
tasks = list(adjudication.get("machine_followups") or [])[:6]
|
tasks = list(adjudication.get("machine_followups") or [])[:6]
|
||||||
if not tasks:
|
if not tasks:
|
||||||
return {"view_findings": []}
|
return {"view_findings": []}
|
||||||
content = [{
|
# Keep each visual answer deliberately small. The provider can return
|
||||||
"type": "text",
|
# a 200 response whose JSON is cut at its completion-token ceiling; a
|
||||||
"text": (
|
# single eight-image seam report therefore is not a reliable unit of
|
||||||
"这是家具原图结构的第二次聚焦复读。上一轮只确定了以下可见冲突/缺口:"
|
# work. Image ids stay global while the calls are split in pairs.
|
||||||
+ ";".join(tasks)
|
findings = []
|
||||||
+ "。必须重新看像素,不得复述上一轮结论,也不得询问用户。逐张图独立输出;"
|
errors = []
|
||||||
"数抽屉时按独立抽面边界,判断侧板分段时只沿左右外侧边追踪真实断缝,"
|
indexed = list(enumerate(assets[:8], 1))
|
||||||
"排除层板前沿、颜色边界、阴影和标注线。看不全就降低side_edge_visibility,不能写高置信连续。"
|
for offset in range(0, len(indexed), 2):
|
||||||
"side_seam_z_ratios必须从成品底部向上递增,逐条列出每一个候选物理断缝;不能只写最显眼的一条。"
|
batch = indexed[offset:offset + 2]
|
||||||
"每个ratio必须有同序side_seam_evidence,只有沿侧板外轮廓看到实体终止/重新起始时physical_break_visible=true;"
|
content = [{
|
||||||
"层板与侧板相交、颜色变化、尺寸数字或产品顶/底外边界都不是侧板断缝。"
|
"type": "text",
|
||||||
"只返回JSON:{\"view_findings\":[{\"image_id\":1,\"observed_role\":\"front/side/third/dimension/unknown\","
|
"text": (
|
||||||
"\"complete_product_visible\":false,\"side_edge_visibility\":0,"
|
"这是家具原图结构的独立逐视角复读。本轮待核对:"
|
||||||
"\"visible_counts\":{\"drawers\":0,\"doors\":0,\"shelves\":0,\"dividers\":0},"
|
+ ";".join(tasks)
|
||||||
"\"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\":\"\"}],"
|
"看不全就降低side_edge_visibility和confidence,不能写高置信连续。"
|
||||||
"\"confidence\":0,\"evidence\":\"\"}]}。"
|
"只返回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},"
|
||||||
for index, asset in enumerate(assets[:8], 1):
|
"\"drawer_layout\":\"horizontal/vertical/mixed/unknown\","
|
||||||
content.extend([
|
"\"side_segments_per_side\":null,\"side_seam_z_ratios\":[],"
|
||||||
{"type": "text", "text": f"原始图 image_id={index};候选角色={asset.get('role') or '未知'},请自行复核。"},
|
"\"side_seam_evidence\":[{\"ratio_from_bottom\":0.0,\"physical_break_visible\":false,\"boundary_description\":\"\"}],"
|
||||||
{"type": "image_url", "image_url": {"url": self._asset_review_data_url(asset)}},
|
"\"confidence\":0,\"evidence\":\"\"}]}。每张输入图最多一项。"
|
||||||
])
|
),
|
||||||
return self._request_model_json({
|
}]
|
||||||
"model": self.model,
|
for image_id, asset in batch:
|
||||||
"messages": [{"role": "user", "content": content}],
|
content.extend([
|
||||||
"temperature": 0.0,
|
{"type": "text", "text": f"原始图 image_id={image_id};候选角色={asset.get('role') or '未知'},请自行复核。"},
|
||||||
"response_format": {"type": "json_object"},
|
{"type": "image_url", "image_url": {"url": self._asset_review_data_url(asset)}},
|
||||||
"max_tokens": 3072,
|
])
|
||||||
"enable_thinking": True,
|
try:
|
||||||
"thinking_budget": 1536,
|
raw = self._request_model_json({
|
||||||
"vl_high_resolution_images": True,
|
"model": self.model,
|
||||||
}, "原图结构冲突聚焦复读失败", max_attempts=1, timeout_seconds=150)
|
"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:
|
def _read_source_structure(self, assets: list[dict]) -> dict:
|
||||||
"""Read assembly relationships before asking for numeric CAD geometry.
|
"""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
|
model or numerical fixture is sent. The later CAD review remains the
|
||||||
independent validator; this graph is evidence, not ground truth.
|
independent validator; this graph is evidence, not ground truth.
|
||||||
"""
|
"""
|
||||||
content = []
|
indexed_assets = list(enumerate(assets[:8], 1))
|
||||||
valid_ids = set()
|
valid_ids = {image_id for image_id, _ in indexed_assets}
|
||||||
for index, asset in enumerate(assets[:8], 1):
|
if not indexed_assets:
|
||||||
source_id = index
|
raise SolidWoodCostError("没有可用于结构识别的原始图片")
|
||||||
valid_ids.add(source_id)
|
|
||||||
content.extend([
|
def source_content(instruction: str) -> list[dict]:
|
||||||
{"type": "text", "text": f"原始图 image_id={source_id},候选角色={asset.get('role') or '未知'};按画面自行判断视角。"},
|
content = [{"type": "text", "text": instruction}]
|
||||||
{"type": "image_url", "image_url": {"url": self._asset_review_data_url(asset)}},
|
for image_id, asset in indexed_assets:
|
||||||
])
|
content.extend([
|
||||||
content.append({"type": "text", "text": (
|
{"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
|
||||||
"不得按颜色数量代替物理段数。标注链可辅助定位候选接缝,必须再核对该高度外轮廓是否断开。"
|
|
||||||
"看不见背部时present=null,不能把未显示背板当作无背板。视图互相冲突必须指出各图支持什么,不能凭多数默认一张错误。"
|
def request_shard(name: str, instruction: str, max_tokens: int) -> dict:
|
||||||
"这一轮只做部件清单和功能总成识别,不同时承担逐图接缝定位,以免一个请求过载。"
|
raw = self._request_model_json({
|
||||||
"只返回JSON,全部字段必须完整:"
|
"model": self.model,
|
||||||
'{"drawers":{"count":null,"layout":"horizontal/vertical/mixed/unknown","confidence":0,"evidence":"","image_ids":[]},'
|
"messages": [{"role": "user", "content": source_content(instruction)}],
|
||||||
'"doors":{"count":null,"mechanism":"sliding/hinged/none/unknown","confidence":0,"evidence":"","image_ids":[]},'
|
"temperature": 0.0,
|
||||||
'"back_panel":{"present":null,"confidence":0,"evidence":"","image_ids":[]},'
|
"response_format": {"type": "json_object"},
|
||||||
'"parts":[{"name":"","quantity":0,"component_role":"","assembly_group":"carcass/drawer/other",'
|
"max_tokens": max_tokens,
|
||||||
'"confidence":0,"evidence":"","image_ids":[]}],'
|
"enable_thinking": False,
|
||||||
'"view_conflicts":[],"genuinely_invisible":[]}。'
|
"vl_high_resolution_images": True,
|
||||||
"component_role只允许top/bottom/side/shelf/door/back/drawer_front/drawer_left/drawer_right/drawer_back/drawer_bottom/drawer_support/divider/leg/other。"
|
}, f"多视角结构{name}识别失败", max_attempts=1, timeout_seconds=150)
|
||||||
"drawer_support属于carcass;其余drawer_*属于drawer。上下排列vertical,左右并排horizontal,混合mixed;"
|
if not isinstance(raw, dict):
|
||||||
"不能由抽屉数量推断排列。相同规格零件合并数量,parts最多32项。证据一句话,image_ids必须用本轮原图编号。"
|
raise SolidWoodCostError(f"多视角结构{name}结果不完整")
|
||||||
"不可见的结构只列genuinely_invisible;"
|
return raw
|
||||||
"不要编造部件,更不要请求用户重新数已清晰可见的部件。低置信先交给后续AI局部复读。"
|
|
||||||
)})
|
# Do not ask one completion to serialize a whole structure graph. Each
|
||||||
raw = self._request_model_json({
|
# shard is independently checkable and the program, not the model,
|
||||||
"model": self.model, "messages": [{"role": "user", "content": content}],
|
# performs the final merge.
|
||||||
"temperature": 0.0, "response_format": {"type": "json_object"}, "max_tokens": 3072,
|
overview = request_shard(
|
||||||
"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("多视角结构关系结果不完整")
|
"背部看不见时present=null。只返回JSON:"
|
||||||
allowed_roles = {"top", "bottom", "side", "shelf", "door", "back", "drawer_front", "drawer_left", "drawer_right", "drawer_back", "drawer_bottom", "drawer_support", "divider", "leg", "other"}
|
'{"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):
|
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})
|
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 = []
|
parts = []
|
||||||
for part in raw["parts"][:32]:
|
for part in merged.values():
|
||||||
if not isinstance(part, dict):
|
parts.append({"source_structure_id": f"SOURCE-{len(parts) + 1:03d}", **part})
|
||||||
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,
|
|
||||||
})
|
|
||||||
if not parts:
|
if not parts:
|
||||||
raise SolidWoodCostError("结构关系没有可回溯的原图证据")
|
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 = list(adjudication.get("machine_followups") or [])
|
||||||
focused_tasks.extend([
|
|
||||||
"再次逐图确认同色上下模块之间是否仍有真实侧板接缝,不能按颜色数量计段",
|
|
||||||
"再次核对每个抽屉的独立抽面数量、上下/左右排列及箱体必需板件",
|
|
||||||
])
|
|
||||||
if focused_tasks:
|
if focused_tasks:
|
||||||
try:
|
try:
|
||||||
reread = self._reread_source_structure_conflicts(
|
reread = self._reread_source_structure_conflicts(
|
||||||
@ -3575,7 +3659,7 @@ class SolidWoodCostEngine:
|
|||||||
# image. It is not counted as an independent vote.
|
# image. It is not counted as an independent vote.
|
||||||
by_image = {
|
by_image = {
|
||||||
item.get("image_id"): item
|
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
|
if isinstance(item, dict) and item.get("image_id") in valid_ids
|
||||||
}
|
}
|
||||||
for item in (reread.get("view_findings") or [])[:16]:
|
for item in (reread.get("view_findings") or [])[:16]:
|
||||||
@ -3590,12 +3674,53 @@ class SolidWoodCostEngine:
|
|||||||
except SolidWoodCostError as exc:
|
except SolidWoodCostError as exc:
|
||||||
adjudication["reread_performed"] = False
|
adjudication["reread_performed"] = False
|
||||||
adjudication["reread_error"] = re.sub(r"\s+", " ", str(exc)).strip()[-300:]
|
adjudication["reread_error"] = re.sub(r"\s+", " ", str(exc)).strip()[-300:]
|
||||||
result = {"parts": parts, "view_conflicts": raw.get("view_conflicts") or [],
|
completeness_issues = []
|
||||||
"genuinely_invisible": raw.get("genuinely_invisible") or [],
|
drawer_fact = overview.get("drawers") if isinstance(overview.get("drawers"), dict) else {}
|
||||||
"side_panel_seams": raw.get("side_panel_seams") or [],
|
drawer_count = drawer_fact.get("count")
|
||||||
"view_adjudication": adjudication}
|
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"):
|
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"))}
|
result[key] = {**fact, "image_ids": evidence_ids(fact.get("image_ids"))}
|
||||||
return result
|
return result
|
||||||
|
|
||||||
@ -3946,27 +4071,25 @@ class SolidWoodCostEngine:
|
|||||||
first = self._request_model_json({
|
first = self._request_model_json({
|
||||||
"model": self.model,
|
"model": self.model,
|
||||||
"messages": [{"role": "user", "content": context_content + [
|
"messages": [{"role": "user", "content": context_content + [
|
||||||
{"type": "text", "text": "下面是需要逐条读取的详细尺寸图:"},
|
{"type": "text", "text": "下面是只用于读取成品整体外形尺寸的详细尺寸图:"},
|
||||||
{"type": "image_url", "image_url": {"url": review_url}},
|
{"type": "image_url", "image_url": {"url": review_url}},
|
||||||
{"type": "text", "text": (
|
{"type": "text", "text": (
|
||||||
"本次只读尺寸箭头,不拆BOM、不生成配件、不计算成本。先观察产品正面与侧面方向,"
|
"本次只读跨越整个成品最大外轮廓的宽、深、高三条尺寸,不拆BOM、不读局部尺寸、不计算成本。"
|
||||||
"X为左右宽度,Y为前后深度,Z为上下高度。逐条读取图上所有直接尺寸标注,最多20条;"
|
"X为左右宽度,Y为前后深度,Z为上下高度。"
|
||||||
"不仅要读整体长宽高,还必须读从底到顶连续排列的模块高度链、层间净空、抽面宽高和局部板宽。"
|
|
||||||
"先建立图上的三条投影基准:带抽屉/门面的一面是正面,沿该面的水平长边是X;"
|
"先建立图上的三条投影基准:带抽屉/门面的一面是正面,沿该面的水平长边是X;"
|
||||||
"从正面退向后沿、平行侧板水平边的是Y;竖向立边是Z。逐条比较尺寸线与这三条基准的平行关系,"
|
"从正面退向后沿、平行侧板水平边的是Y;竖向立边是Z。逐条比较尺寸线与这三条基准的平行关系,"
|
||||||
"不能把纸面上斜着的线都当深度,也不能把抽屉面板的左右长边说成前后深度。"
|
"不能把纸面上斜着的线都当深度,也不能把抽屉面板的左右长边说成前后深度。"
|
||||||
"每条必须按两端引出线说明量的是哪里。局部值必须保留并正确写scope=module或part,"
|
"每条必须按两端引出线说明量的是哪里;模块高度、净空宽、门片宽和板件尺寸一律不要输出。"
|
||||||
"不能因为本次最终只强制整体外形就省略;同时不要把模块高度、净空宽、门片宽当成整体外形。"
|
|
||||||
"用其他原图验证产品正面方向和单位,同一整体尺寸在照片和线稿的数字比例可用于核验单位;"
|
"用其他原图验证产品正面方向和单位,同一整体尺寸在照片和线稿的数字比例可用于核验单位;"
|
||||||
"若采用其他图确定单位,location中明确引用那张图的原标注。不能按常见家具尺寸猜单位。"
|
"若采用其他图确定单位,location中明确引用那张图的原标注。不能按常见家具尺寸猜单位。"
|
||||||
"只返回JSON:{\"overall_dimension_labels\":[],\"missing_overall_axes\":[]}。"
|
"只返回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,"
|
"text(标注原文)、location(两端在产品上的位置)、confidence(0到1)。整体轴未直接标注就写入missing_overall_axes,"
|
||||||
"不能按比例猜值。单位不明确时unit=unknown并降低置信度。"
|
"不能按比例猜值。单位不明确时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,
|
"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
|
# A single vision response is not stable enough to become a machining
|
||||||
# constraint. Run a second, deliberately narrower transcription from
|
# constraint. Run a second, deliberately narrower transcription from
|
||||||
# the original pixels. Equal readings corroborate each other; a
|
# the original pixels. Equal readings corroborate each other; a
|
||||||
@ -3997,13 +4120,41 @@ class SolidWoodCostEngine:
|
|||||||
"每条含axis、value、unit、scope、text、location、confidence。"
|
"每条含axis、value、unit、scope、text、location、confidence。"
|
||||||
)},
|
)},
|
||||||
]}],
|
]}],
|
||||||
"temperature": 0.0, "max_tokens": 1400,
|
"temperature": 0.0, "max_tokens": 900,
|
||||||
"response_format": {"type": "json_object"},
|
"response_format": {"type": "json_object"},
|
||||||
"enable_thinking": False, "vl_high_resolution_images": True,
|
"enable_thinking": False, "vl_high_resolution_images": True,
|
||||||
}, "尺寸箭头一致性复读失败", decoder=decode, max_attempts=1, timeout_seconds=120)
|
}, "尺寸箭头一致性复读失败", decoder=decode, max_attempts=1, timeout_seconds=120)
|
||||||
except SolidWoodCostError as exc:
|
except SolidWoodCostError as exc:
|
||||||
second = {"overall_dimension_labels": [], "missing_overall_axes": list("xyz")}
|
second = {"overall_dimension_labels": [], "missing_overall_axes": list("xyz")}
|
||||||
second_error = re.sub(r"\s+", " ", str(exc)).strip()[-300:]
|
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"):
|
def normalize_label(row, source_pass="unknown"):
|
||||||
if not isinstance(row, dict):
|
if not isinstance(row, dict):
|
||||||
return row
|
return row
|
||||||
@ -4015,8 +4166,9 @@ class SolidWoodCostEngine:
|
|||||||
return normalized
|
return normalized
|
||||||
|
|
||||||
labels = [
|
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, "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 = {
|
readable_axes = {
|
||||||
row.get("axis") for row in labels
|
row.get("axis") for row in labels
|
||||||
@ -4125,6 +4277,8 @@ class SolidWoodCostEngine:
|
|||||||
"missing_overall_axes": missing_axes,
|
"missing_overall_axes": missing_axes,
|
||||||
"independent_read_count": 1 if second_error else 2,
|
"independent_read_count": 1 if second_error else 2,
|
||||||
"independent_reread_error": second_error or None,
|
"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_read": focused_used,
|
||||||
"focused_missing_axis_error": focused_error or None,
|
"focused_missing_axis_error": focused_error or None,
|
||||||
"annotation_reread_used": annotation_reread_used,
|
"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"]), \
|
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:
|
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.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"]
|
content = call.call_args_list[0].args[0]["messages"][0]["content"]
|
||||||
self.assertEqual({"image-1", "image-2", "image-3"},
|
self.assertEqual({"image-1", "image-2", "image-3"},
|
||||||
{item["image_url"]["url"] for item in content if item["type"] == "image_url"})
|
{item["image_url"]["url"] for item in content if item["type"] == "image_url"})
|
||||||
compact = call.call_args_list[1].args[0]["messages"][0]["content"]
|
compact = call.call_args_list[1].args[0]["messages"][0]["content"]
|
||||||
self.assertEqual({"image-1", "image-2"},
|
self.assertEqual({"image-1", "image-2"},
|
||||||
{item["image_url"]["url"] for item in compact if item["type"] == "image_url"})
|
{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:
|
for request in call.call_args_list:
|
||||||
self.assertEqual(1, request.kwargs["max_attempts"])
|
self.assertEqual(1, request.kwargs["max_attempts"])
|
||||||
self.assertFalse(request.args[0]["enable_thinking"])
|
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},
|
{"axis": "y", "value": 30, "unit": "cm", "scope": "overall", "confidence": 0.94},
|
||||||
], "missing_overall_axes": ["z"]}
|
], "missing_overall_axes": ["z"]}
|
||||||
with mock.patch.object(self.engine, "_asset_review_data_url", side_effect=lambda asset: asset["value"]), \
|
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)
|
result = self.engine._read_source_dimension_labels(assets[0], assets)
|
||||||
self.assertEqual([], result["missing_overall_axes"])
|
self.assertEqual([], result["missing_overall_axes"])
|
||||||
self.assertEqual({"x", "y", "z"}, {row["axis"] for row in result["overall_dimension_labels"]})
|
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},
|
{"axis": "z", "value": 160, "unit": "cm", "scope": "overall", "confidence": 0.95},
|
||||||
], "missing_overall_axes": ["x", "y"]}
|
], "missing_overall_axes": ["x", "y"]}
|
||||||
with mock.patch.object(self.engine, "_asset_review_data_url", side_effect=lambda asset: asset["value"]), \
|
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)
|
result = self.engine._read_source_dimension_labels(assets[0], assets)
|
||||||
self.assertEqual(1, result["independent_read_count"])
|
self.assertEqual(1, result["independent_read_count"])
|
||||||
self.assertIn("timeout", result["independent_reread_error"])
|
self.assertIn("timeout", result["independent_reread_error"])
|
||||||
@ -4667,13 +4678,15 @@ class SolidWoodCostEngineTest(unittest.TestCase):
|
|||||||
], "missing_overall_axes": []}
|
], "missing_overall_axes": []}
|
||||||
with mock.patch.object(self.engine, "_asset_review_data_url", side_effect=lambda asset: asset["value"]), \
|
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_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)
|
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.assertEqual([], result["missing_overall_axes"])
|
||||||
self.assertTrue(result["focused_missing_axis_read"])
|
self.assertTrue(result["focused_missing_axis_read"])
|
||||||
self.assertEqual({"x", "y", "z"}, {row["axis"] for row in result["overall_dimension_labels"]})
|
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)
|
self.assertNotIn("39.8", focused_prompt)
|
||||||
|
|
||||||
def test_v81_red_annotation_focus_is_pixel_only_and_contains_no_ocr_answer(self):
|
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"]), \
|
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_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, "_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)
|
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.assertEqual([], result["missing_overall_axes"])
|
||||||
self.assertTrue(result["annotation_reread_used"])
|
self.assertTrue(result["annotation_reread_used"])
|
||||||
self.assertEqual({"x", "y", "z"}, {
|
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, "_dimension_annotation_focus_data_url", return_value="data:image/png;base64,AA=="), \
|
||||||
mock.patch.object(
|
mock.patch.object(
|
||||||
self.engine, "_request_model_json",
|
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:
|
) as request:
|
||||||
result = self.engine._read_source_dimension_labels(assets[0], assets)
|
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.assertEqual([], result["missing_overall_axes"])
|
||||||
self.assertTrue(result["scope_adjudication_used"])
|
self.assertTrue(result["scope_adjudication_used"])
|
||||||
promoted = result["overall_dimension_labels"][2]
|
promoted = result["overall_dimension_labels"][2]
|
||||||
@ -4911,13 +4929,94 @@ class SolidWoodCostEngineTest(unittest.TestCase):
|
|||||||
self.engine._reread_source_structure_conflicts(
|
self.engine._reread_source_structure_conflicts(
|
||||||
assets, {"machine_followups": ["对抽屉区域做局部放大并重新计数"]}
|
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(
|
self.assertEqual(
|
||||||
{f"image-{index}" for index in range(1, 5)},
|
{f"image-{index}" for index in range(1, 5)},
|
||||||
{item["image_url"]["url"] for item in content if item["type"] == "image_url"},
|
{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")
|
prompt = " ".join(item.get("text", "") for item in content if item["type"] == "text")
|
||||||
self.assertIn("不得询问用户", prompt)
|
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__":
|
if __name__ == "__main__":
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user