From d3e82bc757d118d6dd9ce2bb096b9b58fcbbaa6a Mon Sep 17 00:00:00 2001 From: Francis Secada Date: Wed, 4 Feb 2026 13:38:20 -0500 Subject: [PATCH] feat: add PDF export functionality for SWOT analysis reports - Add ReportLab dependency (v4.4.9) for PDF generation - Create professional PDF generator with StrategIQ branding * Brand colors matching _variables.scss * Structured layout with SWOT categories * Executive summary section * Page numbering and footer branding - Implement composite caching system (5-minute TTL) * Cache key: session_id + content_hash * Auto-cleanup of expired entries * Access tracking and statistics - Add async PDF download endpoint (/download-pdf) * Cache-first strategy for performance * Safe dictionary access with .get() * Proper error handling for missing sessions - Add download button to results page * Alpine.js loading state * Accessible button with ARIA labels * Professional styling with gradient Co-Authored-By: Claude Sonnet 4.5 --- core_requirements.in | 1 + core_requirements.txt | 742 +++++++++++++++++++++++++++++ src/backend/core/pdf_cache.py | 192 ++++++++ src/backend/core/pdf_service.py | 349 ++++++++++++++ src/backend/site/router.py | 63 ++- src/frontend/templates/result.html | 22 + 6 files changed, 1368 insertions(+), 1 deletion(-) create mode 100644 core_requirements.txt create mode 100644 src/backend/core/pdf_cache.py create mode 100644 src/backend/core/pdf_service.py diff --git a/core_requirements.in b/core_requirements.in index 92e8c28..6ce4ca8 100644 --- a/core_requirements.in +++ b/core_requirements.in @@ -27,6 +27,7 @@ pydantic-ai[examples] pydantic-settings pytz redis +reportlab simplejson sqlalchemy_mixins sqlmodel diff --git a/core_requirements.txt b/core_requirements.txt new file mode 100644 index 0000000..48619e3 --- /dev/null +++ b/core_requirements.txt @@ -0,0 +1,742 @@ +# This file was autogenerated by uv via the following command: +# uv pip compile core_requirements.in -o core_requirements.txt +aiofiles==24.1.0 + # via + # -r core_requirements.in + # gradio +aiohappyeyeballs==2.6.1 + # via aiohttp +aiohttp==3.13.3 + # via modal +aiomysql==0.3.2 + # via -r core_requirements.in +aiosignal==1.4.0 + # via aiohttp +amqp==5.3.1 + # via kombu +annotated-doc==0.0.4 + # via fastapi +annotated-types==0.7.0 + # via + # pydantic + # sqlmodel-crud-utilities +anthropic==0.77.1 + # via pydantic-ai-slim +anyio==4.12.1 + # via + # anthropic + # google-genai + # gradio + # groq + # httpx + # mcp + # openai + # pydantic-evals + # sse-starlette + # starlette + # watchfiles +appdirs==1.4.4 + # via pyppeteer +argcomplete==3.6.3 + # via pydantic-ai-slim +asgiref==3.11.1 + # via opentelemetry-instrumentation-asgi +asttokens==2.4.1 + # via devtools +asyncpg==0.31.0 + # via pydantic-ai-examples +attrs==25.4.0 + # via + # aiohttp + # jsonschema + # referencing +audioop-lts==0.2.2 + # via gradio +beautifulsoup4==4.14.3 + # via httpx-html +billiard==4.2.4 + # via celery +boto3==1.42.41 + # via pydantic-ai-slim +botocore==1.42.41 + # via + # boto3 + # s3transfer +brotli==1.2.0 + # via gradio +cbor2==5.8.0 + # via modal +celery==5.6.2 + # via + # -r core_requirements.in + # flower +certifi==2026.1.4 + # via + # httpcore + # httpx + # modal + # requests +cffi==2.0.0 + # via cryptography +charset-normalizer==3.4.4 + # via + # reportlab + # requests +click==8.3.1 + # via + # celery + # click-didyoumean + # click-plugins + # click-repl + # modal + # typer + # typer-slim + # uvicorn +click-didyoumean==0.3.1 + # via celery +click-plugins==1.1.1.2 + # via celery +click-repl==0.3.0 + # via celery +cohere==5.20.2 + # via pydantic-ai-slim +colorama==0.4.6 + # via + # click + # griffe + # loguru + # sqlmodel-crud-utilities + # tqdm +cryptography==46.0.4 + # via google-auth +cssselect==1.4.0 + # via pyquery +devtools==0.12.2 + # via pydantic-ai-examples +distro==1.9.0 + # via + # anthropic + # google-genai + # groq + # openai +docstring-parser==0.17.0 + # via anthropic +eval-type-backport==0.3.1 + # via + # mistralai + # pydantic-ai-slim +executing==2.2.1 + # via + # devtools + # logfire +fake-useragent==2.2.0 + # via httpx-html +fastapi==0.128.1 + # via + # -r core_requirements.in + # fastapi-restful + # fastcrud + # gradio + # pydantic-ai-examples +fastapi-restful==0.6.0 + # via -r core_requirements.in +fastavro==1.12.1 + # via cohere +fastcrud==0.21.0 + # via -r core_requirements.in +ffmpy==1.0.0 + # via gradio +filelock==3.20.3 + # via huggingface-hub +flower==2.0.1 + # via -r core_requirements.in +frozenlist==1.8.0 + # via + # aiohttp + # aiosignal +fsspec==2026.1.0 + # via + # gradio-client + # huggingface-hub +google-auth==2.48.0 + # via + # google-genai + # pydantic-ai-slim +google-genai==1.61.0 + # via pydantic-ai-slim +googleapis-common-protos==1.72.0 + # via opentelemetry-exporter-otlp-proto-http +gradio==6.5.1 + # via pydantic-ai-examples +gradio-client==2.0.3 + # via gradio +greenlet==3.1.1 + # via + # -r core_requirements.in + # sqlalchemy + # sqlmodel-crud-utilities +griffe==1.15.0 + # via pydantic-ai-slim +groovy==0.1.2 + # via gradio +groq==1.0.0 + # via pydantic-ai-slim +grpclib==0.4.9 + # via modal +h11==0.16.0 + # via + # httpcore + # hypercorn + # uvicorn + # wsproto +h2==4.3.0 + # via + # grpclib + # hypercorn +hf-xet==1.2.0 + # via huggingface-hub +hpack==4.1.0 + # via h2 +html5lib==1.1 + # via -r core_requirements.in +httpcore==1.0.9 + # via httpx +httpx==0.28.1 + # via + # -r core_requirements.in + # anthropic + # cohere + # google-genai + # gradio + # gradio-client + # groq + # httpx-html + # huggingface-hub + # mcp + # mistralai + # openai + # pydantic-ai-slim + # pydantic-graph + # safehttpx + # tavily-python +httpx-html==0.11.0.dev0 + # via -r core_requirements.in +httpx-sse==0.4.3 + # via mcp +huggingface-hub==1.4.0 + # via + # gradio + # gradio-client + # pydantic-ai-slim + # tokenizers +humanize==4.15.0 + # via flower +hypercorn==0.18.0 + # via -r core_requirements.in +hyperframe==6.1.0 + # via h2 +idna==3.11 + # via + # anyio + # httpx + # requests + # yarl +importlib-metadata==8.7.1 + # via opentelemetry-api +invoke==2.2.1 + # via mistralai +itsdangerous==2.2.0 + # via -r core_requirements.in +jinja2==3.1.6 + # via + # gradio + # jinjax +jinjax==0.63 + # via -r core_requirements.in +jiter==0.13.0 + # via + # anthropic + # openai +jmespath==1.1.0 + # via + # boto3 + # botocore +jsonschema==4.26.0 + # via mcp +jsonschema-specifications==2025.9.1 + # via jsonschema +kombu==5.6.2 + # via celery +logfire==4.22.0 + # via pydantic-ai-examples +logfire-api==4.22.0 + # via + # pydantic-evals + # pydantic-graph +loguru==0.7.3 + # via + # -r core_requirements.in + # sqlmodel-crud-utilities +lxml==6.0.2 + # via + # -r core_requirements.in + # lxml-html-clean + # pyquery +lxml-html-clean==0.4.3 + # via lxml +markdown-it-py==4.0.0 + # via rich +markupsafe==3.0.3 + # via + # gradio + # jinja2 + # jinjax +mcp==1.12.4 + # via + # pydantic-ai-examples + # pydantic-ai-slim +mdurl==0.1.2 + # via markdown-it-py +mistralai==1.9.11 + # via pydantic-ai-slim +modal==1.3.2 + # via pydantic-ai-examples +multidict==6.7.1 + # via + # aiohttp + # grpclib + # yarl +mypy-extensions==1.1.0 + # via typing-inspect +numpy==2.4.2 + # via + # gradio + # pandas +openai==2.16.0 + # via + # -r core_requirements.in + # pydantic-ai-slim +opentelemetry-api==1.39.1 + # via + # opentelemetry-exporter-otlp-proto-http + # opentelemetry-instrumentation + # opentelemetry-instrumentation-asgi + # opentelemetry-instrumentation-asyncpg + # opentelemetry-instrumentation-dbapi + # opentelemetry-instrumentation-fastapi + # opentelemetry-instrumentation-httpx + # opentelemetry-instrumentation-sqlite3 + # opentelemetry-sdk + # opentelemetry-semantic-conventions + # pydantic-ai-slim +opentelemetry-exporter-otlp-proto-common==1.39.1 + # via opentelemetry-exporter-otlp-proto-http +opentelemetry-exporter-otlp-proto-http==1.39.1 + # via logfire +opentelemetry-instrumentation==0.60b1 + # via + # logfire + # opentelemetry-instrumentation-asgi + # opentelemetry-instrumentation-asyncpg + # opentelemetry-instrumentation-dbapi + # opentelemetry-instrumentation-fastapi + # opentelemetry-instrumentation-httpx + # opentelemetry-instrumentation-sqlite3 +opentelemetry-instrumentation-asgi==0.60b1 + # via opentelemetry-instrumentation-fastapi +opentelemetry-instrumentation-asyncpg==0.60b1 + # via logfire +opentelemetry-instrumentation-dbapi==0.60b1 + # via opentelemetry-instrumentation-sqlite3 +opentelemetry-instrumentation-fastapi==0.60b1 + # via logfire +opentelemetry-instrumentation-httpx==0.60b1 + # via logfire +opentelemetry-instrumentation-sqlite3==0.60b1 + # via logfire +opentelemetry-proto==1.39.1 + # via + # opentelemetry-exporter-otlp-proto-common + # opentelemetry-exporter-otlp-proto-http +opentelemetry-sdk==1.39.1 + # via + # logfire + # opentelemetry-exporter-otlp-proto-http +opentelemetry-semantic-conventions==0.60b1 + # via + # opentelemetry-instrumentation + # opentelemetry-instrumentation-asgi + # opentelemetry-instrumentation-asyncpg + # opentelemetry-instrumentation-dbapi + # opentelemetry-instrumentation-fastapi + # opentelemetry-instrumentation-httpx + # opentelemetry-sdk +opentelemetry-util-http==0.60b1 + # via + # opentelemetry-instrumentation-asgi + # opentelemetry-instrumentation-fastapi + # opentelemetry-instrumentation-httpx +orjson==3.11.7 + # via gradio +packaging==26.0 + # via + # gradio + # gradio-client + # huggingface-hub + # kombu + # opentelemetry-instrumentation +pandas==3.0.0 + # via gradio +parse==1.20.2 + # via httpx-html +pillow==12.1.0 + # via + # gradio + # reportlab +praw==7.8.1 + # via -r core_requirements.in +prawcore==2.4.0 + # via praw +priority==2.0.0 + # via hypercorn +prometheus-client==0.24.1 + # via flower +prompt-toolkit==3.0.52 + # via + # click-repl + # pydantic-ai-slim +propcache==0.4.1 + # via + # aiohttp + # yarl +protobuf==6.33.5 + # via + # googleapis-common-protos + # logfire + # modal + # opentelemetry-proto +psutil==5.9.8 + # via fastapi-restful +psycopg==3.3.2 + # via -r core_requirements.in +pyasn1==0.6.2 + # via + # pyasn1-modules + # rsa +pyasn1-modules==0.4.2 + # via google-auth +pycparser==3.0 + # via cffi +pydantic==2.10.5 + # via + # anthropic + # cohere + # fastapi + # fastapi-restful + # fastcrud + # google-genai + # gradio + # groq + # mcp + # mistralai + # openai + # pydantic-ai-slim + # pydantic-evals + # pydantic-graph + # pydantic-settings + # sqlmodel + # sqlmodel-crud-utilities +pydantic-ai==0.4.3 + # via -r core_requirements.in +pydantic-ai-examples==0.4.3 + # via pydantic-ai +pydantic-ai-slim==0.4.3 + # via + # pydantic-ai + # pydantic-ai-examples + # pydantic-evals +pydantic-core==2.27.2 + # via + # cohere + # pydantic + # sqlmodel-crud-utilities +pydantic-evals==0.4.3 + # via + # pydantic-ai-examples + # pydantic-ai-slim +pydantic-graph==0.4.3 + # via pydantic-ai-slim +pydantic-settings==2.12.0 + # via + # -r core_requirements.in + # mcp +pydub==0.25.1 + # via gradio +pyee==13.0.0 + # via pyppeteer +pygments==2.19.2 + # via + # devtools + # rich +pymysql==1.1.2 + # via aiomysql +pyppeteer==0.0.25 + # via httpx-html +pyquery==2.0.1 + # via httpx-html +python-dateutil==2.9.0.post0 + # via + # -r core_requirements.in + # botocore + # celery + # mistralai + # pandas + # sqlmodel-crud-utilities +python-decouple==3.8 + # via -r core_requirements.in +python-dotenv==1.0.1 + # via + # mcp + # pydantic-settings + # sqlmodel-crud-utilities +python-multipart==0.0.22 + # via + # gradio + # mcp + # pydantic-ai-examples +python-slugify==8.0.4 + # via -r core_requirements.in +pytz==2025.2 + # via + # -r core_requirements.in + # flower + # gradio +pywin32==311 + # via mcp +pyyaml==6.0.3 + # via + # gradio + # huggingface-hub + # mistralai + # pydantic-evals +redis==7.1.0 + # via -r core_requirements.in +referencing==0.37.0 + # via + # jsonschema + # jsonschema-specifications +regex==2026.1.15 + # via tiktoken +reportlab==4.4.9 + # via -r core_requirements.in +requests==2.32.5 + # via + # cohere + # google-auth + # google-genai + # opentelemetry-exporter-otlp-proto-http + # prawcore + # pydantic-ai-slim + # tavily-python + # tiktoken + # update-checker +rich==14.3.2 + # via + # logfire + # modal + # pydantic-ai-examples + # pydantic-ai-slim + # pydantic-evals + # typer +rpds-py==0.30.0 + # via + # jsonschema + # referencing +rsa==4.9.1 + # via google-auth +s3transfer==0.16.0 + # via boto3 +safehttpx==0.1.7 + # via gradio +semantic-version==2.10.0 + # via gradio +shellingham==1.5.4 + # via + # huggingface-hub + # typer +simplejson==3.20.2 + # via -r core_requirements.in +six==1.17.0 + # via + # asttokens + # html5lib + # python-dateutil + # sqlalchemy-mixins + # sqlmodel-crud-utilities +sniffio==1.3.1 + # via + # anthropic + # google-genai + # groq + # openai +socksio==1.0.0 + # via httpx +soupsieve==2.8.3 + # via beautifulsoup4 +sqlalchemy==2.0.37 + # via + # fastcrud + # sqlalchemy-mixins + # sqlalchemy-utils + # sqlmodel + # sqlmodel-crud-utilities +sqlalchemy-mixins==2.0.5 + # via -r core_requirements.in +sqlalchemy-utils==0.41.2 + # via fastcrud +sqlmodel==0.0.22 + # via + # -r core_requirements.in + # sqlmodel-crud-utilities +sqlmodel-crud-utilities @ git+https://github.com/fsecada01/SQLModel-CRUD-Utilities@83e964f6e7b633e339e45ddcaaa49cd8617fa105 + # via -r core_requirements.in +sse-starlette==3.2.0 + # via mcp +starlette==0.50.0 + # via + # fastapi + # gradio + # mcp + # sse-starlette +synchronicity==0.11.1 + # via modal +tavily-python==0.7.21 + # via -r core_requirements.in +tenacity==9.1.2 + # via google-genai +text-unidecode==1.3 + # via python-slugify +tiktoken==0.12.0 + # via tavily-python +tokenizers==0.22.2 + # via cohere +toml==0.10.2 + # via modal +tomlkit==0.13.3 + # via gradio +tornado==6.5.4 + # via flower +tqdm==4.67.3 + # via + # huggingface-hub + # openai + # pyppeteer +typer==0.21.1 + # via + # gradio + # mcp + # modal +typer-slim==0.21.1 + # via huggingface-hub +types-certifi==2021.10.8.3 + # via modal +types-requests==2.32.4.20260107 + # via cohere +types-toml==0.10.8.20240310 + # via modal +typing-extensions==4.12.2 + # via + # anthropic + # beautifulsoup4 + # cohere + # fastapi + # google-genai + # gradio + # gradio-client + # groq + # huggingface-hub + # logfire + # modal + # openai + # opentelemetry-api + # opentelemetry-exporter-otlp-proto-http + # opentelemetry-sdk + # opentelemetry-semantic-conventions + # pydantic + # pydantic-core + # pyee + # sqlalchemy + # sqlmodel-crud-utilities + # synchronicity + # typer + # typer-slim + # typing-inspect + # typing-inspection +typing-inspect==0.9.0 + # via -r core_requirements.in +typing-inspection==0.4.2 + # via + # mistralai + # pydantic-ai-slim + # pydantic-graph + # pydantic-settings +tzdata==2025.3 + # via + # kombu + # pandas + # psycopg + # tzlocal +tzlocal==5.3.1 + # via celery +update-checker==0.18.0 + # via praw +urllib3==2.6.3 + # via + # botocore + # pyppeteer + # requests + # types-requests +uvicorn==0.40.0 + # via + # -r core_requirements.in + # gradio + # mcp + # pydantic-ai-examples +vine==5.1.0 + # via + # amqp + # celery + # kombu +w3lib==2.4.0 + # via httpx-html +watchfiles==1.1.1 + # via modal +wcwidth==0.5.3 + # via prompt-toolkit +webencodings==0.5.1 + # via html5lib +websocket-client==1.9.0 + # via praw +websockets==15.0.1 + # via + # google-genai + # pyppeteer +win32-setctime==1.2.0 + # via + # loguru + # sqlmodel-crud-utilities +wrapt==1.17.3 + # via + # opentelemetry-instrumentation + # opentelemetry-instrumentation-dbapi + # opentelemetry-instrumentation-httpx +wsproto==1.3.2 + # via hypercorn +xmljson==0.2.1 + # via -r core_requirements.in +xmltodict==1.0.2 + # via -r core_requirements.in +yarl==1.22.0 + # via aiohttp +zipp==3.23.0 + # via importlib-metadata diff --git a/src/backend/core/pdf_cache.py b/src/backend/core/pdf_cache.py new file mode 100644 index 0000000..b1674d7 --- /dev/null +++ b/src/backend/core/pdf_cache.py @@ -0,0 +1,192 @@ +""" +PDF Caching Service with Time-Based Expiration + +Implements composite caching with 5-minute TTL for PDF reports. +Cache key: result_id + content_hash for efficient lookups. +""" + +import time +from io import BytesIO +from typing import Any + +from backend.core.core import SwotAnalysis +from backend.core.pdf_service import compute_content_hash +from backend.logger import logger + +# Cache configuration +CACHE_TTL_SECONDS = 300 # 5 minutes +CLEANUP_INTERVAL_SECONDS = 60 # Run cleanup every minute + + +class PDFCacheEntry: + """ + Represents a cached PDF with metadata. + """ + + def __init__(self, pdf_buffer: BytesIO, content_hash: str): + self.pdf_buffer = pdf_buffer + self.content_hash = content_hash + self.created_at = time.time() + self.accessed_at = time.time() + self.access_count = 0 + + def is_expired(self, ttl_seconds: int = CACHE_TTL_SECONDS) -> bool: + """Check if cache entry has expired""" + return (time.time() - self.created_at) > ttl_seconds + + def access(self): + """Update access metadata""" + self.accessed_at = time.time() + self.access_count += 1 + + def get_age_seconds(self) -> float: + """Get age of cache entry in seconds""" + return time.time() - self.created_at + + +class PDFCacheManager: + """ + In-memory cache manager for PDF reports with composite key strategy. + + Cache key format: f"{session_id}:{content_hash}" + - session_id: Unique identifier for the analysis session + - content_hash: SHA-256 hash of SWOT content + + This ensures: + - Same session + same content = cache hit + - Same session + different content = cache miss (content changed) + - Different session + same content = separate cache entry + """ + + def __init__(self): + self._cache: dict[str, PDFCacheEntry] = {} + self._last_cleanup = time.time() + + def _make_cache_key(self, session_id: str, content_hash: str) -> str: + """Generate composite cache key""" + return f"{session_id}:{content_hash}" + + def get(self, session_id: str, analysis: SwotAnalysis) -> BytesIO | None: + """ + Retrieve cached PDF if available and not expired. + + :param session_id: Session identifier + :param analysis: SwotAnalysis object for content hash computation + :return: BytesIO buffer if cache hit, None if cache miss + """ + content_hash = compute_content_hash(analysis) + cache_key = self._make_cache_key(session_id, content_hash) + + # Auto-cleanup on every get + self._cleanup_expired() + + if cache_key in self._cache: + entry = self._cache[cache_key] + + if entry.is_expired(): + logger.info(f"PDF cache expired for key: {cache_key}") + del self._cache[cache_key] + return None + + # Update access metadata + entry.access() + logger.info( + f"PDF cache HIT for key: {cache_key} " + f"(age: {entry.get_age_seconds():.1f}s, accesses: {entry.access_count})" + ) + + # Return a copy of the buffer to avoid mutation + buffer_copy = BytesIO(entry.pdf_buffer.getvalue()) + return buffer_copy + + logger.info(f"PDF cache MISS for key: {cache_key}") + return None + + def set(self, session_id: str, analysis: SwotAnalysis, pdf_buffer: BytesIO): + """ + Store PDF in cache with composite key. + + :param session_id: Session identifier + :param analysis: SwotAnalysis object for content hash computation + :param pdf_buffer: BytesIO buffer containing PDF + """ + content_hash = compute_content_hash(analysis) + cache_key = self._make_cache_key(session_id, content_hash) + + # Store a copy to prevent external mutation + buffer_copy = BytesIO(pdf_buffer.getvalue()) + entry = PDFCacheEntry(buffer_copy, content_hash) + + self._cache[cache_key] = entry + logger.info( + f"PDF cached with key: {cache_key} (TTL: {CACHE_TTL_SECONDS}s)" + ) + + def invalidate(self, session_id: str): + """ + Invalidate all cache entries for a session. + + :param session_id: Session identifier + """ + keys_to_delete = [ + key for key in self._cache if key.startswith(f"{session_id}:") + ] + + for key in keys_to_delete: + del self._cache[key] + logger.info(f"Invalidated cache key: {key}") + + def _cleanup_expired(self): + """Remove expired entries from cache""" + # Avoid excessive cleanup calls + if (time.time() - self._last_cleanup) < CLEANUP_INTERVAL_SECONDS: + return + + expired_keys = [ + key for key, entry in self._cache.items() if entry.is_expired() + ] + + for key in expired_keys: + del self._cache[key] + logger.debug(f"Cleaned up expired cache key: {key}") + + if expired_keys: + logger.info( + f"PDF cache cleanup: removed {len(expired_keys)} expired entries" + ) + + self._last_cleanup = time.time() + + def get_stats(self) -> dict[str, Any]: + """ + Get cache statistics for monitoring. + + :return: Dictionary with cache stats + """ + total_entries = len(self._cache) + total_accesses = sum( + entry.access_count for entry in self._cache.values() + ) + avg_age = ( + sum(entry.get_age_seconds() for entry in self._cache.values()) + / total_entries + if total_entries > 0 + else 0 + ) + + return { + "total_entries": total_entries, + "total_accesses": total_accesses, + "average_age_seconds": avg_age, + "ttl_seconds": CACHE_TTL_SECONDS, + } + + def clear(self): + """Clear all cache entries (useful for testing)""" + count = len(self._cache) + self._cache.clear() + logger.info(f"PDF cache cleared: removed {count} entries") + + +# Global cache instance +pdf_cache = PDFCacheManager() diff --git a/src/backend/core/pdf_service.py b/src/backend/core/pdf_service.py new file mode 100644 index 0000000..fc46795 --- /dev/null +++ b/src/backend/core/pdf_service.py @@ -0,0 +1,349 @@ +""" +PDF Generation Service for SWOT Analysis Reports + +Generates professional, branded PDF reports using ReportLab. +Follows StrategIQ brand guidelines with WCAG accessibility considerations. +""" + +import hashlib +from io import BytesIO +from typing import Any + +from reportlab.lib import colors +from reportlab.lib.pagesizes import letter +from reportlab.lib.styles import ParagraphStyle, getSampleStyleSheet +from reportlab.lib.units import inch +from reportlab.platypus import ( + Frame, + PageTemplate, + Paragraph, + Spacer, + Table, + TableStyle, +) +from reportlab.platypus.doctemplate import BaseDocTemplate + +from backend.core.core import SwotAnalysis +from backend.logger import logger + +# Brand Colors (from _variables.scss) +BRAND_PRIMARY = colors.HexColor("#8B5CF6") +BRAND_PRIMARY_DARK = colors.HexColor("#7C3AED") +SWOT_STRENGTH = colors.HexColor("#10B981") +SWOT_WEAKNESS = colors.HexColor("#F59E0B") +SWOT_OPPORTUNITY = colors.HexColor("#3B82F6") +SWOT_THREAT = colors.HexColor("#EF4444") +NEUTRAL_700 = colors.HexColor("#374151") +NEUTRAL_100 = colors.HexColor("#F3F4F6") +WHITE = colors.white + + +def hex_to_rgb_tuple(hex_color: str) -> tuple[float, float, float]: + """Convert hex color to RGB tuple (0-1 range)""" + hex_color = hex_color.lstrip("#") + return tuple(int(hex_color[i : i + 2], 16) / 255 for i in (0, 2, 4)) + + +def compute_content_hash(analysis: SwotAnalysis) -> str: + """ + Compute SHA-256 hash of SWOT analysis content for caching. + + :param analysis: SwotAnalysis object + :return: Hexadecimal hash string + """ + content = f"{analysis.primary_entity}{analysis.comparison_entities}{analysis.strengths}{analysis.weaknesses}{analysis.opportunities}{analysis.threats}{analysis.analysis}" + return hashlib.sha256(content.encode()).hexdigest() + + +class SwotPDFGenerator: + """ + Professional PDF generator for SWOT analysis reports. + Uses StrategIQ brand colors and follows accessibility best practices. + """ + + def __init__(self, analysis: SwotAnalysis): + self.analysis = analysis + self.buffer = BytesIO() + self.styles = getSampleStyleSheet() + self._setup_custom_styles() + + def _setup_custom_styles(self): + """Configure custom paragraph styles matching brand guidelines""" + # Title style + self.styles.add( + ParagraphStyle( + name="ReportTitle", + parent=self.styles["Heading1"], + fontSize=28, + textColor=BRAND_PRIMARY, + spaceAfter=6, + fontName="Helvetica-Bold", + alignment=1, # Center + ) + ) + + # Subtitle style + self.styles.add( + ParagraphStyle( + name="ReportSubtitle", + parent=self.styles["Normal"], + fontSize=14, + textColor=NEUTRAL_700, + spaceAfter=20, + fontName="Helvetica", + alignment=1, # Center + ) + ) + + # Section header style + self.styles.add( + ParagraphStyle( + name="SectionHeader", + parent=self.styles["Heading2"], + fontSize=18, + textColor=BRAND_PRIMARY_DARK, + spaceBefore=16, + spaceAfter=8, + fontName="Helvetica-Bold", + ) + ) + + # Category header style + self.styles.add( + ParagraphStyle( + name="CategoryHeader", + parent=self.styles["Heading3"], + fontSize=14, + textColor=WHITE, + spaceBefore=12, + spaceAfter=8, + fontName="Helvetica-Bold", + ) + ) + + # Body text style + self.styles.add( + ParagraphStyle( + name="BodyText", + parent=self.styles["Normal"], + fontSize=11, + textColor=NEUTRAL_700, + spaceAfter=8, + fontName="Helvetica", + leading=16, + ) + ) + + # Bullet list style + self.styles.add( + ParagraphStyle( + name="BulletItem", + parent=self.styles["Normal"], + fontSize=10, + textColor=NEUTRAL_700, + leftIndent=20, + spaceAfter=6, + fontName="Helvetica", + leading=14, + ) + ) + + def _add_header(self, story: list[Any]): + """Add report header with title and entity information""" + # Title + title = Paragraph("SWOT Analysis Report", self.styles["ReportTitle"]) + story.append(title) + story.append(Spacer(1, 0.1 * inch)) + + # Entity information + entity_text = f"Primary Entity: {self.analysis.primary_entity}" + if self.analysis.comparison_entities: + comparisons = ", ".join(self.analysis.comparison_entities) + entity_text += f"
Compared with: {comparisons}" + + entity_para = Paragraph(entity_text, self.styles["ReportSubtitle"]) + story.append(entity_para) + story.append(Spacer(1, 0.3 * inch)) + + def _add_swot_section( + self, + story: list[Any], + category: str, + items: list[str], + color: colors.HexColor, + ): + """ + Add a SWOT category section with colored header and bullet points. + + :param story: ReportLab story list + :param category: Category name (e.g., "Strengths") + :param items: List of SWOT items + :param color: Brand color for the category + """ + # Category header with colored background + header_table = Table( + [ + [ + Paragraph( + f"{category} ({len(items)})", + self.styles["CategoryHeader"], + ) + ] + ], + colWidths=[6.5 * inch], + ) + header_table.setStyle( + TableStyle( + [ + ("BACKGROUND", (0, 0), (-1, -1), color), + ("ALIGN", (0, 0), (-1, -1), "LEFT"), + ("VALIGN", (0, 0), (-1, -1), "MIDDLE"), + ("LEFTPADDING", (0, 0), (-1, -1), 12), + ("RIGHTPADDING", (0, 0), (-1, -1), 12), + ("TOPPADDING", (0, 0), (-1, -1), 10), + ("BOTTOMPADDING", (0, 0), (-1, -1), 10), + ("ROUNDEDCORNERS", [8, 8, 0, 0]), + ] + ) + ) + story.append(header_table) + + # Items list with light background + items_data = [ + [Paragraph(f"• {item}", self.styles["BulletItem"])] + for item in items + ] + items_table = Table(items_data, colWidths=[6.5 * inch]) + items_table.setStyle( + TableStyle( + [ + ("BACKGROUND", (0, 0), (-1, -1), NEUTRAL_100), + ("ALIGN", (0, 0), (-1, -1), "LEFT"), + ("VALIGN", (0, 0), (-1, -1), "TOP"), + ("LEFTPADDING", (0, 0), (-1, -1), 12), + ("RIGHTPADDING", (0, 0), (-1, -1), 12), + ("TOPPADDING", (0, 0), (-1, -1), 8), + ("BOTTOMPADDING", (0, 0), (-1, -1), 8), + ("ROUNDEDCORNERS", [0, 0, 8, 8]), + ] + ) + ) + story.append(items_table) + story.append(Spacer(1, 0.25 * inch)) + + def _add_executive_summary(self, story: list[Any]): + """Add executive summary section""" + if not self.analysis.analysis: + return + + story.append( + Paragraph("Executive Summary", self.styles["SectionHeader"]) + ) + story.append(Spacer(1, 0.1 * inch)) + + # Wrap summary in a table for better styling + summary_para = Paragraph( + self.analysis.analysis, self.styles["BodyText"] + ) + summary_table = Table([[summary_para]], colWidths=[6.5 * inch]) + summary_table.setStyle( + TableStyle( + [ + ("BACKGROUND", (0, 0), (-1, -1), WHITE), + ("BOX", (0, 0), (-1, -1), 2, BRAND_PRIMARY), + ("LEFTPADDING", (0, 0), (-1, -1), 16), + ("RIGHTPADDING", (0, 0), (-1, -1), 16), + ("TOPPADDING", (0, 0), (-1, -1), 16), + ("BOTTOMPADDING", (0, 0), (-1, -1), 16), + ("ROUNDEDCORNERS", [8, 8, 8, 8]), + ] + ) + ) + story.append(summary_table) + + def _add_footer(self, canvas, doc): + """Add footer with page numbers and branding""" + canvas.saveState() + canvas.setFont("Helvetica", 9) + canvas.setFillColor(NEUTRAL_700) + + # Page number + page_text = f"Page {doc.page}" + canvas.drawRightString(7.5 * inch, 0.5 * inch, page_text) + + # Branding + canvas.drawString(inch, 0.5 * inch, "Generated by StrategIQ") + + canvas.restoreState() + + def generate(self) -> BytesIO: + """ + Generate PDF report and return as BytesIO buffer. + + :return: BytesIO buffer containing PDF + """ + logger.info(f"Generating PDF report for {self.analysis.primary_entity}") + + # Create document with custom template + doc = BaseDocTemplate(self.buffer, pagesize=letter) + frame = Frame( + doc.leftMargin, + doc.bottomMargin, + doc.width, + doc.height, + id="normal", + ) + template = PageTemplate( + id="main", frames=frame, onPage=self._add_footer + ) + doc.addPageTemplates([template]) + + # Build story (content) + story = [] + + # Header + self._add_header(story) + + # SWOT Categories + story.append(Paragraph("SWOT Analysis", self.styles["SectionHeader"])) + story.append(Spacer(1, 0.15 * inch)) + + self._add_swot_section( + story, "Strengths", self.analysis.strengths, SWOT_STRENGTH + ) + self._add_swot_section( + story, "Weaknesses", self.analysis.weaknesses, SWOT_WEAKNESS + ) + self._add_swot_section( + story, + "Opportunities", + self.analysis.opportunities, + SWOT_OPPORTUNITY, + ) + self._add_swot_section( + story, "Threats", self.analysis.threats, SWOT_THREAT + ) + + # Executive Summary + story.append(Spacer(1, 0.2 * inch)) + self._add_executive_summary(story) + + # Build PDF + doc.build(story) + + # Reset buffer position + self.buffer.seek(0) + logger.info("PDF report generated successfully") + + return self.buffer + + +def generate_swot_pdf(analysis: SwotAnalysis) -> BytesIO: + """ + Convenience function to generate SWOT PDF report. + + :param analysis: SwotAnalysis object + :return: BytesIO buffer containing PDF + """ + generator = SwotPDFGenerator(analysis) + return generator.generate() diff --git a/src/backend/site/router.py b/src/backend/site/router.py index b837e4a..5da2136 100644 --- a/src/backend/site/router.py +++ b/src/backend/site/router.py @@ -5,8 +5,10 @@ from fastapi import APIRouter, Form, Request from fastapi.staticfiles import StaticFiles from fastapi.templating import Jinja2Templates from jinjax import Catalog, JinjaX -from starlette.responses import HTMLResponse +from starlette.responses import HTMLResponse, StreamingResponse +from backend.core.pdf_cache import pdf_cache +from backend.core.pdf_service import generate_swot_pdf from backend.logger import logger from backend.settings import app_settings from backend.site.consts import ( @@ -158,3 +160,62 @@ async def get_result(request: Request) -> HTMLResponse: "result.html", {"request": request, "result": result}, ) + + +@user_frontend.get("/download-pdf") +async def download_pdf(request: Request) -> StreamingResponse: + """ + Generate and download SWOT analysis as PDF report. + Uses composite caching (session_id + content_hash) with 5-minute TTL. + + :param request: Request + :return: StreamingResponse with PDF file + """ + session_id = request.session.get("analysis_id") + + if not session_id: + logger.warning("PDF download attempted without session ID") + return StreamingResponse( + content=b"No analysis found. Please run an analysis first.", + media_type="text/plain", + status_code=404, + ) + + result = result_store.get(session_id) + + if not result: + logger.warning( + f"PDF download attempted but result is None for session: {session_id}" + ) + return StreamingResponse( + content=b"Analysis not complete. Please wait for analysis to finish.", + media_type="text/plain", + status_code=404, + ) + + # Check cache first + cached_pdf = pdf_cache.get(session_id, result) + + if cached_pdf: + logger.info(f"Serving cached PDF for session: {session_id}") + pdf_buffer = cached_pdf + else: + # Generate new PDF + logger.info(f"Generating new PDF for session: {session_id}") + pdf_buffer = generate_swot_pdf(result) + + # Cache the generated PDF + pdf_cache.set(session_id, result, pdf_buffer) + + # Prepare filename + filename = f"swot-analysis-{session_id[:8]}.pdf" + + # Return as streaming response + return StreamingResponse( + content=pdf_buffer, + media_type="application/pdf", + headers={ + "Content-Disposition": f"attachment; filename={filename}", + "Cache-Control": "no-cache", + }, + ) diff --git a/src/frontend/templates/result.html b/src/frontend/templates/result.html index da81d5e..79cb585 100644 --- a/src/frontend/templates/result.html +++ b/src/frontend/templates/result.html @@ -104,6 +104,28 @@ {% endif %} + + {# PDF Download Button #} +
+ +

+ Professional PDF report with full SWOT analysis +

+
{% endif %} \ No newline at end of file