Files
cdsl-cad/cadfs_to_cdsl/describe.py
T
2026-09-04 11:17:36 +08:00

977 lines
43 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
from __future__ import annotations
import asyncio
import base64
from collections import Counter
from concurrent.futures import ThreadPoolExecutor
import json
import math
from pathlib import Path
import random
import re
import struct
import threading
from typing import Any, Protocol
from .dataset import Sample, scan_dataset
from .featurescript_parser import parse_featurescript
from .lowering import _number, _point, plain
DESCRIPTION_SCHEMA_VERSION = "cadfs_to_cdsl.description.v1"
MANIFEST_NAME = "description_manifest.jsonl"
ENTITY_LABELS = {
"skLineSegment": "线段",
"skCircle": "圆",
"skArc": "圆弧",
"skEllipse": "椭圆",
"skFitSpline": "样条",
"skPoint": "点",
}
OPERATION_LABELS = {
"extrude": "拉伸",
"revolve": "旋转",
"fillet": "圆角",
"chamfer": "倒角",
"hole": "孔",
"linearPattern": "线性阵列",
"circularPattern": "圆周阵列",
"mirror": "镜像",
"shell": "抽壳",
"loft": "放样",
"sweep": "扫掠",
"booleanBodies": "布尔",
"cPlane": "参考平面",
"referenceAxis": "参考轴",
}
VISION_DESCRIPTION_TOOL = {
"type": "function",
"function": {
"name": "describe_cad_model",
"description": "Return a conservative semantic and geometric description for one CAD model.",
"parameters": {
"type": "object",
"additionalProperties": False,
"required": [
"category",
"category_confidence",
"candidate_names",
"summary_zh",
"possible_functions",
"applications",
"structural_features",
"geometric_features",
"keywords_zh",
"keywords_en",
"uncertainties",
],
"properties": {
"category": {"type": "string", "minLength": 1, "maxLength": 160},
"category_confidence": {"type": "number", "minimum": 0, "maximum": 1},
"candidate_names": {"type": "array", "items": {"type": "string", "minLength": 1, "maxLength": 120}, "maxItems": 8},
"summary_zh": {"type": "string", "minLength": 1, "maxLength": 1200},
"possible_functions": {"type": "array", "items": {"type": "string", "minLength": 1, "maxLength": 240}, "maxItems": 8},
"applications": {"type": "array", "items": {"type": "string", "minLength": 1, "maxLength": 240}, "maxItems": 8},
"structural_features": {"type": "array", "items": {"type": "string", "minLength": 1, "maxLength": 240}, "maxItems": 16},
"geometric_features": {"type": "array", "items": {"type": "string", "minLength": 1, "maxLength": 240}, "maxItems": 16},
"keywords_zh": {"type": "array", "items": {"type": "string", "minLength": 1, "maxLength": 80}, "maxItems": 32},
"keywords_en": {"type": "array", "items": {"type": "string", "minLength": 1, "maxLength": 80}, "maxItems": 32},
"uncertainties": {"type": "array", "items": {"type": "string", "minLength": 1, "maxLength": 240}, "maxItems": 12},
},
},
},
}
class VisionDescriptionClient(Protocol):
def describe(self, *, sample_id: str, image_path: Path, local_facts: dict[str, Any]) -> dict[str, Any]:
...
class ConfiguredVisionDescriptionClient:
def __init__(self) -> None:
from app.cad_agent.adapters.structured_llm import StructuredModelGateway
from app.settings import get_settings
self.settings = get_settings()
provider, model = self.settings.resolve_review_model()
if not model.vision:
raise ValueError("configured review model is not vision-capable")
self.provider_id = provider.id
self.model_id = model.id
self.gateway = StructuredModelGateway(self.settings)
def describe(self, *, sample_id: str, image_path: Path, local_facts: dict[str, Any]) -> dict[str, Any]:
try:
loop = asyncio.get_running_loop()
except RuntimeError:
loop = None
if loop and loop.is_running():
raise RuntimeError("vision description cannot run inside an active event loop")
return asyncio.run(self._describe(sample_id=sample_id, image_path=image_path, local_facts=local_facts))
async def _describe(self, *, sample_id: str, image_path: Path, local_facts: dict[str, Any]) -> dict[str, Any]:
payload = {
"sample_id": sample_id,
"local_category": local_facts.get("category"),
"local_candidate_names": local_facts.get("candidate_names"),
"local_geometric_features": local_facts.get("geometric_features"),
"local_operations": local_facts.get("operations"),
"local_dimensions": local_facts.get("dimensions"),
"annotation_excerpt": str(local_facts.get("annotation_excerpt") or "")[:4000],
"instruction": (
"Use the image and deterministic CAD facts to identify likely model class and retrieval terms. "
"Keep product identity and use cases as candidates when the evidence is not definitive. "
"Do not invent exact dimensions beyond the supplied facts."
),
}
content: list[dict[str, Any]] = [{"type": "text", "text": json.dumps(payload, ensure_ascii=False)}]
if image_path.is_file():
content.append(self._image_part(image_path))
response = await self.gateway.call_tool(
messages=[
{
"role": "system",
"content": (
"You describe CAD parts for vector search. Return only the required tool call. "
"Separate visible geometry from inferred semantics, and mark uncertain real-world identity conservatively."
),
},
{"role": "user", "content": content},
],
tool=VISION_DESCRIPTION_TOOL,
provider_id=self.provider_id,
model_id=self.model_id,
required_tool_name="describe_cad_model",
)
calls = response.get("tool_calls") or []
if len(calls) != 1:
raise ValueError("vision provider did not return exactly one tool call")
function = calls[0].get("function") if isinstance(calls[0], dict) else None
if not isinstance(function, dict) or function.get("name") != "describe_cad_model":
raise ValueError("vision provider returned an unexpected tool call")
arguments = json.loads(str(function.get("arguments") or "{}"))
if not isinstance(arguments, dict):
raise ValueError("vision provider arguments are not an object")
arguments["usage"] = response.get("usage") or {}
return arguments
@staticmethod
def _image_part(path: Path) -> dict[str, Any]:
media_type = "image/jpeg" if path.suffix.lower() in {".jpg", ".jpeg"} else "image/png"
data = base64.b64encode(path.read_bytes()).decode("ascii")
return {"type": "image_url", "image_url": {"url": f"data:{media_type};base64,{data}"}}
class VisionCallState:
def __init__(self, client: VisionDescriptionClient, *, failure_limit: int = 3) -> None:
self.client = client
self.failure_limit = failure_limit
self._lock = threading.Lock()
self._consecutive_failures = 0
self._disabled_reason = ""
def describe(self, *, sample_id: str, image_path: Path, local_facts: dict[str, Any]) -> tuple[dict[str, Any] | None, dict[str, Any] | None]:
with self._lock:
disabled = self._disabled_reason
if disabled:
return None, {"code": "vision_disabled", "message": disabled}
try:
result = self.client.describe(sample_id=sample_id, image_path=image_path, local_facts=local_facts)
except Exception as exc:
with self._lock:
self._consecutive_failures += 1
if self._consecutive_failures >= self.failure_limit:
self._disabled_reason = f"vision disabled after {self._consecutive_failures} consecutive failures"
return None, {"code": "vision_failed", "message": str(exc), "type": type(exc).__name__}
with self._lock:
self._consecutive_failures = 0
return result, None
def sample_shard(sample: Sample) -> str:
for modality in ("image", "featurescript", "annotation", "step", "stl"):
path = sample.files.get(modality)
if path:
return Path(path).parent.name
return sample.sample_id[:4]
def select_description_samples(
input_root: Path,
*,
shard: str | None = None,
sample_ids: list[str] | None = None,
offset: int = 0,
limit: int | None = None,
seed: int | None = None,
) -> list[Sample]:
samples = scan_dataset(input_root, include_hashes=False)
if shard:
samples = [sample for sample in samples if sample_shard(sample) == shard]
if sample_ids:
wanted = set(sample_ids)
samples = [sample for sample in samples if sample.sample_id in wanted]
missing = wanted - {sample.sample_id for sample in samples}
if missing:
raise ValueError("unknown sample ids: " + ", ".join(sorted(missing)))
if seed is not None and limit is not None:
samples = random.Random(seed).sample(samples, min(limit, len(samples)))
return sorted(samples, key=lambda sample: sample.sample_id)
return samples[offset:None if limit is None else offset + limit]
def describe_samples(
input_root: Path,
*,
shard: str | None = None,
sample_ids: list[str] | None = None,
mode: str = "hybrid",
offset: int = 0,
limit: int | None = None,
seed: int | None = None,
force: bool = False,
workers: int = 1,
vision_client: VisionDescriptionClient | None = None,
) -> tuple[list[dict[str, Any]], dict[str, Any]]:
if mode not in {"local", "hybrid", "vision"}:
raise ValueError("--mode must be local, hybrid, or vision")
if not shard and not sample_ids and limit is None:
raise ValueError("describe requires --shard, --sample-id, or --limit to avoid accidental full-corpus generation")
if not 1 <= workers <= 8:
raise ValueError("--workers must be between 1 and 8")
samples = select_description_samples(input_root, shard=shard, sample_ids=sample_ids, offset=offset, limit=limit, seed=seed)
existing_manifest = _read_manifest(input_root / "description_txt" / MANIFEST_NAME)
shared_diagnostics: list[dict[str, Any]] = []
vision_state = VisionCallState(vision_client) if vision_client is not None else None
if mode != "local" and vision_client is None:
try:
vision_client = ConfiguredVisionDescriptionClient()
vision_state = VisionCallState(vision_client)
except Exception as exc:
shared_diagnostics.append({"code": "vision_unavailable", "message": str(exc), "type": type(exc).__name__})
def process(sample: Sample) -> dict[str, Any]:
return describe_one(
sample,
input_root,
mode=mode,
force=force,
vision_state=vision_state,
existing_record=existing_manifest.get(sample.sample_id),
shared_diagnostics=shared_diagnostics,
)
if workers == 1:
records = [process(sample) for sample in samples]
else:
with ThreadPoolExecutor(max_workers=workers, thread_name_prefix="cadfs-describe") as executor:
records = list(executor.map(process, samples))
manifest_rows = _write_manifest(input_root, records)
return records, _summary(input_root, records, manifest_rows)
def describe_one(
sample: Sample,
input_root: Path,
*,
mode: str,
force: bool = False,
vision_state: VisionCallState | None = None,
existing_record: dict[str, Any] | None = None,
shared_diagnostics: list[dict[str, Any]] | None = None,
) -> dict[str, Any]:
shard = sample_shard(sample)
txt_path = input_root / "description_txt" / shard / f"{sample.sample_id}.txt"
if txt_path.exists() and not force:
return existing_record or _skipped_record(sample, input_root, txt_path, shard)
diagnostics = list(shared_diagnostics or [])
try:
local_facts = _local_facts(sample)
diagnostics.extend(local_facts.pop("diagnostics", []))
vision_result = None
if mode != "local":
if vision_state is None:
diagnostics.append({"code": "vision_unavailable", "message": "no configured vision client"})
else:
vision_result, vision_diagnostic = vision_state.describe(
sample_id=sample.sample_id,
image_path=Path(sample.files.get("image", "")),
local_facts=local_facts,
)
if vision_diagnostic is not None:
diagnostics.append(vision_diagnostic)
record = _compose_record(sample, input_root, shard, txt_path, local_facts, vision_result, diagnostics, mode)
_atomic_write_text(txt_path, render_description_text(record))
return record
except Exception as exc:
diagnostics.append({"code": "description_failed", "message": str(exc), "type": type(exc).__name__})
record = _failed_record(sample, input_root, txt_path, shard, diagnostics)
_atomic_write_text(txt_path, render_description_text(record))
return record
def render_description_text(record: dict[str, Any]) -> str:
confidence = _format_float(float(record.get("category_confidence") or 0.0))
keywords = _unique([*(record.get("keywords_zh") or []), *(record.get("keywords_en") or [])], limit=48)
evidence = [
"确定事实来自 FeatureScript、原始 CAD 操作说明、STL/STEP 文件存在性和可解析的几何范围。",
*_as_text_list(record.get("uncertainties")),
]
diagnostics = record.get("diagnostics") or []
if diagnostics:
codes = _unique([str(item.get("code") or "diagnostic") for item in diagnostics if isinstance(item, dict)], limit=8)
if codes:
evidence.append("诊断:" + "".join(codes))
lines = [
f"样本ID{record.get('sample_id', '')}",
f"分类:{record.get('category', '通用机械 CAD 零件(候选)')}",
"候选名称:" + _join_or_unknown(record.get("candidate_names")),
"模型概述:" + str(record.get("summary_zh") or "该模型为缺少语义上下文的 CAD 几何样本,描述以可见结构和建模特征为主。"),
"可能作用:" + _join_or_unknown(record.get("possible_functions")),
"典型应用:" + _join_or_unknown(record.get("applications")),
"结构特征:" + _join_or_unknown(record.get("structural_features")),
"建模与几何特征:" + _join_or_unknown(record.get("geometric_features")),
"检索关键词:" + _join_or_unknown(keywords),
"证据与不确定性:" + "".join(evidence),
f"置信度:{confidence}(几何事实置信度较高;真实零件类别、用途和装配位置为候选判断)",
]
return "\n".join(lines) + "\n"
def _local_facts(sample: Sample) -> dict[str, Any]:
feature_path = Path(sample.files["featurescript"])
annotation_path = Path(sample.files.get("annotation", ""))
stl_path = Path(sample.files.get("stl", ""))
image_path = Path(sample.files.get("image", ""))
source = feature_path.read_text(encoding="utf-8")
annotation = annotation_path.read_text(encoding="utf-8") if annotation_path.is_file() else ""
model = parse_featurescript(source, sample.sample_id)
diagnostics: list[dict[str, Any]] = []
sketch_info, sketch_features, sketch_dimensions, entity_counts = _describe_sketches(model)
operations, operation_dimensions = _describe_operations(model)
stl_info = _stl_bbox(stl_path) if stl_path.is_file() else None
image_info = _image_metadata(image_path) if image_path.is_file() else None
shape_cues = _shape_cues(annotation, entity_counts, operations)
geometric_features = _unique(
[
f"包含 {len(model.sketches)} 个草图和 {len(model.features)} 个建模/修饰特征",
*sketch_features,
*shape_cues,
*_stl_features(stl_info),
],
limit=32,
)
dimensions = _unique([*sketch_dimensions, *operation_dimensions, *_stl_dimensions(stl_info)], limit=32)
classification = _classify_local(annotation, operations, geometric_features, stl_info)
keywords_zh, keywords_en = _keywords(classification, operations, geometric_features)
uncertainties = [
"CADFS 样本未提供真实装配上下文,类别、用途和安装位置只能作为候选语义。",
"确定描述优先依据几何、草图和特征操作;视觉判断仅作为补充证据。",
]
summary = _local_summary(classification, operations, geometric_features, dimensions)
if not source.strip():
diagnostics.append({"code": "empty_featurescript", "message": "FeatureScript file is empty"})
return {
"category": classification["category"],
"category_confidence": classification["confidence"],
"candidate_names": classification["candidate_names"],
"summary_zh": summary,
"possible_functions": classification["possible_functions"],
"applications": classification["applications"],
"structural_features": _unique([*classification["structural_features"], *sketch_info], limit=24),
"geometric_features": geometric_features,
"operations": operations,
"dimensions": dimensions,
"keywords_zh": keywords_zh,
"keywords_en": keywords_en,
"uncertainties": uncertainties,
"annotation_excerpt": annotation[:4000],
"image_metadata": image_info,
"diagnostics": diagnostics,
}
def _describe_sketches(model: Any) -> tuple[list[str], list[str], list[str], Counter[str]]:
sketch_info: list[str] = []
features: list[str] = []
dimensions: list[str] = []
entity_counts: Counter[str] = Counter()
for sketch in model.sketches:
counts = Counter(entity.operation for entity in sketch.entities)
entity_counts.update(counts)
labels = [f"{ENTITY_LABELS.get(name, name)} {count} 个" for name, count in sorted(counts.items())]
plane = _plane_name(sketch.workplane)
if labels:
sketch_info.append(f"{sketch.feature_id} 位于 {plane} 平面,包含" + "、".join(labels))
else:
sketch_info.append(f"{sketch.feature_id} 位于 {plane} 平面,没有可解析草图实体")
if counts:
features.append("草图包含" + "、".join(labels))
bbox = _sketch_bbox(sketch)
if bbox:
width = bbox[2] - bbox[0]
height = bbox[3] - bbox[1]
dimensions.append(f"{sketch.feature_id} 二维范围约 {_format_float(width)} × {_format_float(height)} mm")
return sketch_info, _unique(features, limit=12), dimensions, entity_counts
def _describe_operations(model: Any) -> tuple[list[str], list[str]]:
operations: list[str] = []
dimensions: list[str] = []
for feature in model.features:
params = feature.params
operation = feature.operation
if operation == "extrude":
operation_type = _enum_tail(params.get("operationType") or "NEW").upper()
action = "extrude_cut" if operation_type in {"REMOVE", "CUT"} else "extrude_add"
depth = _safe_number(params.get("depth"))
label = f"{action}"
if depth is not None:
label += f" depth_mm={_format_float(depth)}"
dimensions.append(f"{feature.feature_id} 拉伸深度 {_format_mm(depth)}")
if _truthy(params.get("hasSecondDirection")):
label += " two_sided=true"
operations.append(label)
elif operation == "revolve":
operation_type = _enum_tail(params.get("operationType") or params.get("surfaceOperationType") or "NEW").upper()
action = "revolve_cut" if operation_type in {"REMOVE", "CUT"} else "revolve_add"
angle = 360.0 if "FULL" in _enum_tail(params.get("revolveType") or "FULL").upper() else _safe_number(params.get("angle"))
operations.append(f"{action} angle_deg={_format_float(angle or 360.0)}")
elif operation == "hole":
diameter = _safe_number(params.get("holeDiameter"))
style = _enum_tail(params.get("style") or "simple").lower()
label = f"hole {style}"
if diameter is not None:
label += f" diameter_mm={_format_float(diameter)}"
dimensions.append(f"{feature.feature_id} 孔径 {_format_mm(diameter)}")
operations.append(label)
elif operation == "fillet":
radius = _safe_number(params.get("radius"))
operations.append("fillet" + (f" radius_mm={_format_float(radius)}" if radius is not None else ""))
if radius is not None:
dimensions.append(f"{feature.feature_id} 圆角半径 {_format_mm(radius)}")
elif operation == "chamfer":
width = _safe_number(params.get("width") or params.get("width1"))
operations.append("chamfer" + (f" distance_mm={_format_float(width)}" if width is not None else ""))
if width is not None:
dimensions.append(f"{feature.feature_id} 倒角距离 {_format_mm(width)}")
else:
operations.append(OPERATION_LABELS.get(operation, operation))
return _unique(operations, limit=32), _unique(dimensions, limit=32)
def _shape_cues(annotation: str, entity_counts: Counter[str], operations: list[str]) -> list[str]:
text = annotation.lower()
joined_ops = " ".join(operations).lower()
cues: list[str] = []
if "triangle" in text or "triangular" in text:
cues.append("具有三角形轮廓或三角截面")
if "rectangle" in text or entity_counts.get("skLineSegment", 0) >= 4:
cues.append("包含矩形/多边形直线轮廓")
if "circle" in text or entity_counts.get("skCircle", 0):
cues.append("包含圆形轮廓或圆孔候选特征")
if "arc" in text or entity_counts.get("skArc", 0):
cues.append("包含圆弧边界")
if "slot" in text or "notch" in text or "cutout" in text or "开口" in text:
cues.append("包含槽、缺口或开口候选结构")
if "hole" in text or "hole" in joined_ops:
cues.append("包含孔加工候选结构")
if "fillet" in joined_ops:
cues.append("包含圆角过渡")
if "chamfer" in joined_ops:
cues.append("包含倒角边")
if "pattern" in joined_ops:
cues.append("包含阵列重复特征")
if "mirror" in joined_ops:
cues.append("包含镜像对称特征")
if any(item.startswith("revolve") for item in operations):
cues.append("绕轴旋转形成回转体")
if any(item.startswith("extrude") for item in operations):
cues.append("由二维草图拉伸形成实体")
return _unique(cues, limit=16)
def _classify_local(annotation: str, operations: list[str], geometric_features: list[str], stl_info: dict[str, Any] | None) -> dict[str, Any]:
text = " ".join([annotation.lower(), " ".join(operations).lower(), " ".join(geometric_features)])
slender = _is_slender(stl_info)
has_extrude = any(item.startswith("extrude") for item in operations)
has_revolve = any(item.startswith("revolve") for item in operations)
has_hole = "hole" in text or "孔" in text
has_triangle = "triangle" in text or "triangular" in text or "三角" in text
has_rectangle = "rectangle" in text or "矩形" in text
has_cutout = any(word in text for word in ("slot", "notch", "cutout", "开口", "缺口"))
if has_revolve:
return {
"category": "回转轴套/法兰类零件(候选)" if has_hole else "回转体机械零件(候选)",
"confidence": 0.5,
"candidate_names": ["回转体", "轴套", "法兰盘", "轮毂状零件", "revolved part", "flange", "bushing"],
"possible_functions": ["可能用于同轴定位、连接、支承、隔套或旋转类结构的几何占位。"],
"applications": ["机械传动、夹具、管路连接、轴承座周边或需要轴线对称零件的装配场景。"],
"structural_features": ["回转外形", "轴向轮廓", "圆柱/圆盘候选结构"],
}
if has_triangle and has_extrude:
return {
"category": "三角棱柱/楔形梁类零件(候选)",
"confidence": 0.56,
"candidate_names": ["长条三角棱柱", "楔形梁", "三角截面导轨", "triangular prism", "wedge beam"],
"possible_functions": ["可能作为楔块、导向条、支撑肋、定位块或三角截面结构件使用。"],
"applications": ["夹具定位、机械支撑、导向结构、教育/仿真几何库或需要楔形截面的 CAD 检索场景。"],
"structural_features": ["三角截面", "长条拉伸体", "棱柱体", "斜面侧壁"],
}
if has_hole and has_rectangle:
return {
"category": "带孔板/安装板类零件(候选)",
"confidence": 0.52,
"candidate_names": ["安装板", "连接板", "带孔支架板", "mounting plate", "bracket plate"],
"possible_functions": ["可能用于螺钉安装、定位连接、固定支撑或作为装配转接板。"],
"applications": ["设备框架、夹具底板、连接支架、外壳内部固定件。"],
"structural_features": ["板状主体", "孔特征", "平面安装面"],
}
if has_cutout and has_extrude:
return {
"category": "开槽板/叉形支架类零件(候选)",
"confidence": 0.48,
"candidate_names": ["开槽板", "叉形支架", "U 形支架", "slotted plate", "fork bracket"],
"possible_functions": ["可能用于避让、卡接、导向、夹持或作为插槽式连接件。"],
"applications": ["支架、夹具、连接耳、导向槽结构或板件开口检索场景。"],
"structural_features": ["开口/缺口", "板状拉伸体", "平直侧壁"],
}
if has_extrude and slender:
return {
"category": "拉伸型梁/导轨类零件(候选)",
"confidence": 0.46,
"candidate_names": ["拉伸梁", "导轨条", "长条棱柱", "extruded beam", "rail"],
"possible_functions": ["可能用于导向、支撑、隔距、边框或长条结构件。"],
"applications": ["框架、滑轨、夹具、机械结构支撑或型材检索场景。"],
"structural_features": ["长条外形", "恒定截面候选", "拉伸成型"],
}
if has_extrude:
return {
"category": "拉伸棱柱/板块类零件(候选)",
"confidence": 0.42,
"candidate_names": ["拉伸实体", "板块", "棱柱体", "extruded solid", "prismatic part"],
"possible_functions": ["可能作为基础块、板件、支撑件或后续加工毛坯。"],
"applications": ["通用机械零件、夹具、支架、CAD 几何检索和相似形状匹配。"],
"structural_features": ["二维轮廓拉伸", "平面端面", "直壁结构"],
}
return {
"category": "通用机械 CAD 零件(候选)",
"confidence": 0.34,
"candidate_names": ["机械零件", "CAD 几何样本", "mechanical part", "CAD model"],
"possible_functions": ["可能作为机械结构、连接、支撑或几何检索样本,实际用途需结合装配上下文确认。"],
"applications": ["CAD 数据集检索、几何相似度匹配、零件分类训练和工程知识库索引。"],
"structural_features": ["可解析 CAD 特征组合"],
}
def _keywords(classification: dict[str, Any], operations: list[str], geometric_features: list[str]) -> tuple[list[str], list[str]]:
text = " ".join([classification["category"], " ".join(classification["candidate_names"]), " ".join(operations), " ".join(geometric_features)]).lower()
zh = ["CAD模型", "机械零件", "几何检索", "相似特征", *classification["candidate_names"][:4]]
en = ["cad model", "mechanical part", "geometry retrieval", "similar features"]
mappings = [
("三角", "三角棱柱", "triangular prism"),
("wedge", "楔形", "wedge"),
("extrude", "拉伸", "extruded"),
("revolve", "回转", "revolved"),
("hole", "孔", "hole"),
("fillet", "圆角", "fillet"),
("chamfer", "倒角", "chamfer"),
("slot", "槽", "slot"),
("flange", "法兰", "flange"),
("bushing", "轴套", "bushing"),
("plate", "板件", "plate"),
("bracket", "支架", "bracket"),
("rail", "导轨", "rail"),
]
for needle, zh_value, en_value in mappings:
if needle in text or zh_value in text:
zh.append(zh_value)
en.append(en_value)
return _unique(zh, limit=32), _unique(en, limit=32)
def _compose_record(
sample: Sample,
input_root: Path,
shard: str,
txt_path: Path,
local_facts: dict[str, Any],
vision_result: dict[str, Any] | None,
diagnostics: list[dict[str, Any]],
mode: str,
) -> dict[str, Any]:
vision = _normalize_vision(vision_result)
status = "described_local"
if mode != "local":
status = "described_hybrid" if vision else "local_fallback"
category = vision.get("category") or local_facts["category"]
category_confidence = _clamp_float(vision.get("category_confidence"), local_facts["category_confidence"])
candidate_names = _unique([*vision.get("candidate_names", []), *local_facts["candidate_names"]], limit=12)
structural_features = _unique([*local_facts["structural_features"], *vision.get("structural_features", [])], limit=32)
geometric_features = _unique([*local_facts["geometric_features"], *vision.get("geometric_features", [])], limit=40)
uncertainties = _unique([*local_facts["uncertainties"], *vision.get("uncertainties", [])], limit=20)
return {
"schema_version": DESCRIPTION_SCHEMA_VERSION,
"sample_id": sample.sample_id,
"shard": shard,
"status": status,
"txt_path": str(txt_path),
"category": category,
"category_confidence": category_confidence,
"candidate_names": candidate_names,
"summary_zh": vision.get("summary_zh") or local_facts["summary_zh"],
"possible_functions": _unique([*vision.get("possible_functions", []), *local_facts["possible_functions"]], limit=12),
"applications": _unique([*vision.get("applications", []), *local_facts["applications"]], limit=12),
"structural_features": structural_features,
"geometric_features": geometric_features,
"operations": local_facts["operations"],
"dimensions": local_facts["dimensions"],
"keywords_zh": _unique([*vision.get("keywords_zh", []), *local_facts["keywords_zh"]], limit=40),
"keywords_en": _unique([*vision.get("keywords_en", []), *local_facts["keywords_en"]], limit=40),
"uncertainties": uncertainties,
"source_files": _source_files(sample, input_root),
"diagnostics": diagnostics,
}
def _normalize_vision(value: dict[str, Any] | None) -> dict[str, Any]:
if not isinstance(value, dict):
return {}
result: dict[str, Any] = {}
for key in (
"candidate_names",
"possible_functions",
"applications",
"structural_features",
"geometric_features",
"keywords_zh",
"keywords_en",
"uncertainties",
):
result[key] = _as_text_list(value.get(key))
for key in ("category", "summary_zh"):
if isinstance(value.get(key), str) and value[key].strip():
result[key] = value[key].strip()
result["category_confidence"] = value.get("category_confidence")
return result
def _local_summary(classification: dict[str, Any], operations: list[str], geometric_features: list[str], dimensions: list[str]) -> str:
operation_text = "、".join(operations[:4]) if operations else "可解析 CAD 特征"
feature_text = "".join(geometric_features[:4]) if geometric_features else "几何结构待进一步识别"
dimension_text = "".join(dimensions[:3]) if dimensions else "未提取到稳定尺寸摘要"
return (
f"该模型可保守识别为{classification['category']},主要由 {operation_text} 构成。"
f"确定结构包括:{feature_text}。尺寸线索:{dimension_text}。"
"真实产品身份和装配用途缺少上下文,因此以候选名称和相似几何特征用于检索。"
)
def _failed_record(sample: Sample, input_root: Path, txt_path: Path, shard: str, diagnostics: list[dict[str, Any]]) -> dict[str, Any]:
return {
"schema_version": DESCRIPTION_SCHEMA_VERSION,
"sample_id": sample.sample_id,
"shard": shard,
"status": "failed",
"txt_path": str(txt_path),
"category": "通用机械 CAD 零件(描述失败)",
"category_confidence": 0.0,
"candidate_names": ["CAD 模型"],
"summary_zh": "该样本描述生成失败,仅保留源文件索引和诊断信息。",
"possible_functions": ["无法可靠判断。"],
"applications": ["需重新运行描述生成或人工复核后再进入向量库。"],
"structural_features": [],
"geometric_features": [],
"operations": [],
"dimensions": [],
"keywords_zh": ["CAD模型", "描述失败"],
"keywords_en": ["cad model", "description failed"],
"uncertainties": ["描述生成过程失败,不能据此判断模型类别或用途。"],
"source_files": _source_files(sample, input_root),
"diagnostics": diagnostics,
}
def _skipped_record(sample: Sample, input_root: Path, txt_path: Path, shard: str) -> dict[str, Any]:
return {
"schema_version": DESCRIPTION_SCHEMA_VERSION,
"sample_id": sample.sample_id,
"shard": shard,
"status": "skipped_existing",
"txt_path": str(txt_path),
"category": "",
"category_confidence": 0.0,
"candidate_names": [],
"geometric_features": [],
"operations": [],
"dimensions": [],
"keywords_zh": [],
"keywords_en": [],
"uncertainties": ["已有描述文件,未使用 --force,因此本次未覆盖。"],
"source_files": _source_files(sample, input_root),
"diagnostics": [{"code": "existing_output_skipped"}],
}
def _source_files(sample: Sample, input_root: Path) -> dict[str, str]:
return {key: str(Path(value)) for key, value in sorted(sample.files.items()) if Path(value).is_file() or input_root}
def _read_manifest(path: Path) -> dict[str, dict[str, Any]]:
if not path.exists():
return {}
records: dict[str, dict[str, Any]] = {}
for line in path.read_text(encoding="utf-8").splitlines():
if not line.strip():
continue
try:
value = json.loads(line)
except json.JSONDecodeError:
continue
sample_id = str(value.get("sample_id") or "")
if sample_id:
records[sample_id] = value
return records
def _write_manifest(input_root: Path, records: list[dict[str, Any]]) -> int:
path = input_root / "description_txt" / MANIFEST_NAME
existing = _read_manifest(path)
for record in records:
existing[str(record["sample_id"])] = record
ordered = [existing[key] for key in sorted(existing)]
text = "".join(json.dumps(record, ensure_ascii=False, sort_keys=True) + "\n" for record in ordered)
_atomic_write_text(path, text)
return len(ordered)
def _summary(input_root: Path, records: list[dict[str, Any]], manifest_rows: int) -> dict[str, Any]:
counts = Counter(str(record.get("status") or "unknown") for record in records)
txt_count = sum(1 for record in records if Path(str(record.get("txt_path") or "")).is_file())
return {
"sample_count": len(records),
"statuses": dict(sorted(counts.items())),
"txt_count": txt_count,
"manifest": str(input_root / "description_txt" / MANIFEST_NAME),
"manifest_rows": manifest_rows,
}
def _atomic_write_text(path: Path, text: str) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
tmp = path.with_name(path.name + ".tmp")
tmp.write_text(text, encoding="utf-8")
tmp.replace(path)
def _safe_number(value: Any) -> float | None:
try:
result = _number(value, True)
except Exception:
return None
return result if math.isfinite(result) else None
def _safe_point(value: Any) -> list[float] | None:
try:
point = _point(value)
except Exception:
return None
return point if len(point) >= 2 and all(math.isfinite(item) for item in point[:2]) else None
def _sketch_bbox(sketch: Any) -> list[float] | None:
xs: list[float] = []
ys: list[float] = []
for entity in sketch.entities:
params = entity.params
if entity.operation == "skLineSegment":
for key in ("start", "end"):
point = _safe_point(params.get(key))
if point:
xs.append(point[0])
ys.append(point[1])
elif entity.operation == "skCircle":
center = _safe_point(params.get("center"))
radius = _safe_number(params.get("radius"))
if center and radius is not None:
xs.extend([center[0] - radius, center[0] + radius])
ys.extend([center[1] - radius, center[1] + radius])
elif entity.operation == "skArc":
for key in ("start", "mid", "end"):
point = _safe_point(params.get(key))
if point:
xs.append(point[0])
ys.append(point[1])
elif entity.operation == "skPoint":
point = _safe_point(params.get("position"))
if point:
xs.append(point[0])
ys.append(point[1])
if not xs or not ys:
return None
return [min(xs), min(ys), max(xs), max(ys)]
def _stl_bbox(path: Path) -> dict[str, Any] | None:
try:
data = path.read_bytes()
except OSError:
return None
vertices = _binary_stl_vertices(data) or _ascii_stl_vertices(data)
if not vertices:
return None
mins = [min(vertex[i] for vertex in vertices) for i in range(3)]
maxs = [max(vertex[i] for vertex in vertices) for i in range(3)]
size = [maxs[i] - mins[i] for i in range(3)]
return {"min_mm": mins, "max_mm": maxs, "size_mm": size, "vertex_count": len(vertices)}
def _binary_stl_vertices(data: bytes) -> list[tuple[float, float, float]]:
if len(data) < 84:
return []
triangle_count = struct.unpack("<I", data[80:84])[0]
expected = 84 + triangle_count * 50
if triangle_count <= 0 or expected > len(data):
return []
vertices: list[tuple[float, float, float]] = []
offset = 84
for _ in range(triangle_count):
offset += 12
for _ in range(3):
vertices.append(struct.unpack("<fff", data[offset:offset + 12]))
offset += 12
offset += 2
return vertices
def _ascii_stl_vertices(data: bytes) -> list[tuple[float, float, float]]:
try:
text = data[:5_000_000].decode("utf-8", errors="ignore")
except Exception:
return []
pattern = re.compile(r"\bvertex\s+([-+0-9.eE]+)\s+([-+0-9.eE]+)\s+([-+0-9.eE]+)")
vertices = []
for match in pattern.finditer(text):
try:
vertices.append((float(match.group(1)), float(match.group(2)), float(match.group(3))))
except ValueError:
continue
return vertices
def _image_metadata(path: Path) -> dict[str, Any]:
try:
from PIL import Image
with Image.open(path) as image:
return {"width": image.width, "height": image.height, "format": str(image.format or "").lower()}
except Exception as exc:
return {"error": f"image metadata unavailable: {type(exc).__name__}"}
def _stl_features(stl_info: dict[str, Any] | None) -> list[str]:
if not stl_info:
return []
size = stl_info.get("size_mm") or []
if len(size) != 3:
return []
features = ["STL 网格提供三维包围盒"]
if _is_slender(stl_info):
features.append("整体呈长条比例")
if _is_plate_like(stl_info):
features.append("整体呈薄板比例")
return features
def _stl_dimensions(stl_info: dict[str, Any] | None) -> list[str]:
if not stl_info:
return []
size = stl_info.get("size_mm") or []
if len(size) != 3:
return []
return [f"STL 三维包围盒约 {_format_float(size[0])} × {_format_float(size[1])} × {_format_float(size[2])} mm"]
def _is_slender(stl_info: dict[str, Any] | None) -> bool:
if not stl_info:
return False
values = [abs(float(item)) for item in stl_info.get("size_mm") or [] if abs(float(item)) > 1e-9]
return len(values) >= 2 and max(values) / max(min(values), 1e-9) >= 3.0
def _is_plate_like(stl_info: dict[str, Any] | None) -> bool:
if not stl_info:
return False
values = sorted(abs(float(item)) for item in stl_info.get("size_mm") or [] if abs(float(item)) > 1e-9)
return len(values) == 3 and values[0] * 4 <= values[1]
def _plane_name(value: Any) -> str:
text = json.dumps(plain(value), ensure_ascii=False)
for name in ("Top", "Front", "Right"):
if f"{name}.planeOp" in text:
return name
return "未知"
def _enum_tail(value: Any) -> str:
return str(value or "").split(".")[-1]
def _truthy(value: Any) -> bool:
return value is True or (isinstance(value, str) and value.lower() == "true")
def _format_float(value: float) -> str:
rounded = round(float(value), 4)
if rounded == int(rounded):
return str(int(rounded))
return f"{rounded:.4f}".rstrip("0").rstrip(".")
def _format_mm(value: float) -> str:
return f"{_format_float(value)} mm"
def _clamp_float(value: Any, fallback: float) -> float:
try:
number = float(value)
except (TypeError, ValueError):
number = float(fallback)
if not math.isfinite(number):
number = float(fallback)
return max(0.0, min(1.0, number))
def _as_text_list(value: Any) -> list[str]:
if not isinstance(value, list):
return []
return [str(item).strip() for item in value if str(item).strip()]
def _unique(values: list[Any], *, limit: int | None = None) -> list[str]:
result: list[str] = []
seen: set[str] = set()
for value in values:
text = str(value).strip()
if not text or text in seen:
continue
seen.add(text)
result.append(text)
if limit is not None and len(result) >= limit:
break
return result
def _join_or_unknown(values: Any) -> str:
items = _as_text_list(values) if isinstance(values, list) else _unique(list(values or [])) if isinstance(values, tuple) else []
return "、".join(items) if items else "无法可靠判断"