US07-03: Harden Media and Metadata Edge Cases (#85)

This commit was merged in pull request #85.
This commit is contained in:
2026-08-17 01:12:57 +02:00
parent d2e44b5657
commit 3fa35fe21e
18 changed files with 1525 additions and 53 deletions

View File

@@ -108,9 +108,8 @@ from datetime import datetime, timezone
from sqlalchemy import select
from sqlalchemy.orm import sessionmaker
from photo_pipeline.integrations import exiftool
from photo_pipeline.models import Asset, SafetyReview
from photo_pipeline.services import hashing
from photo_pipeline.models import Asset, ExifProjection, SafetyReview
from photo_pipeline.services import exif_checkpoint
DECISIONS = {SFW, NSFW, "deferred"}
@@ -174,6 +173,14 @@ class SafetyService:
with self._session_factory() as session:
assets = list(session.scalars(_eligible_assets_query().order_by(Asset.current_path)))
latest = self._latest_by_asset(session)
# One query, not one per asset: the reviewer needs to see a divergent
# checkpoint, which is neither "verified" nor a plain failure (US07-03).
projections = {
row.asset_id: row.state
for row in session.scalars(
select(ExifProjection).where(ExifProjection.stage == "safety")
)
}
rows = []
for asset in assets:
review = latest.get(asset.id)
@@ -189,6 +196,7 @@ class SafetyService:
"decision": decision,
"suggested": classify(review.score) if review and review.score is not None else None,
"exif_verified": bool(review and review.exif_verified_at),
"exif_state": projections.get(asset.id),
}
)
return {"total": len(rows), "items": rows[offset : offset + limit]}
@@ -247,13 +255,24 @@ class SafetyService:
result_sha256 = None
if write_exif and decision in (SFW, NSFW) and path:
ops = exif_projection(decision)
if exiftool.apply_keywords(path, add=ops["add"], remove=ops["remove"]):
# Read back: the chosen keyword present, the opposite absent.
keywords = exiftool.read_keyword_sets([path]).get(path, set())
opposite = NSFW if decision == SFW else SFW
if decision in keywords and opposite not in keywords:
exif_verified_at = _now()
result_sha256 = hashing.sha256_file(path)
# The full checkpoint: write the owned keyword, read the whole file back,
# and prove every field this stage does not own survived. A divergent
# result is recorded and left alone — it must not count as verified, so
# upload stays blocked until a human decides (US07-03).
result = exif_checkpoint.run(
path, add=tuple(ops["add"]), remove=tuple(ops["remove"])
)
exif_checkpoint.record(
self._session_factory,
asset_id=asset_id,
stage="safety",
result=result,
add=tuple(ops["add"]),
remove=tuple(ops["remove"]),
)
if result.verified:
exif_verified_at = result.verified_at
result_sha256 = result.sha256
now = _now()
with self._session_factory() as session: