mirror of
https://github.com/fsecada01/Pygentic-AI.git
synced 2026-05-12 12:15:00 +00:00
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:
@ -27,6 +27,7 @@ pydantic-ai[examples]
|
||||
pydantic-settings
|
||||
pytz
|
||||
redis
|
||||
reportlab
|
||||
simplejson
|
||||
sqlalchemy_mixins
|
||||
sqlmodel
|
||||
|
||||
742
core_requirements.txt
Normal file
742
core_requirements.txt
Normal 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
|
||||
192
src/backend/core/pdf_cache.py
Normal file
192
src/backend/core/pdf_cache.py
Normal 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()
|
||||
349
src/backend/core/pdf_service.py
Normal file
349
src/backend/core/pdf_service.py
Normal 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()
|
||||
@ -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",
|
||||
},
|
||||
)
|
||||
|
||||
@ -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 %}
|
||||
Reference in New Issue
Block a user