From d5ee04db5bc983ddb6bebeacceaf0ac43d199c28 Mon Sep 17 00:00:00 2001 From: Christian Date: Thu, 30 Jul 2026 12:17:57 +0200 Subject: [PATCH 1/4] Add Coolify customer Docker image, compose, and nginx auth Unified REPORT_ANALYST_RUNTIME image, storage volume compose, and basic-auth sidecar for Actwyser-style customer deploys. --- Dockerfile | 37 +++++-- INSTALL.md | 12 +- README.md | 1 + docker-compose.coolify-customer.yml | 46 ++++++++ docker-entrypoint.sh | 29 +++++ docker/nginx-basic-auth/Dockerfile | 9 ++ docker/nginx-basic-auth/entrypoint.sh | 45 ++++++++ docs/COOLIFY.md | 44 -------- docs/DOCKER-DEPLOY.md | 74 +++++++++++++ report_analyst/core/file_storage.py | 32 +++--- report_analyst/core/report_data_client.py | 8 +- report_analyst/core/service.py | 43 ++++++-- .../Dockerfile | 3 +- .../Dockerfile | 9 +- report_analyst_enterprise/README.md | 50 +++++++++ .../Dockerfile | 4 +- tests/test_report_upload_dir.py | 103 ++++++++++++++++++ 17 files changed, 462 insertions(+), 87 deletions(-) create mode 100644 docker-compose.coolify-customer.yml create mode 100644 docker-entrypoint.sh create mode 100644 docker/nginx-basic-auth/Dockerfile create mode 100755 docker/nginx-basic-auth/entrypoint.sh delete mode 100644 docs/COOLIFY.md create mode 100644 docs/DOCKER-DEPLOY.md rename Dockerfile.api => report_analyst_api/Dockerfile (85%) rename Dockerfile.enterprise => report_analyst_enterprise/Dockerfile (71%) create mode 100644 report_analyst_enterprise/README.md rename Dockerfile.jobs => report_analyst_jobs/Dockerfile (85%) create mode 100644 tests/test_report_upload_dir.py diff --git a/Dockerfile b/Dockerfile index 45022998..4964e372 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,9 +1,10 @@ -# Core image: report_analyst Streamlit app (RPL only, no enterprise deps) +# Unified Report Analyst image — same codebase, runtime selected via REPORT_ANALYST_RUNTIME: +# core — Streamlit only (customer / standalone) +# enterprise — Streamlit + FastAPI + NATS worker (Climate+Tech internal) FROM python:3.12-slim WORKDIR /app -# System deps: PyMuPDF (poppler), chroma-hnswlib (build), sqlite-vss (build from source on ARM), healthcheck (curl) RUN apt-get update && apt-get install -y --no-install-recommends \ build-essential \ cmake \ @@ -13,18 +14,40 @@ RUN apt-get update && apt-get install -y --no-install-recommends \ curl \ && rm -rf /var/lib/apt/lists/* -# Python dependencies (core only) +ENV OPENBLAS_NUM_THREADS=1 +ENV REPORT_ANALYST_RUNTIME=core +ENV STORAGE_PATH=/app/storage + COPY requirements.txt . RUN pip install --no-cache-dir -r requirements.txt -# Application code +COPY report_analyst_enterprise/requirements.txt report_analyst_enterprise/requirements.txt +RUN pip install --no-cache-dir -r report_analyst_enterprise/requirements.txt + +COPY report_analyst_api/requirements.txt report_analyst_api/requirements.txt +RUN pip install --no-cache-dir -r report_analyst_api/requirements.txt + +COPY report_analyst_search_backend/requirements.txt report_analyst_search_backend/requirements.txt +RUN pip install --no-cache-dir -r report_analyst_search_backend/requirements.txt + +RUN pip install --no-cache-dir "nats-py>=2.7.0" "aiohttp>=3.9.0" "pandas>=2.0.0" "numpy>=1.24.0" + COPY report_analyst/ report_analyst/ +COPY report_analyst_enterprise/ report_analyst_enterprise/ +COPY report_analyst_api/ report_analyst_api/ +COPY report_analyst_jobs/ report_analyst_jobs/ +COPY report_analyst_search_backend/ report_analyst_search_backend/ COPY prompts/ prompts/ COPY .streamlit/ .streamlit/ +COPY alembic.ini . +COPY alembic/ alembic/ + +COPY docker-entrypoint.sh /docker-entrypoint.sh +RUN chmod +x /docker-entrypoint.sh -EXPOSE 8080 +EXPOSE 8080 8001 -HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \ +HEALTHCHECK --interval=30s --timeout=10s --start-period=15s --retries=3 \ CMD curl -f http://localhost:8080/_stcore/health || exit 1 -ENTRYPOINT ["streamlit", "run", "report_analyst/streamlit_app.py", "--server.port=8080", "--server.address=0.0.0.0"] +ENTRYPOINT ["/docker-entrypoint.sh"] diff --git a/INSTALL.md b/INSTALL.md index bee104cc..14ca55a2 100644 --- a/INSTALL.md +++ b/INSTALL.md @@ -119,8 +119,14 @@ Two images are available: **core** (RPL, Streamlit only) and **enterprise** (cor # Core image (Streamlit app only) docker build -t report-analyst:core . -# Enterprise image (adds report_analyst_enterprise, Alembic) -docker build -f Dockerfile.enterprise -t report-analyst:enterprise . +# Enterprise image (report_analyst_enterprise module) +docker build -f report_analyst_enterprise/Dockerfile -t report-analyst:enterprise . + +# REST API (report_analyst_api module) +docker build -f report_analyst_api/Dockerfile -t report-analyst:api . + +# NATS jobs worker (report_analyst_jobs module) +docker build -f report_analyst_jobs/Dockerfile -t report-analyst:jobs . ``` On **Apple Silicon (ARM)** use `--platform linux/amd64` so `sqlite-vss` installs (no Linux ARM wheel): @@ -138,6 +144,8 @@ docker run -p 8080:8080 -e OPENAI_API_KEY=your_key -e DATABASE_URL=postgresql:// App is at `http://localhost:8080`. +For API, jobs worker, and platform integration (NATS, search backend, S3), see [`docs/DOCKER-DEPLOY.md`](docs/DOCKER-DEPLOY.md). + --- ## Usage Summary diff --git a/README.md b/README.md index b903cec3..7b6b6be5 100644 --- a/README.md +++ b/README.md @@ -520,6 +520,7 @@ The repository uses a **module-based licensing model**: | `report_analyst_api/` | FastAPI API module | **Climate+Tech Open License for Good** | | `report_analyst_jobs/` | Jobs, NATS, integration toolkit | **Climate+Tech Open License for Good** | | `report_analyst_search_backend/` | Search/upload backend integration | **Climate+Tech Open License for Good** | +| `report_analyst_enterprise/` | Enterprise edition (Postgres/pgvector, deploy) | **Climate+Tech Open License for Good** | The core analysis module `report_analyst/` is open source under the RPL (Reciprocal Public License). All other modules (API, jobs, search backend, etc.) are provided under the Climate+Tech Open License for Good, and can be dual-licensed for commercial or special use cases upon request. diff --git a/docker-compose.coolify-customer.yml b/docker-compose.coolify-customer.yml new file mode 100644 index 00000000..c4d1464b --- /dev/null +++ b/docker-compose.coolify-customer.yml @@ -0,0 +1,46 @@ +# Coolify customer deploy: Streamlit + persistent SQLite/vector storage (Heroku-style disk). +# Traefik routes to `report-analyst-auth:8080` when HTTP Basic Auth is enabled +# (set-app-http-basic-auth.sh), else `report-analyst:8080`. +# Auth uses nginx + Coolify app env HTTP_BASIC_AUTH_* (not Traefik label substitution). +services: + report-analyst-auth: + build: + context: ./docker/nginx-basic-auth + dockerfile: Dockerfile + environment: + HTTP_BASIC_AUTH_USERNAME: ${HTTP_BASIC_AUTH_USERNAME:-} + HTTP_BASIC_AUTH_PASSWORD: ${HTTP_BASIC_AUTH_PASSWORD:-} + UPSTREAM_HOST: report-analyst + UPSTREAM_PORT: "8080" + expose: + - "8080" + depends_on: + report-analyst: + condition: service_healthy + healthcheck: + test: ["CMD", "wget", "-q", "--spider", "http://localhost:8080/health"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 10s + + report-analyst: + build: + context: . + dockerfile: Dockerfile + environment: + REPORT_ANALYST_RUNTIME: ${REPORT_ANALYST_RUNTIME:-core} + STORAGE_PATH: /app/storage + volumes: + - storage-data:/app/storage + expose: + - "8080" + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:8080/_stcore/health"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 15s + +volumes: + storage-data: diff --git a/docker-entrypoint.sh b/docker-entrypoint.sh new file mode 100644 index 00000000..f2aaf131 --- /dev/null +++ b/docker-entrypoint.sh @@ -0,0 +1,29 @@ +#!/usr/bin/env bash +# Start processes based on REPORT_ANALYST_RUNTIME: +# core — Streamlit only (default, customer deployments) +# enterprise — Streamlit + FastAPI + NATS worker (internal Climate+Tech) +set -euo pipefail + +RUNTIME="${REPORT_ANALYST_RUNTIME:-core}" +pids=() + +cleanup() { + for pid in "${pids[@]}"; do + kill "$pid" 2>/dev/null || true + done +} +trap cleanup EXIT TERM INT + +if [ "$RUNTIME" = "enterprise" ]; then + echo "Starting enterprise runtime (Streamlit + API + NATS worker)" + python report_analyst_jobs/nats_integration.py worker & + pids+=($!) + uvicorn report_analyst_api.main:app --host 0.0.0.0 --port 8001 & + pids+=($!) +else + echo "Starting core runtime (Streamlit only)" +fi + +exec streamlit run report_analyst/streamlit_app.py \ + --server.port=8080 \ + --server.address=0.0.0.0 diff --git a/docker/nginx-basic-auth/Dockerfile b/docker/nginx-basic-auth/Dockerfile new file mode 100644 index 00000000..1cca57e7 --- /dev/null +++ b/docker/nginx-basic-auth/Dockerfile @@ -0,0 +1,9 @@ +FROM nginx:1.27-alpine + +RUN apk add --no-cache apache2-utils wget + +COPY entrypoint.sh /entrypoint.sh +RUN chmod +x /entrypoint.sh + +EXPOSE 8080 +ENTRYPOINT ["/entrypoint.sh"] diff --git a/docker/nginx-basic-auth/entrypoint.sh b/docker/nginx-basic-auth/entrypoint.sh new file mode 100755 index 00000000..87bff0a8 --- /dev/null +++ b/docker/nginx-basic-auth/entrypoint.sh @@ -0,0 +1,45 @@ +#!/bin/sh +# Optional HTTP Basic Auth in front of Streamlit. Credentials from Coolify app env +# (HTTP_BASIC_AUTH_USERNAME / HTTP_BASIC_AUTH_PASSWORD). When unset, proxies without auth. +set -eu + +UPSTREAM="${UPSTREAM_HOST:-report-analyst}:${UPSTREAM_PORT:-8080}" +# nginx default is 1m; Streamlit uploads use PUT /_stcore/upload_file/ (often multi-MB PDFs). +CLIENT_MAX_BODY_SIZE="${NGINX_CLIENT_MAX_BODY_SIZE:-200m}" +AUTH_FILE=/etc/nginx/auth.htpasswd +CONF=/etc/nginx/conf.d/default.conf + +if [ -n "${HTTP_BASIC_AUTH_USERNAME:-}" ] && [ -n "${HTTP_BASIC_AUTH_PASSWORD:-}" ]; then + htpasswd -nbB "$HTTP_BASIC_AUTH_USERNAME" "$HTTP_BASIC_AUTH_PASSWORD" > "$AUTH_FILE" + AUTH_DIRECTIVES="auth_basic \"Report Analyst\"; + auth_basic_user_file ${AUTH_FILE};" +else + rm -f "$AUTH_FILE" + AUTH_DIRECTIVES="" +fi + +cat > "$CONF" <`: + +```bash +HTTP_BASIC_AUTH_USERNAME=demo-user +HTTP_BASIC_AUTH_PASSWORD=... +``` + +Use `coolify-provisioning/scripts/set-app-http-basic-auth.sh` — see `REPORT-ANALYST-DEPLOY.md` § HTTP Basic Auth. Follows [Coolify Basic Auth](https://coolify.io/docs/knowledge-base/proxy/traefik/basic-auth) + [Custom Middlewares](https://coolify.io/docs/knowledge-base/proxy/traefik/custom-middlewares) (`coolify.traefik.middlewares` shorthand). + +Health: `GET /_stcore/health`. Unauthenticated requests return **401** when basic auth is enabled. + +When basic auth uses the **`report-analyst-auth` nginx sidecar**, set `NGINX_CLIENT_MAX_BODY_SIZE` (default `200m`) so PDF uploads via Streamlit `/_stcore/upload_file/` are not rejected with **413** (nginx’s built-in limit is 1 MB). + +Optional Postgres (enterprise module features in UI): + +```bash +docker run -p 8080:8080 \ + -e REPORT_ANALYST_RUNTIME=core \ + -e DATABASE_URL=postgresql://... \ + -e USE_ALEMBIC_MIGRATIONS=true \ + report-analyst +``` + +## Internal enterprise (Climate+Tech) + +```bash +docker run -p 8080:8080 -p 8001:8001 \ + -e REPORT_ANALYST_RUNTIME=enterprise \ + -e DATABASE_URL=postgresql://... \ + -e USE_BACKEND=true \ + -e USE_CENTRALIZED_LLM=true \ + -e NATS_URL=nats://nats:4222 \ + report-analyst +``` + +- Streamlit health: `GET /_stcore/health` +- API health: `GET /health` on port 8001 + +Licensed modules (`report_analyst_enterprise/`, `report_analyst_api/`, `report_analyst_jobs/`, `report_analyst_search_backend/`) ship in the same image; no separate container builds. + +See [`INSTALL.md`](../INSTALL.md) for local install without Docker. diff --git a/report_analyst/core/file_storage.py b/report_analyst/core/file_storage.py index d8e1eb8c..7e832c9a 100644 --- a/report_analyst/core/file_storage.py +++ b/report_analyst/core/file_storage.py @@ -66,7 +66,7 @@ def _init_table(self): """Initialize the stored_files table""" try: metadata = MetaData() - stored_files = Table( + Table( "stored_files", metadata, Column("id", String(36), primary_key=True), # UUID as string @@ -81,8 +81,8 @@ def _init_table(self): metadata.create_all(engine, checkfirst=True) logger.info("stored_files table initialized") except Exception as e: - logger.error(f"Error initializing stored_files table: {str(e)}") - raise FileStorageError(f"Failed to initialize file storage table: {str(e)}") + logger.error(f"Error initializing stored_files table: {e!s}") + raise FileStorageError(f"Failed to initialize file storage table: {e!s}") from e def store_file(self, file_bytes: bytes, filename: str, content_type: Optional[str] = None) -> str: """ @@ -124,8 +124,8 @@ def store_file(self, file_bytes: bytes, filename: str, content_type: Optional[st logger.info(f"Stored file {filename} (ID: {file_id}, size: {file_size} bytes) in PostgreSQL") return file_id except Exception as e: - logger.error(f"Error storing file in PostgreSQL: {str(e)}") - raise FileStorageError(f"Failed to store file: {str(e)}") + logger.error(f"Error storing file in PostgreSQL: {e!s}") + raise FileStorageError(f"Failed to store file: {e!s}") from e def retrieve_file(self, file_id: str) -> Optional[bytes]: """ @@ -147,8 +147,8 @@ def retrieve_file(self, file_id: str) -> Optional[bytes]: return bytes(row[0]) return None except Exception as e: - logger.error(f"Error retrieving file {file_id} from PostgreSQL: {str(e)}") - raise FileStorageError(f"Failed to retrieve file: {str(e)}") + logger.error(f"Error retrieving file {file_id} from PostgreSQL: {e!s}") + raise FileStorageError(f"Failed to retrieve file: {e!s}") from e def get_file_info(self, file_id: str) -> Optional[dict]: """ @@ -180,7 +180,7 @@ def get_file_info(self, file_id: str) -> Optional[dict]: } return None except Exception as e: - logger.error(f"Error getting file info for {file_id}: {str(e)}") + logger.error(f"Error getting file info for {file_id}: {e!s}") return None def delete_file(self, file_id: str) -> bool: @@ -200,7 +200,7 @@ def delete_file(self, file_id: str) -> bool: conn.commit() return result.rowcount > 0 except Exception as e: - logger.error(f"Error deleting file {file_id}: {str(e)}") + logger.error(f"Error deleting file {file_id}: {e!s}") return False def find_by_filename(self, filename: str) -> Optional[str]: @@ -222,20 +222,24 @@ def find_by_filename(self, filename: str) -> Optional[str]: return row[0] return None except Exception as e: - logger.error(f"Error finding file by filename {filename}: {str(e)}") + logger.error(f"Error finding file by filename {filename}: {e!s}") return None - def save_to_temp(self, file_id: str, temp_dir: Path = Path("temp")) -> Optional[str]: + def save_to_temp(self, file_id: str, temp_dir: Optional[Path] = None) -> Optional[str]: """ Retrieve file from PostgreSQL and save to temporary directory. Args: file_id: Unique identifier for the stored file - temp_dir: Directory to save the file to + temp_dir: Directory to save the file to (defaults to get_report_upload_dir()) Returns: Path to the temporary file, or None if not found """ + if temp_dir is None: + from report_analyst.core.service import get_report_upload_dir + + temp_dir = get_report_upload_dir() try: file_info = self.get_file_info(file_id) if not file_info: @@ -256,7 +260,7 @@ def save_to_temp(self, file_id: str, temp_dir: Path = Path("temp")) -> Optional[ logger.info(f"Retrieved file {file_id} to {temp_path}") return str(temp_path) except Exception as e: - logger.error(f"Error saving file {file_id} to temp: {str(e)}") + logger.error(f"Error saving file {file_id} to temp: {e!s}") return None @@ -287,5 +291,5 @@ def get_file_storage( return None except Exception as e: - logger.warning(f"PostgreSQL file storage not available: {str(e)}") + logger.warning(f"PostgreSQL file storage not available: {e!s}") return None diff --git a/report_analyst/core/report_data_client.py b/report_analyst/core/report_data_client.py index 529672d2..55b26bdd 100644 --- a/report_analyst/core/report_data_client.py +++ b/report_analyst/core/report_data_client.py @@ -71,7 +71,11 @@ def resolve_to_http_url(self) -> Optional[str]: class ReportDataClient: """Unified client for sustainability report data from multiple sources""" - def __init__(self, temp_dir: Path = Path("temp")): + def __init__(self, temp_dir: Optional[Path] = None): + if temp_dir is None: + from report_analyst.core.service import get_report_upload_dir + + temp_dir = get_report_upload_dir() self.temp_dir = temp_dir self._backend_clients: Dict[str, Any] = {} # Cache backend clients by host @@ -125,7 +129,7 @@ def _list_local_reports(self) -> List[ReportResource]: logger.warning(f"Skipping {file.name}: PDF has 0 pages, likely invalid") continue except Exception as e: - logger.warning(f"Skipping {file.name}: cannot open as PDF ({str(e)})") + logger.warning(f"Skipping {file.name}: cannot open as PDF ({e!s})") continue # Create file:// URI diff --git a/report_analyst/core/service.py b/report_analyst/core/service.py index f60df4ac..ce8a9a5e 100644 --- a/report_analyst/core/service.py +++ b/report_analyst/core/service.py @@ -10,6 +10,7 @@ import json import logging import os +from pathlib import Path from typing import Any, Dict, List, Optional from sqlalchemy import text @@ -34,16 +35,40 @@ def get_questions_for_api(question_set_id: str) -> Dict[str, Any]: return loader.get_questions(question_set_id) -def get_report_temp_dir(): - """Return the directory used for local report PDFs (async uploads and report_path). Same as API _resolve_analyze_path.""" - from pathlib import Path +def get_report_upload_dir(): + """Return the directory for uploaded report PDFs. + + Precedence: + 1. REPORT_ANALYST_UPLOAD_DIR (explicit) + 2. REPORT_ANALYST_TEMP (legacy API name) + 3. TEMP_DIR (legacy config name) + 4. {STORAGE_PATH}/uploads when STORAGE_PATH is set (Coolify volume) + 5. {project_root}/temp (local dev default) + + Creates the directory if missing. + """ + for key in ("REPORT_ANALYST_UPLOAD_DIR", "REPORT_ANALYST_TEMP", "TEMP_DIR"): + path = os.environ.get(key) + if path: + upload_dir = Path(os.path.realpath(path)) + upload_dir.mkdir(parents=True, exist_ok=True) + return upload_dir + + storage_path = os.environ.get("STORAGE_PATH") + if storage_path: + upload_dir = Path(os.path.realpath(storage_path)) / "uploads" + upload_dir.mkdir(parents=True, exist_ok=True) + return upload_dir - path = os.environ.get("REPORT_ANALYST_TEMP") - if path: - return Path(os.path.realpath(path)) - # Default: project/temp (relative to report_analyst package parent) root = Path(__file__).resolve().parent.parent.parent - return root / "temp" + upload_dir = root / "temp" + upload_dir.mkdir(parents=True, exist_ok=True) + return upload_dir + + +def get_report_temp_dir(): + """Alias for :func:`get_report_upload_dir` (historical name used by the API).""" + return get_report_upload_dir() def get_reports_for_api(question_set_id: Optional[str] = None) -> List[Dict[str, Any]]: @@ -81,7 +106,7 @@ def get_reports_for_api(question_set_id: Optional[str] = None) -> List[Dict[str, def get_analysis_keys_for_api() -> List[Dict[str, Any]]: - """Return full report × question_set pairs for selectors. + """Return full report x question_set pairs for selectors. This endpoint is intentionally not limited to stored cache keys so UI selectors can present all available combinations. diff --git a/Dockerfile.api b/report_analyst_api/Dockerfile similarity index 85% rename from Dockerfile.api rename to report_analyst_api/Dockerfile index 14f5a625..44124aba 100644 --- a/Dockerfile.api +++ b/report_analyst_api/Dockerfile @@ -1,4 +1,5 @@ -# FastAPI service for ct-platform /report-analyst-api proxy (port 8001) +# Deprecated for deploy — use repository root Dockerfile + REPORT_ANALYST_RUNTIME=enterprise. +# FastAPI service (report_analyst_api module) FROM python:3.12-slim WORKDIR /app diff --git a/Dockerfile.enterprise b/report_analyst_enterprise/Dockerfile similarity index 71% rename from Dockerfile.enterprise rename to report_analyst_enterprise/Dockerfile index 763972d7..64abc2b8 100644 --- a/Dockerfile.enterprise +++ b/report_analyst_enterprise/Dockerfile @@ -1,9 +1,9 @@ -# Enterprise image: core Streamlit app + report_analyst_enterprise (Postgres/pgvector) +# Deprecated for deploy — use repository root Dockerfile + REPORT_ANALYST_RUNTIME. +# Enterprise Streamlit image (report_analyst_enterprise module + core RPL app) FROM python:3.12-slim WORKDIR /app -# System deps: PyMuPDF (poppler), chroma-hnswlib (build), sqlite-vss (build from source on ARM), healthcheck (curl) RUN apt-get update && apt-get install -y --no-install-recommends \ build-essential \ cmake \ @@ -13,21 +13,18 @@ RUN apt-get update && apt-get install -y --no-install-recommends \ curl \ && rm -rf /var/lib/apt/lists/* -# Core Python dependencies COPY requirements.txt . RUN pip install --no-cache-dir -r requirements.txt -# Enterprise Python dependencies COPY report_analyst_enterprise/requirements.txt report_analyst_enterprise/requirements.txt RUN pip install --no-cache-dir -r report_analyst_enterprise/requirements.txt -# Application code (core + enterprise) COPY report_analyst/ report_analyst/ COPY report_analyst_enterprise/ report_analyst_enterprise/ +COPY report_analyst_search_backend/ report_analyst_search_backend/ COPY prompts/ prompts/ COPY .streamlit/ .streamlit/ -# Alembic for migrations (when USE_ALEMBIC_MIGRATIONS=true and DATABASE_URL set) COPY alembic.ini . COPY alembic/ alembic/ diff --git a/report_analyst_enterprise/README.md b/report_analyst_enterprise/README.md new file mode 100644 index 00000000..b99d4f24 --- /dev/null +++ b/report_analyst_enterprise/README.md @@ -0,0 +1,50 @@ +# Report Analyst Enterprise Module + +Enterprise edition features for Open Sustainability Analyst, licensed under the **Climate+Tech Open License for Good** (see [LICENSE](LICENSE)). + +This module adds: + +- **PostgreSQL + pgvector** support (`database/`) +- **Enterprise Streamlit image** ([Dockerfile](Dockerfile)) — core app plus this module +- Optional **search-backend integration** UI hooks (via core Streamlit + `report_analyst_search_backend/`) + +## Docker + +Deploy from the **repository root** using the unified image. Set `REPORT_ANALYST_RUNTIME`: + +- `core` — Streamlit only (customer / standalone) +- `enterprise` — Streamlit + API + NATS worker (Climate+Tech internal) + +See [`docs/DOCKER-DEPLOY.md`](../docs/DOCKER-DEPLOY.md). + +```bash +docker build -t report-analyst . +docker run -p 8080:8080 -p 8001:8001 \ + -e REPORT_ANALYST_RUNTIME=enterprise \ + -e OPENAI_API_KEY=your_key \ + -e DATABASE_URL=postgresql://user:pass@host:5432/db \ + -e USE_ALEMBIC_MIGRATIONS=true \ + report-analyst +``` + +## Platform integration (search backend + NATS) + +For production (upload, chunking, async analysis), use `REPORT_ANALYST_RUNTIME=enterprise` with: + +```bash +BACKEND_URL=https://your-search-backend.example.com +USE_BACKEND=true +USE_CENTRALIZED_LLM=true +USE_DATA_LAKE=true + +NATS_URL=nats://your-nats-host:4222 +NATS_TOKEN=your_token +``` + +See [`report_analyst_jobs/README.md`](../report_analyst_jobs/README.md) and [`report_analyst_search_backend/`](../report_analyst_search_backend/) for NATS and upload details. + +## License + +**Climate+Tech Open License for Good** — research, educational, and non-commercial use. Commercial and dual licensing: contact [Climate+Tech](https://climateandtech.com/en/climate-ai-solutions/opensustainability-analysis-framework). + +The core analysis engine in `report_analyst/` remains under the **RPL**. diff --git a/Dockerfile.jobs b/report_analyst_jobs/Dockerfile similarity index 85% rename from Dockerfile.jobs rename to report_analyst_jobs/Dockerfile index 1285343e..000e5aa5 100644 --- a/Dockerfile.jobs +++ b/report_analyst_jobs/Dockerfile @@ -1,4 +1,5 @@ -# NATS analysis worker (document.ready → report analyst jobs) +# Deprecated for deploy — use repository root Dockerfile + REPORT_ANALYST_RUNTIME=enterprise. +# NATS jobs worker (report_analyst_jobs module) FROM python:3.12-slim WORKDIR /app @@ -19,7 +20,6 @@ RUN pip install --no-cache-dir -r requirements.txt COPY report_analyst_search_backend/requirements.txt report_analyst_search_backend/requirements.txt RUN pip install --no-cache-dir -r report_analyst_search_backend/requirements.txt -# Jobs deps (installed from repo; omit report-analyst PyPI pin) RUN pip install --no-cache-dir "nats-py>=2.7.0" "aiohttp>=3.9.0" "pydantic>=2.5.0" "pandas>=2.0.0" "numpy>=1.24.0" COPY report_analyst/ report_analyst/ diff --git a/tests/test_report_upload_dir.py b/tests/test_report_upload_dir.py new file mode 100644 index 00000000..fb5862cf --- /dev/null +++ b/tests/test_report_upload_dir.py @@ -0,0 +1,103 @@ +"""Tests for report PDF upload directory resolution.""" + +import pytest + +from report_analyst.core.report_data_client import ReportDataClient +from report_analyst.core.service import get_report_temp_dir, get_report_upload_dir + + +@pytest.fixture(autouse=True) +def clear_upload_dir_env(monkeypatch): + """Isolate upload-dir env for each test.""" + for key in ( + "REPORT_ANALYST_UPLOAD_DIR", + "REPORT_ANALYST_TEMP", + "TEMP_DIR", + "STORAGE_PATH", + ): + monkeypatch.delenv(key, raising=False) + + +def test_upload_dir_defaults_to_project_temp(): + upload_dir = get_report_upload_dir() + assert upload_dir.name == "temp" + assert upload_dir.is_dir() + + +def test_upload_dir_uses_storage_path_uploads_subdir(tmp_path, monkeypatch): + storage = tmp_path / "storage" + monkeypatch.setenv("STORAGE_PATH", str(storage)) + + upload_dir = get_report_upload_dir() + + assert upload_dir == storage / "uploads" + assert upload_dir.is_dir() + + +def test_report_analyst_upload_dir_overrides_storage_path(tmp_path, monkeypatch): + custom = tmp_path / "custom-uploads" + monkeypatch.setenv("STORAGE_PATH", str(tmp_path / "storage")) + monkeypatch.setenv("REPORT_ANALYST_UPLOAD_DIR", str(custom)) + + upload_dir = get_report_upload_dir() + + assert upload_dir == custom.resolve() + assert upload_dir.is_dir() + + +def test_legacy_report_analyst_temp_still_works(tmp_path, monkeypatch): + custom = tmp_path / "legacy-temp" + monkeypatch.setenv("REPORT_ANALYST_TEMP", str(custom)) + + upload_dir = get_report_upload_dir() + + assert upload_dir == custom.resolve() + + +def test_temp_dir_legacy_env(tmp_path, monkeypatch): + custom = tmp_path / "config-temp" + monkeypatch.setenv("STORAGE_PATH", str(tmp_path / "storage")) + monkeypatch.setenv("TEMP_DIR", str(custom)) + + upload_dir = get_report_upload_dir() + + assert upload_dir == custom.resolve() + + +def test_get_report_temp_dir_is_alias(tmp_path, monkeypatch): + storage = tmp_path / "storage" + monkeypatch.setenv("STORAGE_PATH", str(storage)) + + assert get_report_temp_dir() == get_report_upload_dir() + assert get_report_temp_dir() == storage / "uploads" + + +def test_report_data_client_default_uses_upload_dir(tmp_path, monkeypatch): + storage = tmp_path / "storage" + monkeypatch.setenv("STORAGE_PATH", str(storage)) + + client = ReportDataClient() + + assert client.temp_dir == get_report_upload_dir() + assert client.temp_dir == storage / "uploads" + + +_MINIMAL_PDF = ( + b"%PDF-1.4\n%Test PDF\n1 0 obj\n<< /Type /Catalog /Pages 2 0 R >>\nendobj\n" + b"2 0 obj\n<< /Type /Pages /Kids [3 0 R] /Count 1 >>\nendobj\n" + b"3 0 obj\n<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] >>\nendobj\n" + b"xref\n0 4\ntrailer\n<< /Size 4 /Root 1 0 R >>\nstartxref\n100\n%%EOF" +) + + +def test_report_data_client_lists_pdfs_under_storage_uploads(tmp_path, monkeypatch): + pytest.importorskip("fitz") + storage = tmp_path / "storage" + uploads = storage / "uploads" + uploads.mkdir(parents=True) + (uploads / "report.pdf").write_bytes(_MINIMAL_PDF) + monkeypatch.setenv("STORAGE_PATH", str(storage)) + + names = [r.name for r in ReportDataClient().list_reports()] + + assert "report.pdf" in names From 0af0523e9acca4683e00bf47c2fd0d2e2b964167 Mon Sep 17 00:00:00 2001 From: Christian Date: Thu, 20 Aug 2026 12:15:42 +0200 Subject: [PATCH 2/4] Narrow BLE001 exception catches and cover error paths --- report_analyst/core/file_storage.py | 17 ++++--- report_analyst/core/report_data_client.py | 4 +- report_analyst/core/service.py | 9 ++-- tests/test_file_storage_errors.py | 61 +++++++++++++++++++++++ tests/test_report_data_client.py | 9 ++++ tests/test_service_api_errors.py | 41 +++++++++++++++ 6 files changed, 127 insertions(+), 14 deletions(-) create mode 100644 tests/test_file_storage_errors.py create mode 100644 tests/test_service_api_errors.py diff --git a/report_analyst/core/file_storage.py b/report_analyst/core/file_storage.py index 7e832c9a..d35ed46a 100644 --- a/report_analyst/core/file_storage.py +++ b/report_analyst/core/file_storage.py @@ -24,6 +24,7 @@ Text, text, ) +from sqlalchemy.exc import SQLAlchemyError from .database_manager import DatabaseManager @@ -80,7 +81,7 @@ def _init_table(self): engine = self.db_manager.get_engine() metadata.create_all(engine, checkfirst=True) logger.info("stored_files table initialized") - except Exception as e: + except SQLAlchemyError as e: logger.error(f"Error initializing stored_files table: {e!s}") raise FileStorageError(f"Failed to initialize file storage table: {e!s}") from e @@ -123,7 +124,7 @@ def store_file(self, file_bytes: bytes, filename: str, content_type: Optional[st logger.info(f"Stored file {filename} (ID: {file_id}, size: {file_size} bytes) in PostgreSQL") return file_id - except Exception as e: + except SQLAlchemyError as e: logger.error(f"Error storing file in PostgreSQL: {e!s}") raise FileStorageError(f"Failed to store file: {e!s}") from e @@ -146,7 +147,7 @@ def retrieve_file(self, file_id: str) -> Optional[bytes]: if row: return bytes(row[0]) return None - except Exception as e: + except SQLAlchemyError as e: logger.error(f"Error retrieving file {file_id} from PostgreSQL: {e!s}") raise FileStorageError(f"Failed to retrieve file: {e!s}") from e @@ -179,7 +180,7 @@ def get_file_info(self, file_id: str) -> Optional[dict]: "created_at": row[3], } return None - except Exception as e: + except SQLAlchemyError as e: logger.error(f"Error getting file info for {file_id}: {e!s}") return None @@ -199,7 +200,7 @@ def delete_file(self, file_id: str) -> bool: result = conn.execute(query, {"file_id": file_id}) conn.commit() return result.rowcount > 0 - except Exception as e: + except SQLAlchemyError as e: logger.error(f"Error deleting file {file_id}: {e!s}") return False @@ -221,7 +222,7 @@ def find_by_filename(self, filename: str) -> Optional[str]: if row: return row[0] return None - except Exception as e: + except SQLAlchemyError as e: logger.error(f"Error finding file by filename {filename}: {e!s}") return None @@ -259,7 +260,7 @@ def save_to_temp(self, file_id: str, temp_dir: Optional[Path] = None) -> Optiona logger.info(f"Retrieved file {file_id} to {temp_path}") return str(temp_path) - except Exception as e: + except (OSError, FileStorageError) as e: logger.error(f"Error saving file {file_id} to temp: {e!s}") return None @@ -290,6 +291,6 @@ def get_file_storage( return PostgreSQLFileStorage(database_url) return None - except Exception as e: + except FileStorageError as e: logger.warning(f"PostgreSQL file storage not available: {e!s}") return None diff --git a/report_analyst/core/report_data_client.py b/report_analyst/core/report_data_client.py index 55b26bdd..9296230a 100644 --- a/report_analyst/core/report_data_client.py +++ b/report_analyst/core/report_data_client.py @@ -128,7 +128,7 @@ def _list_local_reports(self) -> List[ReportResource]: if page_count == 0: logger.warning(f"Skipping {file.name}: PDF has 0 pages, likely invalid") continue - except Exception as e: + except (OSError, RuntimeError, ValueError) as e: logger.warning(f"Skipping {file.name}: cannot open as PDF ({e!s})") continue @@ -155,7 +155,7 @@ def _list_backend_reports(self, config: Any) -> List[ReportResource]: backend_service = BackendService(config) return backend_service.list_reports() - except Exception as e: + except Exception as e: # noqa: BLE001 — backend may raise arbitrary client errors logger.warning(f"Failed to list backend reports: {e}") return [] diff --git a/report_analyst/core/service.py b/report_analyst/core/service.py index ce8a9a5e..b98a0cb9 100644 --- a/report_analyst/core/service.py +++ b/report_analyst/core/service.py @@ -14,6 +14,7 @@ from typing import Any, Dict, List, Optional from sqlalchemy import text +from sqlalchemy.exc import SQLAlchemyError from report_analyst.core.question_loader import get_question_loader @@ -100,7 +101,7 @@ def get_reports_for_api(question_set_id: Optional[str] = None) -> List[Dict[str, if not allowed_ids: return [] return [r for r in reports if str(r.get("id") or "") in allowed_ids] - except Exception as e: + except (OSError, RuntimeError, ImportError, AttributeError, TypeError, ValueError, SQLAlchemyError) as e: logger.warning("get_reports_for_api failed: %s", e) return [] @@ -136,7 +137,7 @@ def normalize_report_id(report: Dict[str, Any]) -> str: } ) return out - except Exception as e: + except (OSError, RuntimeError, ImportError, AttributeError, TypeError, ValueError, SQLAlchemyError) as e: logger.warning("get_analysis_keys_for_api failed: %s", e) return [] @@ -181,7 +182,7 @@ def get_consolidated_results_for_api( for file_path, question_set, question_id, result_json in rows: try: result = json.loads(result_json) if isinstance(result_json, str) else (result_json or {}) - except Exception: + except (json.JSONDecodeError, TypeError): result = {} answer = str(result.get("ANSWER") or result.get("answer") or result.get("analysis") or "") score = result.get("SCORE", result.get("score", result.get("confidence_score", 0))) @@ -198,7 +199,7 @@ def get_consolidated_results_for_api( } ) return out - except Exception as e: + except (OSError, RuntimeError, ImportError, AttributeError, TypeError, ValueError, SQLAlchemyError) as e: logger.warning("get_consolidated_results_for_api failed: %s", e) return [] diff --git a/tests/test_file_storage_errors.py b/tests/test_file_storage_errors.py new file mode 100644 index 00000000..6761d88d --- /dev/null +++ b/tests/test_file_storage_errors.py @@ -0,0 +1,61 @@ +"""Unit tests for PostgreSQL file storage error paths (no live DB required).""" + +from unittest.mock import MagicMock, patch + +import pytest +from sqlalchemy.exc import SQLAlchemyError + +from report_analyst.core.file_storage import ( + FileStorageError, + PostgreSQLFileStorage, + get_file_storage, +) + + +def _storage_with_failing_conn(): + storage = PostgreSQLFileStorage.__new__(PostgreSQLFileStorage) + storage.db_manager = MagicMock() + conn = MagicMock() + conn.__enter__ = MagicMock(return_value=conn) + conn.__exit__ = MagicMock(return_value=False) + conn.execute.side_effect = SQLAlchemyError("db down") + storage.db_manager.get_connection.return_value = conn + storage.db_manager.get_engine.side_effect = SQLAlchemyError("engine down") + return storage + + +def test_init_table_raises_file_storage_error_on_sqlalchemy_error(): + storage = _storage_with_failing_conn() + with pytest.raises(FileStorageError, match="Failed to initialize"): + storage._init_table() + + +def test_store_retrieve_info_delete_find_handle_sqlalchemy_errors(): + storage = _storage_with_failing_conn() + + with pytest.raises(FileStorageError, match="Failed to store"): + storage.store_file(b"data", "a.pdf") + + with pytest.raises(FileStorageError, match="Failed to retrieve"): + storage.retrieve_file("id-1") + + assert storage.get_file_info("id-1") is None + assert storage.delete_file("id-1") is False + assert storage.find_by_filename("a.pdf") is None + + +def test_save_to_temp_returns_none_on_storage_error(tmp_path): + storage = PostgreSQLFileStorage.__new__(PostgreSQLFileStorage) + storage.db_manager = MagicMock() + storage.get_file_info = MagicMock(side_effect=FileStorageError("boom")) + assert storage.save_to_temp("id-1", temp_dir=tmp_path) is None + + +def test_get_file_storage_returns_none_when_ctor_raises(monkeypatch): + monkeypatch.setenv("USE_POSTGRES_FILE_STORAGE", "true") + monkeypatch.setenv("DATABASE_URL", "postgresql://localhost/test") + with patch( + "report_analyst.core.file_storage.PostgreSQLFileStorage", + side_effect=FileStorageError("unavailable"), + ): + assert get_file_storage() is None diff --git a/tests/test_report_data_client.py b/tests/test_report_data_client.py index b80cb9b6..40a17317 100644 --- a/tests/test_report_data_client.py +++ b/tests/test_report_data_client.py @@ -188,6 +188,15 @@ def test_report_data_client_error_handling(backend_config): assert resources == [] +def test_list_local_reports_skips_unopenable_pdf(temp_dir): + """Invalid PDF bytes that fail open are skipped, not raised.""" + bad_pdf = temp_dir / "bad.pdf" + bad_pdf.write_bytes(b"%PDF-1.4\n" + b"not-a-real-pdf" * 20) + + client = ReportDataClient(temp_dir=temp_dir) + assert client._list_local_reports() == [] + + def test_report_resource_urn_with_colons_in_resource_id(): """Test URN parsing with resource IDs that contain colons""" # Some UUIDs or IDs might have colons diff --git a/tests/test_service_api_errors.py b/tests/test_service_api_errors.py new file mode 100644 index 00000000..24a0717f --- /dev/null +++ b/tests/test_service_api_errors.py @@ -0,0 +1,41 @@ +"""Error-path coverage for shared service API helpers.""" + +from unittest.mock import MagicMock, patch + +from report_analyst.core import service as service_mod + + +def test_get_reports_for_api_returns_empty_on_failure(): + with patch( + "report_analyst.core.report_data_client.ReportDataClient", + side_effect=RuntimeError("boom"), + ): + assert service_mod.get_reports_for_api() == [] + + +def test_get_analysis_keys_for_api_returns_empty_on_failure(): + with patch.object(service_mod, "get_reports_for_api", side_effect=RuntimeError("boom")): + assert service_mod.get_analysis_keys_for_api() == [] + + +def test_get_consolidated_results_handles_bad_json_and_db_failure(): + cache = MagicMock() + conn = MagicMock() + conn.__enter__ = MagicMock(return_value=conn) + conn.__exit__ = MagicMock(return_value=False) + conn.execute.return_value.fetchall.return_value = [ + ("/tmp/r.pdf", "tcfd", "q1", "{not-json"), + ] + cache.db_manager.get_connection.return_value = conn + + with patch("report_analyst.core.cache_manager.CacheManager", return_value=cache): + rows = service_mod.get_consolidated_results_for_api() + assert len(rows) == 1 + assert rows[0]["analysis"] == "" + assert rows[0]["question_id"] == "q1" + + with patch( + "report_analyst.core.cache_manager.CacheManager", + side_effect=RuntimeError("db down"), + ): + assert service_mod.get_consolidated_results_for_api() == [] From 146894e882ed7c059aaf476a5128f0e5c713a733 Mon Sep 17 00:00:00 2001 From: Christian Date: Thu, 20 Aug 2026 18:11:38 +0200 Subject: [PATCH 3/4] Fix ruff E501 and S108 in PR quality-gate tests. - Split minimal PDF fixture bytes to satisfy line length - Avoid /tmp path literal that triggers bandit S108 --- tests/test_report_data_client.py | 16 +++++++++------- tests/test_service_api_errors.py | 2 +- 2 files changed, 10 insertions(+), 8 deletions(-) diff --git a/tests/test_report_data_client.py b/tests/test_report_data_client.py index 40a17317..9471faca 100644 --- a/tests/test_report_data_client.py +++ b/tests/test_report_data_client.py @@ -18,6 +18,13 @@ get_chunks_for_backend_resource, ) +_MINIMAL_PDF = ( + b"%PDF-1.4\n1 0 obj\n<< /Type /Catalog /Pages 2 0 R >>\nendobj\n" + b"2 0 obj\n<< /Type /Pages /Kids [3 0 R] /Count 1 >>\nendobj\n" + b"3 0 obj\n<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] >>\nendobj\n" + b"xref\n0 4\ntrailer\n<< /Size 4 /Root 1 0 R >>\nstartxref\n100\n%%EOF" +) + @pytest.fixture def temp_dir(): @@ -78,10 +85,7 @@ def test_report_data_client_list_local_reports(temp_dir): """Test listing local PDF files""" # Create a minimal valid PDF file test_pdf = temp_dir / "test_report.pdf" - # Write minimal PDF header - test_pdf.write_bytes( - b"%PDF-1.4\n1 0 obj\n<< /Type /Catalog /Pages 2 0 R >>\nendobj\n2 0 obj\n<< /Type /Pages /Kids [3 0 R] /Count 1 >>\nendobj\n3 0 obj\n<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] >>\nendobj\nxref\n0 4\ntrailer\n<< /Size 4 /Root 1 0 R >>\nstartxref\n100\n%%EOF" - ) + test_pdf.write_bytes(_MINIMAL_PDF) client = ReportDataClient(temp_dir=temp_dir) resources = client._list_local_reports() @@ -113,9 +117,7 @@ def test_report_data_client_combined_listing(temp_dir, backend_config, mock_back """Test listing from both local and backend sources""" # Create local PDF test_pdf = temp_dir / "local_report.pdf" - test_pdf.write_bytes( - b"%PDF-1.4\n1 0 obj\n<< /Type /Catalog /Pages 2 0 R >>\nendobj\n2 0 obj\n<< /Type /Pages /Kids [3 0 R] /Count 1 >>\nendobj\n3 0 obj\n<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] >>\nendobj\nxref\n0 4\ntrailer\n<< /Size 4 /Root 1 0 R >>\nstartxref\n100\n%%EOF" - ) + test_pdf.write_bytes(_MINIMAL_PDF) # Mock backend response with patch("requests.get") as mock_get: diff --git a/tests/test_service_api_errors.py b/tests/test_service_api_errors.py index 24a0717f..cac69a3b 100644 --- a/tests/test_service_api_errors.py +++ b/tests/test_service_api_errors.py @@ -24,7 +24,7 @@ def test_get_consolidated_results_handles_bad_json_and_db_failure(): conn.__enter__ = MagicMock(return_value=conn) conn.__exit__ = MagicMock(return_value=False) conn.execute.return_value.fetchall.return_value = [ - ("/tmp/r.pdf", "tcfd", "q1", "{not-json"), + ("reports/r.pdf", "tcfd", "q1", "{not-json"), ] cache.db_manager.get_connection.return_value = conn From 647e004b1f3c0cc0b99ef7061ec8e83e7e18008e Mon Sep 17 00:00:00 2001 From: Christian Date: Fri, 4 Sep 2026 01:51:39 +0200 Subject: [PATCH 4/4] Keep PDF viewer cache errors from crashing Report Analyst. Reset DocumentAnalyzer between tests so AppTest file selection is isolated. --- report_analyst/streamlit_app.py | 70 ++++++++++++++++++--------------- tests/conftest.py | 11 ++++++ tests/test_pdf_viewer.py | 17 ++++++++ 3 files changed, 67 insertions(+), 31 deletions(-) diff --git a/report_analyst/streamlit_app.py b/report_analyst/streamlit_app.py index 80aabfbc..2d8e267b 100644 --- a/report_analyst/streamlit_app.py +++ b/report_analyst/streamlit_app.py @@ -929,19 +929,23 @@ def display_pdf_viewer( questions: Dict[str, Dict], raw_chunks: Optional[List[Dict[str, Any]]] = None, ) -> None: - chunks_by_question = {question_id: data.get("chunks", []) for question_id, data in results.items()} - questions_data = {question_id: question.get("text", question_id) for question_id, question in questions.items()} + chunks_by_question = {question_id: data.get("chunks", []) for question_id, data in (results or {}).items()} + questions_data = {question_id: question.get("text", question_id) for question_id, question in (questions or {}).items()} viewer_key = Path(str(file_path)).stem or "analysis" with st.expander("PDF Viewer with Chunks", expanded=True): - pdf_viewer( - pdf_path=str(file_path), - chunks_data=chunks_by_question, - questions_data=questions_data, - unmapped_chunks=raw_chunks, - key=f"pdf_viewer_{viewer_key}", - height=800, - ) + try: + pdf_viewer( + pdf_path=str(file_path), + chunks_data=chunks_by_question, + questions_data=questions_data, + unmapped_chunks=raw_chunks, + key=f"pdf_viewer_{viewer_key}", + height=800, + ) + except Exception as e: + logger.error(f"Error rendering PDF viewer: {e!s}", exc_info=True) + st.error(f"Error rendering PDF viewer: {e!s}") def display_consolidated_results(analyzer, question_set, file_path=None, selected_config=None): @@ -3927,28 +3931,32 @@ def main(): ) st.error(f"Error during analysis: {e!s}") - viewer_results = fresh_viewer_results - if not viewer_results: - viewer_results = analyzer.analyzer.cache_manager.get_analysis( - file_path=analysis_file_path, - config=config, - ) - if viewer_results: - raw_chunks = [] - elif is_backend: - raw_chunks = st.session_state.get("backend_chunks") or [] - else: - raw_chunks = analyzer.analyzer.cache_manager.get_document_chunks( - file_path=analysis_file_path, - chunk_size=config["chunk_size"], - chunk_overlap=config["chunk_overlap"], + try: + viewer_results = fresh_viewer_results + if not viewer_results: + viewer_results = analyzer.analyzer.cache_manager.get_analysis( + file_path=analysis_file_path, + config=config, + ) + if viewer_results: + raw_chunks = [] + elif is_backend: + raw_chunks = st.session_state.get("backend_chunks") or [] + else: + raw_chunks = analyzer.analyzer.cache_manager.get_document_chunks( + file_path=analysis_file_path, + chunk_size=config["chunk_size"], + chunk_overlap=config["chunk_overlap"], + ) + display_pdf_viewer( + str(analysis_file_path), + viewer_results, + questions, + raw_chunks, ) - display_pdf_viewer( - str(analysis_file_path), - viewer_results, - questions, - raw_chunks, - ) + except Exception as e: + logger.error(f"Error displaying PDF viewer: {e!s}", exc_info=True) + st.error(f"Error displaying PDF viewer: {e!s}") else: # Show helpful error message if file_path is None: diff --git a/tests/conftest.py b/tests/conftest.py index c8cc7989..bd4bdb7c 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -74,6 +74,17 @@ def pytest_configure(config): # Register custom markers config.addinivalue_line("markers", "postgres: mark test as requiring PostgreSQL") config.addinivalue_line("markers", "integration: mark test as integration test") + os.environ.setdefault("MPLBACKEND", "Agg") + + +@pytest.fixture(autouse=True) +def _reset_document_analyzer_singleton(): + """Keep DocumentAnalyzer from leaking cache/LLM state across tests.""" + from report_analyst.core.analyzer import DocumentAnalyzer + + DocumentAnalyzer.reset_instance() + yield + DocumentAnalyzer.reset_instance() # ============================================================================= diff --git a/tests/test_pdf_viewer.py b/tests/test_pdf_viewer.py index e0a2d7ba..2e38e908 100644 --- a/tests/test_pdf_viewer.py +++ b/tests/test_pdf_viewer.py @@ -138,3 +138,20 @@ def test_display_pdf_viewer_opens_without_analysis(): component.assert_called_once() assert component.call_args.kwargs["chunks_data"] == {} assert component.call_args.kwargs["unmapped_chunks"] == [] + + +def test_display_pdf_viewer_survives_component_errors(): + with ( + patch("report_analyst.streamlit_app.st.expander", return_value=nullcontext()), + patch("report_analyst.streamlit_app.pdf_viewer", side_effect=RuntimeError("boom")), + patch("report_analyst.streamlit_app.st.error") as error, + ): + display_pdf_viewer( + file_path="report.pdf", + results=None, + questions=None, + raw_chunks=None, + ) + + error.assert_called_once() + assert "Error rendering PDF viewer" in error.call_args.args[0]