RandomZ / app /services /photo_policy.py
StormShadow308's picture
- Added support for RICS survey levels (1, 2, 3) in document uploads and reports, allowing for better tier management and retrieval filtering.
b76f199
Raw
History Blame Contribute Delete
9.52 kB
"""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