US02-01: Extract Safety and Existing Web UI Donors (#54)
This commit was merged in pull request #54.
This commit is contained in:
100
photo_pipeline/services/safety.py
Normal file
100
photo_pipeline/services/safety.py
Normal file
@@ -0,0 +1,100 @@
|
||||
"""Safety (NSFW) logic: scoring bands, decisions, and EXIF keyword rules.
|
||||
|
||||
Pure, deterministic functions extracted from the donors so the model adapter and
|
||||
exiftool adapter stay at the edges. This is the shared source of truth for how a
|
||||
score becomes a decision and how safety keywords are read and projected.
|
||||
|
||||
Extracted from (donor_ledger.yaml):
|
||||
- pa-nsfw-filter — photo_analyzer._rec_has_nsfw / filter_nsfw_tagged
|
||||
- nt-exif-marks — nsfwtag.exif.read_marks / read_tagged (keyword parsing)
|
||||
- nsfwtag thresholds (DEFAULT_THRESHOLD / DEFAULT_REVIEW_MIN)
|
||||
|
||||
Behavior changes vs the donors (required by stable identity + shared jobs):
|
||||
- Decisions and scores key on ``asset_id`` and live in the database, not in a
|
||||
path-keyed ``nsfw_scores.csv`` (the CSV is a one-time migration import).
|
||||
- Safety keywords are **mutually exclusive**: writing a decision adds the chosen
|
||||
``sfw``/``nsfw`` keyword and removes the opposite. The donor only ever added
|
||||
``nsfw``; there was no ``sfw`` keyword and no removal.
|
||||
- Model scoring runs behind ``integrations.nsfw_model.NsfwModel`` as a shared job,
|
||||
not inline in a CLI pass.
|
||||
- No module here imports the archived CLIs (``nsfwtag`` / ``photo_analyzer``).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Iterable, Mapping
|
||||
|
||||
NSFW = "nsfw"
|
||||
SFW = "sfw"
|
||||
REVIEW_REQUIRED = "review_required"
|
||||
|
||||
DEFAULT_THRESHOLD = 0.6 # AdamCodd model sweet spot (donor DEFAULT_THRESHOLD)
|
||||
DEFAULT_REVIEW_MIN = 0.2 # below this is confidently SFW (donor DEFAULT_REVIEW_MIN)
|
||||
|
||||
|
||||
def classify(
|
||||
score: float, *, threshold: float = DEFAULT_THRESHOLD, review_min: float = DEFAULT_REVIEW_MIN
|
||||
) -> str:
|
||||
"""Suggested decision from a model score. A human confirms before it is durable.
|
||||
|
||||
``>= threshold`` → ``nsfw``; ``>= review_min`` → ``review_required``; else ``sfw``.
|
||||
"""
|
||||
if score >= threshold:
|
||||
return NSFW
|
||||
if score >= review_min:
|
||||
return REVIEW_REQUIRED
|
||||
return SFW
|
||||
|
||||
|
||||
def normalize_keywords(values: Iterable | str | None) -> set[str]:
|
||||
"""Normalize an EXIF Keywords/Subject value (list or scalar) to a lowercased set."""
|
||||
if values is None:
|
||||
return set()
|
||||
if isinstance(values, str):
|
||||
values = [values]
|
||||
return {str(v).strip().lower() for v in values}
|
||||
|
||||
|
||||
def has_nsfw(keywords: Iterable[str]) -> bool:
|
||||
"""True if the ``nsfw`` safety keyword is present (donor _rec_has_nsfw)."""
|
||||
return NSFW in {str(k).strip().lower() for k in keywords}
|
||||
|
||||
|
||||
def marks_from_keywords(keyword_sets: Mapping[str, Iterable[str]]) -> dict[str, set[str]]:
|
||||
"""Split ``{path: keywords}`` into ``{'nsfw': set, 'sfw': set}``.
|
||||
|
||||
They should be mutually exclusive; if a file carries both, ``nsfw`` wins so it
|
||||
never reads as safe (donor nsfwtag.exif.read_marks).
|
||||
"""
|
||||
nsfw, sfw = set(), set()
|
||||
for path, keywords in keyword_sets.items():
|
||||
lowered = {str(k).strip().lower() for k in keywords}
|
||||
if NSFW in lowered:
|
||||
nsfw.add(path)
|
||||
elif SFW in lowered:
|
||||
sfw.add(path)
|
||||
return {NSFW: nsfw, SFW: sfw}
|
||||
|
||||
|
||||
def partition_nsfw(
|
||||
keyword_sets: Mapping[str, Iterable[str]],
|
||||
) -> tuple[list[str], list[str]]:
|
||||
"""Split paths into ``(analyzable, skipped)`` — nsfw-tagged files skip cloud
|
||||
analysis (privacy gate; donor photo_analyzer.filter_nsfw_tagged)."""
|
||||
analyzable, skipped = [], []
|
||||
for path, keywords in keyword_sets.items():
|
||||
(skipped if has_nsfw(keywords) else analyzable).append(path)
|
||||
return analyzable, skipped
|
||||
|
||||
|
||||
def exif_projection(decision: str) -> dict[str, list[str]]:
|
||||
"""Keyword operations to make EXIF match a confirmed decision.
|
||||
|
||||
Enforces mutual exclusion: add the decision keyword, remove the opposite.
|
||||
Non-terminal decisions (review/unknown) touch nothing.
|
||||
"""
|
||||
if decision == NSFW:
|
||||
return {"add": [NSFW], "remove": [SFW]}
|
||||
if decision == SFW:
|
||||
return {"add": [SFW], "remove": [NSFW]}
|
||||
return {"add": [], "remove": []}
|
||||
Reference in New Issue
Block a user