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 +
+