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 <noreply@anthropic.com>
This commit is contained in:
2026-02-04 13:38:20 -05:00
parent 7a903b7872
commit d3e82bc757
6 changed files with 1368 additions and 1 deletions

View File

@ -27,6 +27,7 @@ pydantic-ai[examples]
pydantic-settings
pytz
redis
reportlab
simplejson
sqlalchemy_mixins
sqlmodel

742
core_requirements.txt Normal file
View File

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

View File

@ -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()

View File

@ -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"<b>Primary Entity:</b> {self.analysis.primary_entity}"
if self.analysis.comparison_entities:
comparisons = ", ".join(self.analysis.comparison_entities)
entity_text += f"<br/><b>Compared with:</b> {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"<b>{category}</b> ({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()

View File

@ -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",
},
)

View File

@ -104,6 +104,28 @@
</div>
</section>
{% endif %}
{# PDF Download Button #}
<div class="has-text-centered mt-6"
x-data="{ downloading: false }"
role="region"
aria-label="Download options">
<button class="button is-primary is-medium"
@click="downloading = true; window.location.href = '{{ url_for('download_pdf') }}'; setTimeout(() => downloading = false, 2000)"
:disabled="downloading"
:class="{ 'is-loading': downloading }"
aria-label="Download SWOT analysis as PDF report"
style="background: linear-gradient(135deg, var(--brand-primary) 0%, var(--brand-primary-dark) 100%); border: none; font-weight: 600; padding: 0.75rem 2rem; box-shadow: var(--shadow-md); transition: all 0.3s ease;">
<span class="icon">
<i class="fas fa-file-pdf"></i>
</span>
<span x-text="downloading ? 'Generating PDF...' : 'Download PDF Report'"></span>
</button>
<p class="help mt-2"
style="color: var(--neutral-600);">
Professional PDF report with full SWOT analysis
</p>
</div>
</div>
</section>
{% endif %}