"""Safety review and content-analysis persistence (US02-06). Both tables key on the stable ``asset_id``, not a path — the donors used a path-keyed ``nsfw_scores.csv`` and a path-keyed ``photos`` table, which broke on every move/rename. ``safety_reviews`` is append-only history; the latest row per asset is the current decision (the privacy gate reads it). ``analysis_results`` is one current row per asset (the donor photos schema re-keyed to asset identity). Per-stage ``asset_stage_states``/``exif_projections`` from the concept are not modelled here: the workflow view derives its counts directly from these source tables plus duplicate clusters, which is enough for this story's gates. ponytail: add the full stage-state projection when a stage needs history the source tables can't reconstruct. """ from __future__ import annotations from datetime import datetime from sqlalchemy import DateTime, ForeignKey, Integer, String, func from sqlalchemy.orm import Mapped, mapped_column from photo_pipeline.db import Base class SafetyReview(Base): __tablename__ = "safety_reviews" id: Mapped[str] = mapped_column(String, primary_key=True) asset_id: Mapped[str] = mapped_column(ForeignKey("assets.id"), nullable=False, index=True) # score without decision = scored-but-unreviewed; decision without score = manual. score: Mapped[float | None] = mapped_column() decision: Mapped[str | None] = mapped_column(String) # sfw | nsfw | deferred prior_decision: Mapped[str | None] = mapped_column(String) reviewer: Mapped[str | None] = mapped_column(String) # Set once the mutually-exclusive safety keyword is written to EXIF and read # back — upload eligibility depends on this verified checkpoint. exif_verified_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) result_sha256: Mapped[str | None] = mapped_column(String) created_at: Mapped[datetime] = mapped_column( DateTime(timezone=True), nullable=False, server_default=func.now() ) class AnalysisResult(Base): __tablename__ = "analysis_results" asset_id: Mapped[str] = mapped_column(ForeignKey("assets.id"), primary_key=True) # pending | analyzed | error | skipped_nsfw status: Mapped[str] = mapped_column(String, nullable=False, default="pending") description: Mapped[str | None] = mapped_column(String) tags: Mapped[str | None] = mapped_column(String) # JSON array string (donor shape) people_count: Mapped[int | None] = mapped_column(Integer) setting: Mapped[str | None] = mapped_column(String) time_of_day: Mapped[str | None] = mapped_column(String) season: Mapped[str | None] = mapped_column(String) mood: Mapped[str | None] = mapped_column(String) location_hint: Mapped[str | None] = mapped_column(String) approx_year: Mapped[int | None] = mapped_column(Integer) model: Mapped[str | None] = mapped_column(String) prompt_version: Mapped[str | None] = mapped_column(String) tokens_total: Mapped[int | None] = mapped_column(Integer) raw_response: Mapped[str | None] = mapped_column(String) error_message: Mapped[str | None] = mapped_column(String) analyzed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) exif_written_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))