"""Section photo policy: enable uploads only where the tenant's own RAG library shows a trend. 1. **Explicit overrides** — ``SECTION_PHOTO_POLICY_OVERRIDES_JSON``. 2. **Tenant indexed uploads** — completed ``.pdf`` / ``.docx`` for this tenant (same files used for tenant RAG) are scanned for embedded images under each RICS section heading. A section only gets ``OPTIONAL_IMAGE`` / ``REQUIRES_IMAGE`` when enough uploads contain that heading **and** the configured fraction of those uploads include at least one image in that section (default: 100%). No letter-based heuristics. Knowledge-base exemplar folders are not used for product photo policy. """ from __future__ import annotations import json import logging from dataclasses import dataclass from enum import StrEnum from sqlalchemy.ext.asyncio import AsyncSession from app.config import settings from app.services.photo_policy_corpus import ( CorpusPhotoStats, SectionPhotoStats, corpus_ratio_for_section, get_or_compute_tenant_photo_stats, ) from app.templates.registry import get_survey_pack logger = logging.getLogger(__name__) class PhotoPolicy(StrEnum): requires_image = "REQUIRES_IMAGE" optional_image = "OPTIONAL_IMAGE" no_image_needed = "NO_IMAGE_NEEDED" POLICY_SOURCE_OVERRIDE = "override" POLICY_SOURCE_TENANT_LIBRARY = "tenant_library" POLICY_SOURCE_TENANT_INSUFFICIENT = "tenant_insufficient" POLICY_SOURCE_TENANT_NO_DOCUMENTS = "tenant_no_documents" POLICY_SOURCE_DATA_DRIVEN_DISABLED = "data_driven_disabled" def _parse_photo_policy_overrides() -> dict[str, PhotoPolicy]: raw = (settings.section_photo_policy_overrides_json or "").strip() if not raw: return {} try: obj = json.loads(raw) except json.JSONDecodeError as exc: logger.warning("section_photo_policy_overrides_json is not valid JSON: %s", exc) return {} if not isinstance(obj, dict): return {} out: dict[str, PhotoPolicy] = {} for k, v in obj.items(): code = str(k).strip().upper() if not code: continue if isinstance(v, str): s = v.strip().upper().replace(" ", "_").replace("-", "_") matched: PhotoPolicy | None = None for p in PhotoPolicy: if s == p.value or s == p.name.upper(): matched = p break if matched is not None: out[code] = matched return out @dataclass(frozen=True, slots=True) class SectionPhotoPolicy: code: str policy: PhotoPolicy reason: str source: str def _tenant_row_to_policy( row: SectionPhotoStats | None, *, min_examples: int, consensus: float ) -> tuple[PhotoPolicy, str]: """Map aggregated counts to a policy using ``SECTION_PHOTO_POLICY_TENANT_CONSENSUS_RATIO``.""" if row is None or row.files_seen < min_examples: return ( PhotoPolicy.no_image_needed, ( f"This RICS section was not detected in at least {min_examples} of your indexed PDF/DOCX " "uploads (or appears without enough matching evidence), so photo upload stays disabled." ), ) ratio = float(row.files_with_photos) / float(row.files_seen) if row.files_seen else 0.0 if ratio + 1e-12 < consensus: return ( PhotoPolicy.no_image_needed, ( f"Your indexed uploads show images in this section in {row.files_with_photos}/{row.files_seen} " f"cases ({round(ratio * 100)}%). Photo upload is enabled only when at least " f"{round(consensus * 100)}% of uploads that include this section also contain an image there." ), ) if ratio >= 1.0 - 1e-12: return ( PhotoPolicy.requires_image, ( f"All {row.files_seen} indexed upload(s) that include this section contain at least one image " "in that section — matching your library trend." ), ) return ( PhotoPolicy.optional_image, ( f"Photos appear in this section in {row.files_with_photos}/{row.files_seen} indexed uploads " f"({round(ratio * 100)}%), meeting the ≥{round(consensus * 100)}% threshold." ), ) def _source_for_tenant_policy(policy: PhotoPolicy, stats: CorpusPhotoStats) -> str: if stats.files_total <= 0: return POLICY_SOURCE_TENANT_NO_DOCUMENTS if policy == PhotoPolicy.no_image_needed: return POLICY_SOURCE_TENANT_INSUFFICIENT return POLICY_SOURCE_TENANT_LIBRARY async def classify_section_photo_policy_async( db: AsyncSession | None, tenant_id: str, code: str, *, survey_level: int | None = None, ) -> tuple[PhotoPolicy, str]: """Resolve policy for one section using overrides, then tenant RAG library scan.""" c = (code or "").strip() if not c: return PhotoPolicy.no_image_needed, "No section code." if db is None: return PhotoPolicy.no_image_needed, "Photo policy skipped (no database session)." overrides = _parse_photo_policy_overrides() key = c.upper() if key in overrides: pol = overrides[key] return pol, f"Explicit SECTION_PHOTO_POLICY_OVERRIDES_JSON entry for {key!r} → {pol.value}." if not settings.section_photo_policy_data_driven: return ( PhotoPolicy.no_image_needed, "Tenant photo scan is disabled (SECTION_PHOTO_POLICY_DATA_DRIVEN=false) and this section " "has no JSON override.", ) try: stats = await get_or_compute_tenant_photo_stats(db, tenant_id, survey_level) except Exception as exc: # noqa: BLE001 logger.debug("Tenant photo stats failed: %s", exc) return ( PhotoPolicy.no_image_needed, "Could not scan indexed uploads for photo placement; no override is set for this section.", ) min_ex = int(settings.section_photo_policy_min_examples) consensus = float(settings.section_photo_policy_tenant_consensus_ratio) row = corpus_ratio_for_section(stats, c) pol, reason = _tenant_row_to_policy(row, min_examples=min_ex, consensus=consensus) return pol, reason async def get_photo_policy_for_tenant_async( db: AsyncSession, tenant_id: str, survey_level: int | None, ) -> tuple[list[SectionPhotoPolicy], CorpusPhotoStats]: """Per-section policies plus raw tenant stats (for API metadata).""" pack = get_survey_pack(survey_level) overrides = _parse_photo_policy_overrides() min_ex = int(settings.section_photo_policy_min_examples) consensus = float(settings.section_photo_policy_tenant_consensus_ratio) if not settings.section_photo_policy_data_driven: items = [ SectionPhotoPolicy( code=code, policy=PhotoPolicy.no_image_needed, reason=( "SECTION_PHOTO_POLICY_DATA_DRIVEN is false. Enable tenant scanning or set " "SECTION_PHOTO_POLICY_OVERRIDES_JSON." ), source=POLICY_SOURCE_DATA_DRIVEN_DISABLED, ) for code in pack.section_order ] return items, CorpusPhotoStats(created_at_unix=0, files_total=0, by_code={}) try: stats = await get_or_compute_tenant_photo_stats(db, tenant_id, survey_level) except Exception as exc: # noqa: BLE001 logger.exception("Tenant photo policy scan failed: %s", exc) stats = CorpusPhotoStats(created_at_unix=0, files_total=0, by_code={}) out: list[SectionPhotoPolicy] = [] for code in pack.section_order: key = (code or "").strip().upper() if key in overrides: pol = overrides[key] out.append( SectionPhotoPolicy( code=code, policy=pol, reason=f"Explicit SECTION_PHOTO_POLICY_OVERRIDES_JSON for {key!r} → {pol.value}.", source=POLICY_SOURCE_OVERRIDE, ) ) continue if stats.files_total <= 0: out.append( SectionPhotoPolicy( code=code, policy=PhotoPolicy.no_image_needed, reason=( "No completed PDF/DOCX uploads were found for this tenant (or files are missing on disk). " "Upload and ingest reference surveys first; photo rules are derived only from those files." ), source=POLICY_SOURCE_TENANT_NO_DOCUMENTS, ) ) continue row = corpus_ratio_for_section(stats, code) pol, reason = _tenant_row_to_policy(row, min_examples=min_ex, consensus=consensus) src = _source_for_tenant_policy(pol, stats) out.append(SectionPhotoPolicy(code=code, policy=pol, reason=reason, source=src)) return out, stats def photo_policy_configuration_incomplete( stats: CorpusPhotoStats, items: list[SectionPhotoPolicy], ) -> bool: """True when the tenant library is too small to establish cross-document trends, or scanning is off.""" if not settings.section_photo_policy_data_driven: return any(x.source == POLICY_SOURCE_DATA_DRIVEN_DISABLED for x in items) min_ex = int(settings.section_photo_policy_min_examples) if stats.files_total < min_ex: return True return False