Spaces:
Sleeping
Sleeping
- Added support for RICS survey levels (1, 2, 3) in document uploads and reports, allowing for better tier management and retrieval filtering.
b76f199 | """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 | |
| 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 | |