Files
Hartmut cfccdd5397 feat: rich product metadata extraction from STEP files
Extract volume, surface area, part count, assembly hierarchy, and
complexity from STEP files via OCC B-rep analysis.

Backend:
- extract_rich_metadata() in step_processor.py: computes per-part volume
  (BRepGProp), surface area, triangle/vertex count, assembly depth,
  instance count, complexity score, largest part identification
- cad_metadata JSONB column on Product model (DB migration)
- Auto-populated during STEP processing (non-fatal, 10s timeout)
- Also stored in cad_files.mesh_attributes["rich_metadata"]
- Batch re-extract endpoint: POST /admin/settings/reextract-rich-metadata

AI Agent:
- search_products returns part_count, volume_cm3, complexity, largest_part
- query_database tool description documents cad_metadata schema

Frontend:
- ProductDetail page: CAD Metadata section with stat cards
  (parts, volume, surface area, complexity, triangles, assembly depth)
- Admin System Tools: "Re-extract Rich Metadata" button for backfill

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-15 18:49:50 +01:00

112 lines
5.9 KiB
Python

import uuid
import enum
from datetime import datetime
from sqlalchemy import String, DateTime, Boolean, Text, ForeignKey, BigInteger, Enum as SAEnum, Index
from sqlalchemy.orm import Mapped, mapped_column, relationship
from sqlalchemy.dialects.postgresql import UUID, JSONB
from app.database import Base
class ProcessingStatus(str, enum.Enum):
pending = "pending"
processing = "processing"
completed = "completed"
failed = "failed"
class CadFile(Base):
__tablename__ = "cad_files"
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
original_name: Mapped[str] = mapped_column(String(500), nullable=False)
stored_path: Mapped[str] = mapped_column(String(1000), nullable=False)
file_hash: Mapped[str] = mapped_column(String(64), unique=True, nullable=False, index=True)
file_size: Mapped[int] = mapped_column(BigInteger, nullable=True)
parsed_objects: Mapped[dict] = mapped_column(JSONB, nullable=True)
thumbnail_path: Mapped[str] = mapped_column(String(1000), nullable=True)
gltf_path: Mapped[str] = mapped_column(String(1000), nullable=True)
processing_status: Mapped[ProcessingStatus] = mapped_column(
SAEnum(ProcessingStatus), default=ProcessingStatus.pending, nullable=False
)
error_message: Mapped[str] = mapped_column(String(2000), nullable=True)
render_log: Mapped[dict] = mapped_column(JSONB, nullable=True)
mesh_attributes: Mapped[dict | None] = mapped_column(JSONB, nullable=True)
part_materials: Mapped[dict | None] = mapped_column(JSONB, nullable=True, default=None)
source_material_assignments: Mapped[dict | None] = mapped_column(JSONB, nullable=True, default=None)
resolved_material_assignments: Mapped[dict | None] = mapped_column(JSONB, nullable=True, default=None)
manual_material_overrides: Mapped[dict | None] = mapped_column(JSONB, nullable=True, default=None)
step_file_hash: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True)
tenant_id: Mapped[uuid.UUID | None] = mapped_column(
UUID(as_uuid=True), ForeignKey("tenants.id"), nullable=True, index=True
)
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, nullable=False)
updated_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False)
order_items: Mapped[list["OrderItem"]] = relationship("OrderItem", back_populates="cad_file")
products: Mapped[list["Product"]] = relationship("Product", back_populates="cad_file")
class Product(Base):
__tablename__ = "products"
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
pim_id: Mapped[str] = mapped_column(String(500), nullable=False)
name: Mapped[str | None] = mapped_column(String(500), nullable=True)
category_key: Mapped[str | None] = mapped_column(String(100), nullable=True, index=True)
ebene1: Mapped[str | None] = mapped_column(String(500), nullable=True)
ebene2: Mapped[str | None] = mapped_column(String(500), nullable=True)
baureihe: Mapped[str | None] = mapped_column(String(500), nullable=True)
produkt_baureihe: Mapped[str | None] = mapped_column(String(500), nullable=True)
lagertyp: Mapped[str | None] = mapped_column(String(500), nullable=True)
name_cad_modell: Mapped[str | None] = mapped_column(String(500), nullable=True, index=True)
gewuenschte_bildnummer: Mapped[str | None] = mapped_column(String(500), nullable=True)
medias_rendering: Mapped[bool | None] = mapped_column(Boolean, nullable=True)
components: Mapped[list] = mapped_column(JSONB, nullable=False, default=list)
cad_part_materials: Mapped[list] = mapped_column(JSONB, nullable=False, default=list)
cad_file_id: Mapped[uuid.UUID | None] = mapped_column(
UUID(as_uuid=True), ForeignKey("cad_files.id", ondelete="SET NULL"), nullable=True
)
notes: Mapped[str | None] = mapped_column(Text, nullable=True)
is_active: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True)
arbeitspaket: Mapped[str | None] = mapped_column(String(500), nullable=True)
source_excel: Mapped[str | None] = mapped_column(String(1000), nullable=True)
cad_metadata: Mapped[dict | None] = mapped_column(JSONB, nullable=True, default=None)
tenant_id: Mapped[uuid.UUID | None] = mapped_column(
UUID(as_uuid=True), ForeignKey("tenants.id"), nullable=True, index=True
)
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, nullable=False)
updated_at: Mapped[datetime] = mapped_column(
DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False
)
cad_file: Mapped["CadFile | None"] = relationship("CadFile", back_populates="products")
tenant: Mapped["Tenant | None"] = relationship("Tenant", back_populates="products", lazy="noload")
order_lines: Mapped[list["OrderLine"]] = relationship(
"OrderLine", back_populates="product", cascade="all, delete-orphan"
)
render_positions: Mapped[list["ProductRenderPosition"]] = relationship(
"ProductRenderPosition", back_populates="product",
cascade="all, delete-orphan", order_by="ProductRenderPosition.sort_order"
)
@property
def thumbnail_url(self) -> str | None:
if self.cad_file and self.cad_file.thumbnail_path:
from pathlib import Path
return f"/thumbnails/{Path(self.cad_file.thumbnail_path).name}"
return None
@property
def processing_status(self) -> str | None:
if self.cad_file:
return self.cad_file.processing_status.value if hasattr(
self.cad_file.processing_status, 'value'
) else str(self.cad_file.processing_status)
return None
@property
def cad_parsed_objects(self) -> list[str] | None:
if self.cad_file and self.cad_file.parsed_objects:
return self.cad_file.parsed_objects.get("objects") or []
return None