Spaces:
Sleeping
Sleeping
Upload 24 files
Browse files- .DS_Store +0 -0
- .gitattributes +35 -0
- .gitignore +1 -0
- Dockerfile +17 -0
- README.md +10 -0
- app/CurrencyMapping/__init__.py +17 -0
- app/CurrencyMapping/currency_mapping.py +538 -0
- app/DocumentGeneration/__init__.py +56 -0
- app/DocumentGeneration/chart_generator.py +384 -0
- app/DocumentGeneration/document_helpers.py +159 -0
- app/DocumentGeneration/grouped.py +305 -0
- app/DocumentGeneration/live_pdf_generator.py +132 -0
- app/DocumentGeneration/markdown_renderer.py +759 -0
- app/DocumentGeneration/mixed.py +6 -0
- app/DocumentGeneration/pdf_generator.py +688 -0
- app/DocumentGeneration/table_of_content.py +205 -0
- app/DocumentGeneration/word_generator.py +253 -0
- app/__init__.py +29 -0
- app/assets/logos/MBD logo.png +0 -0
- app/constants.py +41 -0
- app/main.py +1874 -0
- app/models.py +82 -0
- app/utils.py +42 -0
- requirements.txt +20 -0
.DS_Store
ADDED
|
Binary file (6.15 kB). View file
|
|
|
.gitattributes
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
*.7z filter=lfs diff=lfs merge=lfs -text
|
| 2 |
+
*.arrow filter=lfs diff=lfs merge=lfs -text
|
| 3 |
+
*.bin filter=lfs diff=lfs merge=lfs -text
|
| 4 |
+
*.bz2 filter=lfs diff=lfs merge=lfs -text
|
| 5 |
+
*.ckpt filter=lfs diff=lfs merge=lfs -text
|
| 6 |
+
*.ftz filter=lfs diff=lfs merge=lfs -text
|
| 7 |
+
*.gz filter=lfs diff=lfs merge=lfs -text
|
| 8 |
+
*.h5 filter=lfs diff=lfs merge=lfs -text
|
| 9 |
+
*.joblib filter=lfs diff=lfs merge=lfs -text
|
| 10 |
+
*.lfs.* filter=lfs diff=lfs merge=lfs -text
|
| 11 |
+
*.mlmodel filter=lfs diff=lfs merge=lfs -text
|
| 12 |
+
*.model filter=lfs diff=lfs merge=lfs -text
|
| 13 |
+
*.msgpack filter=lfs diff=lfs merge=lfs -text
|
| 14 |
+
*.npy filter=lfs diff=lfs merge=lfs -text
|
| 15 |
+
*.npz filter=lfs diff=lfs merge=lfs -text
|
| 16 |
+
*.onnx filter=lfs diff=lfs merge=lfs -text
|
| 17 |
+
*.ot filter=lfs diff=lfs merge=lfs -text
|
| 18 |
+
*.parquet filter=lfs diff=lfs merge=lfs -text
|
| 19 |
+
*.pb filter=lfs diff=lfs merge=lfs -text
|
| 20 |
+
*.pickle filter=lfs diff=lfs merge=lfs -text
|
| 21 |
+
*.pkl filter=lfs diff=lfs merge=lfs -text
|
| 22 |
+
*.pt filter=lfs diff=lfs merge=lfs -text
|
| 23 |
+
*.pth filter=lfs diff=lfs merge=lfs -text
|
| 24 |
+
*.rar filter=lfs diff=lfs merge=lfs -text
|
| 25 |
+
*.safetensors filter=lfs diff=lfs merge=lfs -text
|
| 26 |
+
saved_model/**/* filter=lfs diff=lfs merge=lfs -text
|
| 27 |
+
*.tar.* filter=lfs diff=lfs merge=lfs -text
|
| 28 |
+
*.tar filter=lfs diff=lfs merge=lfs -text
|
| 29 |
+
*.tflite filter=lfs diff=lfs merge=lfs -text
|
| 30 |
+
*.tgz filter=lfs diff=lfs merge=lfs -text
|
| 31 |
+
*.wasm filter=lfs diff=lfs merge=lfs -text
|
| 32 |
+
*.xz filter=lfs diff=lfs merge=lfs -text
|
| 33 |
+
*.zip filter=lfs diff=lfs merge=lfs -text
|
| 34 |
+
*.zst filter=lfs diff=lfs merge=lfs -text
|
| 35 |
+
*tfevents* filter=lfs diff=lfs merge=lfs -text
|
.gitignore
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
.env
|
Dockerfile
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
FROM python:3.11.8-slim
|
| 2 |
+
|
| 3 |
+
# Set working directory
|
| 4 |
+
WORKDIR /app
|
| 5 |
+
|
| 6 |
+
# Install dependencies
|
| 7 |
+
COPY requirements.txt .
|
| 8 |
+
RUN pip install --no-cache-dir -r requirements.txt
|
| 9 |
+
|
| 10 |
+
# Copy the FastAPI app
|
| 11 |
+
COPY ./app /app
|
| 12 |
+
|
| 13 |
+
# Expose Hugging Face-required port
|
| 14 |
+
EXPOSE 7860
|
| 15 |
+
|
| 16 |
+
# Run the app
|
| 17 |
+
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "7860"]
|
README.md
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
---
|
| 2 |
+
title: BP FastAPI
|
| 3 |
+
emoji: 📈
|
| 4 |
+
colorFrom: purple
|
| 5 |
+
colorTo: blue
|
| 6 |
+
sdk: docker
|
| 7 |
+
pinned: false
|
| 8 |
+
license: other
|
| 9 |
+
short_description: Fast API Business Plan Generator
|
| 10 |
+
---
|
app/CurrencyMapping/__init__.py
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Currency Mapping Package
|
| 2 |
+
|
| 3 |
+
import os
|
| 4 |
+
import sys
|
| 5 |
+
|
| 6 |
+
# Add the current directory to sys.path to handle the space in folder name
|
| 7 |
+
current_dir = os.path.dirname(__file__)
|
| 8 |
+
if current_dir not in sys.path:
|
| 9 |
+
sys.path.insert(0, current_dir)
|
| 10 |
+
|
| 11 |
+
# Import the main function for easy access
|
| 12 |
+
from currency_mapping import convert_currency_symbols_to_iso, canonical_currency_map
|
| 13 |
+
|
| 14 |
+
__all__ = [
|
| 15 |
+
'convert_currency_symbols_to_iso',
|
| 16 |
+
'canonical_currency_map'
|
| 17 |
+
]
|
app/CurrencyMapping/currency_mapping.py
ADDED
|
@@ -0,0 +1,538 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Currency Mapping Module
|
| 2 |
+
# Contains canonical_currency_map dictionary and convert_currency_symbols_to_iso() function
|
| 3 |
+
|
| 4 |
+
# Canonical currency map for symbol resolution (matches TypeScript version)
|
| 5 |
+
canonical_currency_map = {
|
| 6 |
+
# Standard currency symbols
|
| 7 |
+
"$": "USD",
|
| 8 |
+
"£": "GBP",
|
| 9 |
+
"€": "EUR",
|
| 10 |
+
"¥": "JPY",
|
| 11 |
+
"₹": "INR",
|
| 12 |
+
"₽": "RUB",
|
| 13 |
+
"₩": "KRW",
|
| 14 |
+
"₪": "ILS",
|
| 15 |
+
"₺": "TRY",
|
| 16 |
+
"₴": "UAH",
|
| 17 |
+
"₼": "AZN",
|
| 18 |
+
"₸": "KZT",
|
| 19 |
+
"₾": "GEL",
|
| 20 |
+
"֏": "AMD",
|
| 21 |
+
"₲": "PYG",
|
| 22 |
+
"₡": "CRC",
|
| 23 |
+
"₣": "XPF",
|
| 24 |
+
"₦": "NGN",
|
| 25 |
+
"₵": "GHS",
|
| 26 |
+
"₨": "PKR",
|
| 27 |
+
"₱": "PHP",
|
| 28 |
+
"₫": "VND",
|
| 29 |
+
"₭": "LAK",
|
| 30 |
+
"៛": "KHR",
|
| 31 |
+
"৳": "BDT",
|
| 32 |
+
"؋": "AFN",
|
| 33 |
+
"﷼": "IRR",
|
| 34 |
+
|
| 35 |
+
# Custom symbols
|
| 36 |
+
"‡": "CUSTOM_DAGGER",
|
| 37 |
+
"†": "CUSTOM_DAGGER_SINGLE",
|
| 38 |
+
|
| 39 |
+
# Letter-based currencies
|
| 40 |
+
"R": "ZAR", # South African Rand
|
| 41 |
+
"C$": "CAD", # Canadian Dollar
|
| 42 |
+
"A$": "AUD", # Australian Dollar
|
| 43 |
+
"R$": "BRL", # Brazilian Real
|
| 44 |
+
"S/": "PEN", # Peruvian Sol
|
| 45 |
+
"Bs": "BOB", # Bolivian Boliviano
|
| 46 |
+
"Q": "GTQ", # Guatemalan Quetzal
|
| 47 |
+
"L": "HNL", # Honduran Lempira
|
| 48 |
+
"ƒ": "AWG", # Aruban Florin
|
| 49 |
+
"Vt": "VUV", # Vanuatu Vatu
|
| 50 |
+
"T$": "TOP", # Tongan Pa'anga
|
| 51 |
+
"T": "WST", # Samoan Tala
|
| 52 |
+
"лв": "BGN", # Bulgarian Lev
|
| 53 |
+
"Kč": "CZK", # Czech Koruna
|
| 54 |
+
"kr": "DKK", # Danish Krone
|
| 55 |
+
"Ft": "HUF", # Hungarian Forint
|
| 56 |
+
"zł": "PLN", # Polish Zloty
|
| 57 |
+
"lei": "RON", # Romanian Leu
|
| 58 |
+
"CHF": "CHF", # Swiss Franc
|
| 59 |
+
"KM": "BAM", # Bosnian Marka
|
| 60 |
+
"ден": "MKD", # North Macedonian Denar
|
| 61 |
+
"дин": "RSD", # Serbian Dinar
|
| 62 |
+
"so'm": "UZS", # Uzbekistani Som
|
| 63 |
+
"NT$": "TWD", # Taiwan Dollar
|
| 64 |
+
"HK$": "HKD", # Hong Kong Dollar
|
| 65 |
+
"S$": "SGD", # Singapore Dollar
|
| 66 |
+
"RM": "MYR", # Malaysian Ringgit
|
| 67 |
+
"฿": "THB", # Thai Baht
|
| 68 |
+
"Rp": "IDR", # Indonesian Rupiah
|
| 69 |
+
"K": "MMK", # Myanmar Kyat
|
| 70 |
+
"Rs": "LKR", # Sri Lankan Rupee
|
| 71 |
+
"Nu": "BTN", # Bhutanese Ngultrum
|
| 72 |
+
"MVR": "MVR", # Maldivian Rufiyaa
|
| 73 |
+
|
| 74 |
+
# Middle Eastern currencies
|
| 75 |
+
"ع.د": "IQD", # Iraqi Dinar
|
| 76 |
+
"ل.ل": "LBP", # Lebanese Pound
|
| 77 |
+
"د.ا": "JOD", # Jordanian Dinar
|
| 78 |
+
"ر.س": "SAR", # Saudi Riyal
|
| 79 |
+
"د.إ": "AED", # UAE Dirham
|
| 80 |
+
"ر.ق": "QAR", # Qatari Riyal
|
| 81 |
+
"د.ك": "KWD", # Kuwaiti Dinar
|
| 82 |
+
"د.ب": "BHD", # Bahraini Dinar
|
| 83 |
+
"ر.ع.": "OMR", # Omani Rial
|
| 84 |
+
"ر.ي": "YER", # Yemeni Rial
|
| 85 |
+
"ج.م": "EGP", # Egyptian Pound
|
| 86 |
+
"ل.د": "LYD", # Libyan Dinar
|
| 87 |
+
"د.ت": "TND", # Tunisian Dinar
|
| 88 |
+
"د.ج": "DZD", # Algerian Dinar
|
| 89 |
+
"د.م.": "MAD", # Moroccan Dirham
|
| 90 |
+
"ج.س.": "SDG", # Sudanese Pound
|
| 91 |
+
"SSP": "SSP", # South Sudanese Pound
|
| 92 |
+
|
| 93 |
+
# African currencies
|
| 94 |
+
"KSh": "KES", # Kenyan Shilling
|
| 95 |
+
"USh": "UGX", # Ugandan Shilling
|
| 96 |
+
"TSh": "TZS", # Tanzanian Shilling
|
| 97 |
+
"FRw": "RWF", # Rwandan Franc
|
| 98 |
+
"FBu": "BIF", # Burundian Franc
|
| 99 |
+
"MT": "MZN", # Mozambican Metical
|
| 100 |
+
"P": "BWP", # Botswanan Pula
|
| 101 |
+
"N$": "NAD", # Namibian Dollar
|
| 102 |
+
"Ar": "MGA", # Malagasy Ariary
|
| 103 |
+
"CF": "KMF", # Comorian Franc
|
| 104 |
+
"MK": "MWK", # Malawian Kwacha
|
| 105 |
+
"ZK": "ZMW", # Zambian Kwacha
|
| 106 |
+
"Kz": "AOA", # Angolan Kwanza
|
| 107 |
+
"FC": "CDF", # Congolese Franc
|
| 108 |
+
"FCFA": "XAF", # Central African Franc
|
| 109 |
+
"Db": "STD", # Sao Tomean Dobra
|
| 110 |
+
"D": "GMD", # Gambian Dalasi
|
| 111 |
+
"FG": "GNF", # Guinean Franc
|
| 112 |
+
"CFA": "XOF", # West African Franc
|
| 113 |
+
"Le": "SLL", # Sierra Leonean Leone
|
| 114 |
+
"L$": "LRD", # Liberian Dollar
|
| 115 |
+
"Br": "ETB", # Ethiopian Birr
|
| 116 |
+
"Nfk": "ERN", # Eritrean Nakfa
|
| 117 |
+
"Fdj": "DJF", # Djiboutian Franc
|
| 118 |
+
"S": "SOS", # Somali Shilling
|
| 119 |
+
|
| 120 |
+
# Caribbean & Central American currencies
|
| 121 |
+
"J$": "JMD", # Jamaican Dollar
|
| 122 |
+
"BZ$": "BZD", # Belize Dollar
|
| 123 |
+
"TT$": "TTD", # Trinidad and Tobago Dollar
|
| 124 |
+
"Bds$": "BBD", # Barbadian Dollar
|
| 125 |
+
"EC$": "XCD", # East Caribbean Dollar
|
| 126 |
+
"GY$": "GYD", # Guyanese Dollar
|
| 127 |
+
"SRD": "SRD", # Surinamese Dollar
|
| 128 |
+
"RD$": "DOP", # Dominican Peso
|
| 129 |
+
|
| 130 |
+
# Pacific Island currencies
|
| 131 |
+
"FJ$": "FJD", # Fijian Dollar
|
| 132 |
+
"PGK": "PGK", # Papua New Guinean Kina
|
| 133 |
+
"SI$": "SBD", # Solomon Islands Dollar
|
| 134 |
+
"WS$": "WST", # Samoan Tala
|
| 135 |
+
"VT": "VUV", # Vanuatu Vatu
|
| 136 |
+
|
| 137 |
+
# European currencies
|
| 138 |
+
"ISK": "ISK", # Icelandic Krona
|
| 139 |
+
"Lt": "LTL", # Lithuanian Litas
|
| 140 |
+
"Ls": "LVL", # Latvian Lats
|
| 141 |
+
"EEK": "EEK", # Estonian Kroon
|
| 142 |
+
"NOK": "NOK", # Norwegian Krone
|
| 143 |
+
"SEK": "SEK", # Swedish Krona
|
| 144 |
+
"ALL": "ALL", # Albanian Lek
|
| 145 |
+
|
| 146 |
+
# South American currencies
|
| 147 |
+
"Gs": "PYG", # Paraguayan Guarani
|
| 148 |
+
"Bs.S": "VES", # Venezuelan Bolívar Soberano
|
| 149 |
+
"Bs": "VEF", # Venezuelan Bolívar
|
| 150 |
+
"$U": "UYU", # Uruguayan Peso
|
| 151 |
+
"$a": "ARS", # Argentine Peso
|
| 152 |
+
"$b": "BOB", # Bolivian Boliviano
|
| 153 |
+
"CLP": "CLP", # Chilean Peso
|
| 154 |
+
"COP": "COP", # Colombian Peso
|
| 155 |
+
"MXN": "MXN", # Mexican Peso
|
| 156 |
+
|
| 157 |
+
# Small Island Nations
|
| 158 |
+
"BMD": "BMD", # Bermudian Dollar
|
| 159 |
+
"BSD": "BSD", # Bahamian Dollar
|
| 160 |
+
"KYD": "KYD", # Cayman Islands Dollar
|
| 161 |
+
"TVD": "TVD", # Tuvaluan Dollar
|
| 162 |
+
"ZWL": "ZWL", # Zimbabwean Dollar
|
| 163 |
+
|
| 164 |
+
# Additional African currencies
|
| 165 |
+
"LSL": "LSL", # Lesotho Loti
|
| 166 |
+
"SZL": "SZL", # Swazi Lilangeni
|
| 167 |
+
"CVE": "CVE", # Cape Verdean Escudo
|
| 168 |
+
"MRO": "MRO", # Mauritanian Ouguiya
|
| 169 |
+
"MRU": "MRU", # Mauritanian Ouguiya
|
| 170 |
+
"SCR": "SCR", # Seychellois Rupee
|
| 171 |
+
"SHP": "SHP", # Saint Helena Pound
|
| 172 |
+
|
| 173 |
+
# Additional Asian currencies
|
| 174 |
+
"BND": "BND", # Brunei Dollar
|
| 175 |
+
"MOP": "MOP", # Macanese Pataca
|
| 176 |
+
"NPR": "NPR", # Nepalese Rupee
|
| 177 |
+
|
| 178 |
+
# Additional Pacific currencies
|
| 179 |
+
"NZD": "NZD", # New Zealand Dollar
|
| 180 |
+
|
| 181 |
+
# Alternative $ symbols for countries (context-dependent)
|
| 182 |
+
"$BMD": "BMD", # Bermudian Dollar
|
| 183 |
+
"$BSD": "BSD", # Bahamian Dollar
|
| 184 |
+
"$BZD": "BZD", # Belize Dollar
|
| 185 |
+
"$KYD": "KYD", # Cayman Islands Dollar
|
| 186 |
+
"$FJD": "FJD", # Fijian Dollar
|
| 187 |
+
"$GYD": "GYD", # Guyanese Dollar
|
| 188 |
+
"$JMD": "JMD", # Jamaican Dollar
|
| 189 |
+
"$LRD": "LRD", # Liberian Dollar
|
| 190 |
+
"$NAD": "NAD", # Namibian Dollar
|
| 191 |
+
"$SBD": "SBD", # Solomon Islands Dollar
|
| 192 |
+
"$SRD": "SRD", # Surinamese Dollar
|
| 193 |
+
"$TTD": "TTD", # Trinidad and Tobago Dollar
|
| 194 |
+
"$TVD": "TVD", # Tuvaluan Dollar
|
| 195 |
+
"$VUV": "VUV", # Vanuatu Vatu
|
| 196 |
+
"$WST": "WST", # Samoan Tala
|
| 197 |
+
"$XCD": "XCD", # East Caribbean Dollar
|
| 198 |
+
"$ZWL": "ZWL", # Zimbabwean Dollar
|
| 199 |
+
"$BWP": "BWP", # Botswanan Pula
|
| 200 |
+
"$LSL": "LSL", # Lesotho Loti
|
| 201 |
+
"$SZL": "SZL", # Swazi Lilangeni
|
| 202 |
+
"$ZAR": "ZAR", # South African Rand
|
| 203 |
+
"$HKD": "HKD", # Hong Kong Dollar
|
| 204 |
+
"$SGD": "SGD", # Singapore Dollar
|
| 205 |
+
"$TWD": "TWD", # Taiwan Dollar
|
| 206 |
+
"$CHF": "CHF", # Swiss Franc
|
| 207 |
+
"$DKK": "DKK", # Danish Krone
|
| 208 |
+
"$NOK": "NOK", # Norwegian Krone
|
| 209 |
+
"$SEK": "SEK", # Swedish Krona
|
| 210 |
+
"$ARS": "ARS", # Argentine Peso
|
| 211 |
+
"$BOB": "BOB", # Bolivian Boliviano
|
| 212 |
+
"$CLP": "CLP", # Chilean Peso
|
| 213 |
+
"$COP": "COP", # Colombian Peso
|
| 214 |
+
"$MXN": "MXN", # Mexican Peso
|
| 215 |
+
"$PEN": "PEN", # Peruvian Sol
|
| 216 |
+
"$UYU": "UYU", # Uruguayan Peso
|
| 217 |
+
"$VES": "VES", # Venezuelan Bolívar Soberano
|
| 218 |
+
|
| 219 |
+
# Cryptocurrencies (for modern business plans)
|
| 220 |
+
"₿": "BTC", # Bitcoin
|
| 221 |
+
"Ξ": "ETH", # Ethereum
|
| 222 |
+
"Ł": "LTC", # Litecoin
|
| 223 |
+
|
| 224 |
+
# Special Drawing Rights
|
| 225 |
+
"XDR": "XDR", # Special Drawing Rights
|
| 226 |
+
|
| 227 |
+
# Precious Metals
|
| 228 |
+
"XAU": "XAU", # Gold (troy ounce)
|
| 229 |
+
"XAG": "XAG", # Silver (troy ounce)
|
| 230 |
+
"XPT": "XPT", # Platinum (troy ounce)
|
| 231 |
+
"XPD": "XPD", # Palladium (troy ounce)
|
| 232 |
+
|
| 233 |
+
# Other
|
| 234 |
+
"XTS": "XTS", # Testing Currency Code
|
| 235 |
+
"XXX": "XXX", # No Currency
|
| 236 |
+
|
| 237 |
+
# Missing currencies from canonical_currency_map.py
|
| 238 |
+
"ADP": "ADP", # Andorran Peseta
|
| 239 |
+
"AFA": "AFA", # Afghan Afghani (1927–2002)
|
| 240 |
+
"ALK": "ALK", # Albanian Lek (1946–1965)
|
| 241 |
+
"ANG": "ANG", # Netherlands Antillean Guilder
|
| 242 |
+
"AOK": "AOK", # Angolan Kwanza (1977–1991)
|
| 243 |
+
"AON": "AON", # Angolan New Kwanza (1990–2000)
|
| 244 |
+
"AOR": "AOR", # Angolan Readjusted Kwanza (1995–1999)
|
| 245 |
+
"ARA": "ARA", # Argentine Austral
|
| 246 |
+
"ARL": "ARL", # Argentine Peso Ley (1970–1983)
|
| 247 |
+
"ARM": "ARM", # Argentine Peso (1881–1970)
|
| 248 |
+
"ARP": "ARP", # Argentine Peso (1983–1985)
|
| 249 |
+
"ATS": "ATS", # Austrian Schilling
|
| 250 |
+
"AZM": "AZM", # Azerbaijani Manat (1993–2006)
|
| 251 |
+
"BAD": "BAD", # Bosnia-Herzegovina Dinar (1992–1994)
|
| 252 |
+
"BAN": "BAN", # Bosnia-Herzegovina New Dinar (1994–1997)
|
| 253 |
+
"BEC": "BEC", # Belgian Franc (convertible)
|
| 254 |
+
"BEF": "BEF", # Belgian Franc
|
| 255 |
+
"BEL": "BEL", # Belgian Franc (financial)
|
| 256 |
+
"BGL": "BGL", # Bulgarian Hard Lev
|
| 257 |
+
"BGM": "BGM", # Bulgarian Socialist Lev
|
| 258 |
+
"BGO": "BGO", # Bulgarian Lev (1879–1952)
|
| 259 |
+
"BOL": "BOL", # Bolivian Boliviano (1863–1963)
|
| 260 |
+
"BOP": "BOP", # Bolivian Peso
|
| 261 |
+
"BOV": "BOV", # Bolivian Mvdol
|
| 262 |
+
"BRB": "BRB", # Brazilian New Cruzeiro (1967–1986)
|
| 263 |
+
"BRC": "BRC", # Brazilian Cruzado (1986–1989)
|
| 264 |
+
"BRE": "BRE", # Brazilian Cruzeiro (1990–1993)
|
| 265 |
+
"BRN": "BRN", # Brazilian New Cruzado (1989–1990)
|
| 266 |
+
"BRR": "BRR", # Brazilian Cruzeiro (1993–1994)
|
| 267 |
+
"BRZ": "BRZ", # Brazilian Cruzeiro (1942–1967)
|
| 268 |
+
"BUK": "BUK", # Burmese Kyat
|
| 269 |
+
"BYB": "BYB", # Belarusian Ruble (1994–1999)
|
| 270 |
+
"BYN": "BYN", # Belarusian Ruble
|
| 271 |
+
"BYR": "BYR", # Belarusian Ruble (2000–2016)
|
| 272 |
+
"CA$": "CAD", # Canadian Dollar
|
| 273 |
+
"CHE": "CHE", # WIR Euro
|
| 274 |
+
"CHW": "CHW", # WIR Franc
|
| 275 |
+
"CLE": "CLE", # Chilean Escudo
|
| 276 |
+
"CLF": "CLF", # Chilean Unit of Account (UF)
|
| 277 |
+
"CNH": "CNH", # Chinese Yuan (offshore)
|
| 278 |
+
"CNX": "CNX", # Chinese People's Bank Dollar
|
| 279 |
+
"CN¥": "CNY", # Chinese Yuan
|
| 280 |
+
"COU": "COU", # Colombian Real Value Unit
|
| 281 |
+
"CSD": "CSD", # Serbian Dinar (2002–2006)
|
| 282 |
+
"CSK": "CSK", # Czechoslovak Hard Koruna
|
| 283 |
+
"CUC": "CUC", # Cuban Convertible Peso
|
| 284 |
+
"CUP": "CUP", # Cuban Peso
|
| 285 |
+
"CYP": "CYP", # Cypriot Pound
|
| 286 |
+
"DDM": "DDM", # East German Mark
|
| 287 |
+
"DEM": "DEM", # German Mark
|
| 288 |
+
"ECS": "ECS", # Ecuadorian Sucre
|
| 289 |
+
"ECV": "ECV", # Ecuadorian Unit of Constant Value
|
| 290 |
+
"ESA": "ESA", # Spanish Peseta (A account)
|
| 291 |
+
"ESB": "ESB", # Spanish Peseta (convertible account)
|
| 292 |
+
"ESP": "ESP", # Spanish Peseta
|
| 293 |
+
"FIM": "FIM", # Finnish Markka
|
| 294 |
+
"FKP": "FKP", # Falkland Islands Pound
|
| 295 |
+
"FRF": "FRF", # French Franc
|
| 296 |
+
"GEK": "GEK", # Georgian Kupon Larit
|
| 297 |
+
"GHC": "GHC", # Ghanaian Cedi (1979–2007)
|
| 298 |
+
"GIP": "GIP", # Gibraltar Pound
|
| 299 |
+
"GNS": "GNS", # Guinean Syli
|
| 300 |
+
"GQE": "GQE", # Equatorial Guinean Ekwele
|
| 301 |
+
"GRD": "GRD", # Greek Drachma
|
| 302 |
+
"GWE": "GWE", # Portuguese Guinea Escudo
|
| 303 |
+
"GWP": "GWP", # Guinea-Bissau Peso
|
| 304 |
+
"HRD": "HRD", # Croatian Dinar
|
| 305 |
+
"HRK": "HRK", # Croatian Kuna
|
| 306 |
+
"HTG": "HTG", # Haitian Gourde
|
| 307 |
+
"IEP": "IEP", # Irish Pound
|
| 308 |
+
"ILP": "ILP", # Israeli Pound
|
| 309 |
+
"ILR": "ILR", # Israeli Shekel (1980–1985)
|
| 310 |
+
"ISJ": "ISJ", # Icelandic Króna (1918–1981)
|
| 311 |
+
"ITL": "ITL", # Italian Lira
|
| 312 |
+
"KGS": "KGS", # Kyrgystani Som
|
| 313 |
+
"KPW": "KPW", # North Korean Won
|
| 314 |
+
"KRH": "KRH", # South Korean Hwan (1953–1962)
|
| 315 |
+
"KRO": "KRO", # South Korean Won (1945–1953)
|
| 316 |
+
"LTT": "LTT", # Lithuanian Talonas
|
| 317 |
+
"LUC": "LUC", # Luxembourgian Convertible Franc
|
| 318 |
+
"LUF": "LUF", # Luxembourgian Franc
|
| 319 |
+
"LUL": "LUL", # Luxembourg Financial Franc
|
| 320 |
+
"LVR": "LVR", # Latvian Ruble
|
| 321 |
+
"MAF": "MAF", # Moroccan Franc
|
| 322 |
+
"MCF": "MCF", # Monegasque Franc
|
| 323 |
+
"MDC": "MDC", # Moldovan Cupon
|
| 324 |
+
"MDL": "MDL", # Moldovan Leu
|
| 325 |
+
"MGF": "MGF", # Malagasy Franc
|
| 326 |
+
"MKN": "MKN", # Macedonian Denar (1992–1993)
|
| 327 |
+
"MLF": "MLF", # Malian Franc
|
| 328 |
+
"MNT": "MNT", # Mongolian Tugrik
|
| 329 |
+
"MTL": "MTL", # Maltese Lira
|
| 330 |
+
"MTP": "MTP", # Maltese Pound
|
| 331 |
+
"MUR": "MUR", # Mauritian Rupee
|
| 332 |
+
"MVP": "MVP", # Maldivian Rupee (1947–1981)
|
| 333 |
+
"MX$": "MXN", # Mexican Peso
|
| 334 |
+
"MXP": "MXP", # Mexican Silver Peso (1861–1992)
|
| 335 |
+
"MXV": "MXV", # Mexican Investment Unit
|
| 336 |
+
"MZE": "MZE", # Mozambican Escudo
|
| 337 |
+
"MZM": "MZM", # Mozambican Metical (1980–2006)
|
| 338 |
+
"NIC": "NIC", # Nicaraguan Córdoba (1988–1991)
|
| 339 |
+
"NIO": "NIO", # Nicaraguan Córdoba
|
| 340 |
+
"NLG": "NLG", # Dutch Guilder
|
| 341 |
+
"NZ$": "NZD", # New Zealand Dollar
|
| 342 |
+
"PAB": "PAB", # Panamanian Balboa
|
| 343 |
+
"PEI": "PEI", # Peruvian Inti
|
| 344 |
+
"PES": "PES", # Peruvian Sol (1863–1965)
|
| 345 |
+
"PLZ": "PLZ", # Polish Zloty (1950–1995)
|
| 346 |
+
"PTE": "PTE", # Portuguese Escudo
|
| 347 |
+
"RHD": "RHD", # Rhodesian Dollar
|
| 348 |
+
"ROL": "ROL", # Romanian Leu (1952–2006)
|
| 349 |
+
"RUR": "RUR", # Russian Ruble (1991–1998)
|
| 350 |
+
"SDD": "SDD", # Sudanese Dinar (1992–2007)
|
| 351 |
+
"SDP": "SDP", # Sudanese Pound (1957–1998)
|
| 352 |
+
"SIT": "SIT", # Slovenian Tolar
|
| 353 |
+
"SKK": "SKK", # Slovak Koruna
|
| 354 |
+
"SLE": "SLE", # Sierra Leonean Leone
|
| 355 |
+
"SRG": "SRG", # Surinamese Guilder
|
| 356 |
+
"STN": "STN", # São Tomé & Príncipe Dobra
|
| 357 |
+
"SUR": "SUR", # Soviet Rouble
|
| 358 |
+
"SVC": "SVC", # Salvadoran Colón
|
| 359 |
+
"SYP": "SYP", # Syrian Pound
|
| 360 |
+
"TJR": "TJR", # Tajikistani Ruble
|
| 361 |
+
"TJS": "TJS", # Tajikistani Somoni
|
| 362 |
+
"TMM": "TMM", # Turkmenistani Manat (1993–2009)
|
| 363 |
+
"TMT": "TMT", # Turkmenistani Manat
|
| 364 |
+
"TPE": "TPE", # Timorese Escudo
|
| 365 |
+
"TRL": "TRL", # Turkish Lira (1922–2005)
|
| 366 |
+
"UAK": "UAK", # Ukrainian Karbovanets
|
| 367 |
+
"UGS": "UGS", # Ugandan Shilling (1966–1987)
|
| 368 |
+
"USN": "USN", # US Dollar (Next day)
|
| 369 |
+
"USS": "USS", # US Dollar (Same day)
|
| 370 |
+
"UYI": "UYI", # Uruguayan Peso (Indexed Units)
|
| 371 |
+
"UYP": "UYP", # Uruguayan Peso (1975–1993)
|
| 372 |
+
"UYW": "UYW", # Uruguayan Nominal Wage Index Unit
|
| 373 |
+
"VEB": "VEB", # Venezuelan Bolívar (1871–2008)
|
| 374 |
+
"VED": "VED", # Bolívar Soberano
|
| 375 |
+
"VNN": "VNN", # Vietnamese Dong (1978–1985)
|
| 376 |
+
"XBA": "XBA", # European Composite Unit
|
| 377 |
+
"XBB": "XBB", # European Monetary Unit
|
| 378 |
+
"XBC": "XBC", # European Unit of Account (XBC)
|
| 379 |
+
"XBD": "XBD", # European Unit of Account (XBD)
|
| 380 |
+
"Cg.": "XCG", # Caribbean guilder
|
| 381 |
+
"XEU": "XEU", # European Currency Unit
|
| 382 |
+
"XFO": "XFO", # French Gold Franc
|
| 383 |
+
"XFU": "XFU", # French UIC-Franc
|
| 384 |
+
"F CFA": "XOF", # West African CFA Franc
|
| 385 |
+
"XRE": "XRE", # RINET Funds
|
| 386 |
+
"XSU": "XSU", # Sucre
|
| 387 |
+
"XUA": "XUA", # ADB Unit of Account
|
| 388 |
+
"¤": "XXX", # Unknown Currency
|
| 389 |
+
"YDD": "YDD", # Yemeni Dinar
|
| 390 |
+
"YUD": "YUD", # Yugoslavian Hard Dinar (1966–1990)
|
| 391 |
+
"YUM": "YUM", # Yugoslavian New Dinar (1994–2002)
|
| 392 |
+
"YUN": "YUN", # Yugoslavian Convertible Dinar (1990–1992)
|
| 393 |
+
"YUR": "YUR", # Yugoslavian Reformed Dinar (1992–1993)
|
| 394 |
+
"ZAL": "ZAL", # South African Rand (financial)
|
| 395 |
+
"ZMK": "ZMK", # Zambian Kwacha (1968–2012)
|
| 396 |
+
"ZRN": "ZRN", # Zairean New Zaire (1993–1998)
|
| 397 |
+
"ZRZ": "ZRZ", # Zairean Zaire (1971–1993)
|
| 398 |
+
"ZWD": "ZWD", # Zimbabwean Dollar (1980–2008)
|
| 399 |
+
"ZWG": "ZWG", # Zimbabwean Gold
|
| 400 |
+
"ZWR": "ZWR", # Zimbabwean Dollar (2008)
|
| 401 |
+
"US$": "USD", # US Dollar
|
| 402 |
+
"C$": "NIO", # Nicaraguan Córdoba
|
| 403 |
+
"TT$": "TTD", # Trinidad & Tobago Dollar
|
| 404 |
+
"BD$": "BBD", # Barbadian Dollar
|
| 405 |
+
"B$": "BND", # Brunei Dollar
|
| 406 |
+
"SI$": "SBD", # Solomon Islands Dollar
|
| 407 |
+
"G$": "GYD", # Guyanaese Dollar
|
| 408 |
+
"SR$": "SRD", # Surinamese Dollar
|
| 409 |
+
"CLP$": "CLP", # Chilean Peso
|
| 410 |
+
"Bs.": "VES", # Venezuelan Bolívar
|
| 411 |
+
"Rf": "MVR", # Maldivian Rufiyaa
|
| 412 |
+
"SSh": "SOS", # Somali Shilling
|
| 413 |
+
"GH₵": "GHS", # Ghanaian Cedi
|
| 414 |
+
"Br": "BYN", # Belarusian Ruble
|
| 415 |
+
"CFA F": "XOF", # West African CFA Franc
|
| 416 |
+
"CFAF": "XAF", # Central African CFA Franc
|
| 417 |
+
"JP¥": "JPY", # Japanese Yen
|
| 418 |
+
"元": "TWD", # New Taiwan Dollar
|
| 419 |
+
"TL": "TRY", # Turkish Lira
|
| 420 |
+
"лв.": "BGN", # Bulgarian Lev
|
| 421 |
+
"₮": "MNT", # Mongolian Tugrik
|
| 422 |
+
"﷼": "SAR", # Saudi Riyal
|
| 423 |
+
".د.ب": "BHD", # Bahraini Dinar
|
| 424 |
+
"ل.س": "SYP", # Syrian Pound
|
| 425 |
+
"﷼": "IRR", # Iranian Rial
|
| 426 |
+
"ש״ח": "ILS", # Israeli New Shekel
|
| 427 |
+
"Fr": "CHF", # Swiss Franc
|
| 428 |
+
"CFP": "XPF", # CFP Franc
|
| 429 |
+
"E": "SZL", # Swazi Lilangeni
|
| 430 |
+
"Rf.": "MVR", # Maldivian Rufiyaa
|
| 431 |
+
"QR": "QAR", # Qatari Riyal
|
| 432 |
+
"SR": "SAR", # Saudi Riyal
|
| 433 |
+
"BD": "BHD", # Bahraini Dinar
|
| 434 |
+
"KD": "KWD", # Kuwaiti Dinar
|
| 435 |
+
"₯": "GRD", # Greek Drachma
|
| 436 |
+
"ЅМ": "TJS", # Tajikistani Somoni
|
| 437 |
+
"Br (BYN)": "BYN", # Belarusian Ruble
|
| 438 |
+
"B/.": "PAB", # Panamanian Balboa
|
| 439 |
+
"Gs.": "PYG", # Paraguayan Guarani
|
| 440 |
+
"Afl.": "AWG", # Aruban Florin
|
| 441 |
+
"NAƒ": "ANG", # Netherlands Antillean Guilder
|
| 442 |
+
"lei (MDL)": "MDL", # Moldovan Leu
|
| 443 |
+
"KM (BAM)": "BAM", # Bosnia-Herzegovina Convertible Mark
|
| 444 |
+
"ден (MKD)": "MKD", # Macedonian Denar
|
| 445 |
+
"kn": "HRK", # Croatian Kuna
|
| 446 |
+
"Z$": "ZWL", # Zimbabwean Dollar (2009–2024)
|
| 447 |
+
"RTGS$": "ZWL", # Zimbabwean Dollar (2009–2024)
|
| 448 |
+
"Skr": "SEK", # Swedish Krona
|
| 449 |
+
"Nkr": "NOK", # Norwegian Krone
|
| 450 |
+
"Dkr": "DKK", # Danish Krone
|
| 451 |
+
"Íkr": "ISK", # Icelandic Króna
|
| 452 |
+
"AU$": "AUD", # Australian Dollar
|
| 453 |
+
"SG$": "SGD", # Singapore Dollar
|
| 454 |
+
"BB$": "BBD", # Barbadian Dollar
|
| 455 |
+
"BS$": "BSD", # Bahamian Dollar
|
| 456 |
+
"KY$": "KYD", # Cayman Islands Dollar
|
| 457 |
+
"BM$": "BMD", # Bermudan Dollar
|
| 458 |
+
"JM$": "JMD", # Jamaican Dollar
|
| 459 |
+
}
|
| 460 |
+
|
| 461 |
+
def convert_currency_symbols_to_iso(text: str) -> str:
|
| 462 |
+
"""
|
| 463 |
+
Convert currency symbols to ISO codes for PDF compatibility
|
| 464 |
+
Matches the TypeScript canonicalCurrencyMap logic
|
| 465 |
+
"""
|
| 466 |
+
import re # Import at function level to avoid scope issues
|
| 467 |
+
processed_text = text
|
| 468 |
+
|
| 469 |
+
# Track all conversions for detailed logging
|
| 470 |
+
conversions_made = []
|
| 471 |
+
total_conversions = 0
|
| 472 |
+
|
| 473 |
+
# Process each currency symbol in the canonical map
|
| 474 |
+
for symbol, iso_code in canonical_currency_map.items():
|
| 475 |
+
if symbol in processed_text:
|
| 476 |
+
# Check if it's a single character
|
| 477 |
+
is_single_character = len(symbol) == 1
|
| 478 |
+
is_unicode_currency = is_single_character and ord(symbol) > 127 # Non-ASCII
|
| 479 |
+
is_latin_single_letter = is_single_character and symbol.isalpha() and ord(symbol) < 128
|
| 480 |
+
|
| 481 |
+
# Handle multi-character symbols (like "Le", "KSh", etc.)
|
| 482 |
+
is_multi_char_symbol = len(symbol) > 1
|
| 483 |
+
|
| 484 |
+
# Skip plain Latin single letters (to avoid converting words like "A", "R", etc.)
|
| 485 |
+
# but DO convert single-character Unicode currency symbols like €, £, ¥, ؋, etc.
|
| 486 |
+
if not is_unicode_currency and is_latin_single_letter:
|
| 487 |
+
# Only convert if it's followed by a number (currency context)
|
| 488 |
+
# Pattern: R500, R 500, R500,000 etc.
|
| 489 |
+
pattern = rf'\b{re.escape(symbol)}\s*(\d{{1,3}}(?:,?\d{{3}})*(?:\.\d{{2}})?)\b'
|
| 490 |
+
matches = re.findall(pattern, processed_text)
|
| 491 |
+
|
| 492 |
+
if matches:
|
| 493 |
+
old_text = processed_text
|
| 494 |
+
processed_text = re.sub(pattern, rf'{iso_code} \1', processed_text)
|
| 495 |
+
|
| 496 |
+
if old_text != processed_text:
|
| 497 |
+
conversions_made.append(f"{symbol} → {iso_code} (context: {matches})")
|
| 498 |
+
total_conversions += len(matches)
|
| 499 |
+
continue
|
| 500 |
+
|
| 501 |
+
# For multi-character symbols like "Ar", "Le", etc., be very careful
|
| 502 |
+
# Only convert if they appear as standalone words or in currency contexts
|
| 503 |
+
if is_multi_char_symbol and len(symbol) == 2 and symbol.isalpha():
|
| 504 |
+
# Only convert if it's a standalone word or followed by a number
|
| 505 |
+
# Pattern: \bAr\b or \bAr\s*\d or \bLe\b or \bLe\s*\d
|
| 506 |
+
standalone_pattern = rf'\b{re.escape(symbol)}\b'
|
| 507 |
+
currency_pattern = rf'\b{re.escape(symbol)}\s*(\d{{1,3}}(?:,?\d{{3}})*(?:\.\d{{2}})?)'
|
| 508 |
+
|
| 509 |
+
# Check for standalone occurrences first
|
| 510 |
+
standalone_matches = re.findall(standalone_pattern, processed_text)
|
| 511 |
+
currency_matches = re.findall(currency_pattern, processed_text)
|
| 512 |
+
|
| 513 |
+
if standalone_matches or currency_matches:
|
| 514 |
+
old_text = processed_text
|
| 515 |
+
# Replace standalone occurrences
|
| 516 |
+
processed_text = re.sub(standalone_pattern, iso_code, processed_text)
|
| 517 |
+
# Replace currency context occurrences
|
| 518 |
+
processed_text = re.sub(currency_pattern, rf'{iso_code} \1', processed_text)
|
| 519 |
+
|
| 520 |
+
if old_text != processed_text:
|
| 521 |
+
conversions_made.append(f"{symbol} → {iso_code} (standalone/currency context)")
|
| 522 |
+
total_conversions += len(standalone_matches) + len(currency_matches)
|
| 523 |
+
continue
|
| 524 |
+
|
| 525 |
+
# For all other symbols (Unicode currency symbols, multi-character symbols)
|
| 526 |
+
# Count occurrences before replacement
|
| 527 |
+
old_count = processed_text.count(symbol)
|
| 528 |
+
|
| 529 |
+
# Replace all occurrences
|
| 530 |
+
old_text = processed_text
|
| 531 |
+
processed_text = processed_text.replace(symbol, iso_code)
|
| 532 |
+
|
| 533 |
+
# Check if replacement actually happened
|
| 534 |
+
if old_text != processed_text:
|
| 535 |
+
conversions_made.append(f"{symbol} → {iso_code} ({old_count} instances)")
|
| 536 |
+
total_conversions += old_count
|
| 537 |
+
|
| 538 |
+
return processed_text
|
app/DocumentGeneration/__init__.py
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Document Generation Package
|
| 2 |
+
|
| 3 |
+
import os
|
| 4 |
+
import sys
|
| 5 |
+
|
| 6 |
+
# Add the current directory to sys.path to handle the space in folder name
|
| 7 |
+
current_dir = os.path.dirname(__file__)
|
| 8 |
+
if current_dir not in sys.path:
|
| 9 |
+
sys.path.insert(0, current_dir)
|
| 10 |
+
|
| 11 |
+
# Import all the main functions for easy access
|
| 12 |
+
from pdf_generator import create_pdf_document
|
| 13 |
+
from word_generator import create_word_document
|
| 14 |
+
from chart_generator import parse_embedded_chart_data, create_chart_from_data
|
| 15 |
+
from table_of_content import generate_table_of_contents_after_content, add_table_of_contents_page
|
| 16 |
+
from document_helpers import (
|
| 17 |
+
extract_business_name_from_content,
|
| 18 |
+
filter_empty_sections,
|
| 19 |
+
extract_subheadings,
|
| 20 |
+
remove_duplicate_headings
|
| 21 |
+
)
|
| 22 |
+
from grouped import (
|
| 23 |
+
parse_content_into_groups,
|
| 24 |
+
extract_key_phrases,
|
| 25 |
+
get_content_groups_info
|
| 26 |
+
)
|
| 27 |
+
from markdown_renderer import (
|
| 28 |
+
clean_text_for_pdf,
|
| 29 |
+
render_markdown_to_pdf,
|
| 30 |
+
render_markdown_to_pdf_grouped,
|
| 31 |
+
render_markdown_to_docx,
|
| 32 |
+
render_markdown_to_docx_grouped,
|
| 33 |
+
clean_text_for_docx
|
| 34 |
+
)
|
| 35 |
+
|
| 36 |
+
__all__ = [
|
| 37 |
+
'create_pdf_document',
|
| 38 |
+
'create_word_document',
|
| 39 |
+
'parse_embedded_chart_data',
|
| 40 |
+
'create_chart_from_data',
|
| 41 |
+
'generate_table_of_contents_after_content',
|
| 42 |
+
'add_table_of_contents_page',
|
| 43 |
+
'extract_business_name_from_content',
|
| 44 |
+
'filter_empty_sections',
|
| 45 |
+
'extract_subheadings',
|
| 46 |
+
'parse_content_into_groups',
|
| 47 |
+
'extract_key_phrases',
|
| 48 |
+
'get_content_groups_info',
|
| 49 |
+
'remove_duplicate_headings',
|
| 50 |
+
'clean_text_for_pdf',
|
| 51 |
+
'render_markdown_to_pdf',
|
| 52 |
+
'render_markdown_to_pdf_grouped',
|
| 53 |
+
'render_markdown_to_docx',
|
| 54 |
+
'render_markdown_to_docx_grouped',
|
| 55 |
+
'clean_text_for_docx'
|
| 56 |
+
]
|
app/DocumentGeneration/chart_generator.py
ADDED
|
@@ -0,0 +1,384 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Chart Generator Module
|
| 2 |
+
# Contains parse_embedded_chart_data() and create_chart_from_data() functions
|
| 3 |
+
|
| 4 |
+
import re
|
| 5 |
+
import io
|
| 6 |
+
import os
|
| 7 |
+
import sys
|
| 8 |
+
import base64
|
| 9 |
+
import matplotlib.pyplot as plt
|
| 10 |
+
import matplotlib.patches as patches
|
| 11 |
+
from typing import List, Dict, Any, Optional
|
| 12 |
+
|
| 13 |
+
# Add the parent directory to sys.path to import from models.py
|
| 14 |
+
sys.path.append(os.path.dirname(os.path.dirname(__file__)))
|
| 15 |
+
from models import ExportSection
|
| 16 |
+
|
| 17 |
+
def parse_embedded_chart_data(sections: List[ExportSection]) -> List[Dict[str, Any]]:
|
| 18 |
+
"""Parse chart data embedded in section content using CHARTDATA markers"""
|
| 19 |
+
charts = []
|
| 20 |
+
|
| 21 |
+
for section in sections:
|
| 22 |
+
if section.content:
|
| 23 |
+
# Look for chart data markers - handle both formats
|
| 24 |
+
chart_matches = re.findall(r'<!--CHART_DATA_START-->(.*?)<!--CHART_DATA_END-->', section.content, re.DOTALL)
|
| 25 |
+
|
| 26 |
+
# Also check for alternative markers as fallback
|
| 27 |
+
alt_chart_matches = re.findall(r'<!--CHARTDATASTART-->(.*?)<!--CHARTDATAEND-->', section.content, re.DOTALL)
|
| 28 |
+
chart_matches.extend(alt_chart_matches)
|
| 29 |
+
|
| 30 |
+
# logging.info(f"Section '{section.title}': Found {len(chart_matches)} chart data markers")
|
| 31 |
+
|
| 32 |
+
for chart_data in chart_matches:
|
| 33 |
+
try:
|
| 34 |
+
# Parse the chart data array
|
| 35 |
+
chart_array = eval(chart_data.strip())
|
| 36 |
+
# logging.info(f"Parsed chart array with {len(chart_array)} items")
|
| 37 |
+
# logging.info(f"Chart array type: {type(chart_array)}")
|
| 38 |
+
|
| 39 |
+
# Each chart is an array with: [Section, ChartType, Title, Period, Data]
|
| 40 |
+
for chart_item in chart_array:
|
| 41 |
+
if len(chart_item) >= 5:
|
| 42 |
+
section_name = chart_item[0]
|
| 43 |
+
chart_type = chart_item[1] # DC=Doughnut, BG=Bar Graph, etc.
|
| 44 |
+
chart_title = chart_item[2]
|
| 45 |
+
period = chart_item[3]
|
| 46 |
+
data = chart_item[4]
|
| 47 |
+
|
| 48 |
+
chart_info = {
|
| 49 |
+
"section": section_name,
|
| 50 |
+
"type": chart_type,
|
| 51 |
+
"title": chart_title,
|
| 52 |
+
"period": period,
|
| 53 |
+
"data": data,
|
| 54 |
+
"source_section": section.title
|
| 55 |
+
}
|
| 56 |
+
|
| 57 |
+
charts.append(chart_info)
|
| 58 |
+
# logging.info(f"Added chart: {chart_type} - {chart_title} for section {section_name}")
|
| 59 |
+
else:
|
| 60 |
+
# logging.warning(f"Chart item has insufficient data: {len(chart_item)} items, expected 5")
|
| 61 |
+
pass
|
| 62 |
+
|
| 63 |
+
except Exception as e:
|
| 64 |
+
# logging.error(f"Failed to parse chart data for section '{section.title}': {str(e)}")
|
| 65 |
+
continue
|
| 66 |
+
|
| 67 |
+
# Additional debugging: Check for any chart-related content
|
| 68 |
+
if not chart_matches:
|
| 69 |
+
# Look for any chart-related markers or text
|
| 70 |
+
chart_indicators = re.findall(r'<!--.*?CHART.*?-->', section.content, re.DOTALL)
|
| 71 |
+
if chart_indicators:
|
| 72 |
+
# logging.info(f"Section '{section.title}': Found chart indicators: {chart_indicators}")
|
| 73 |
+
pass
|
| 74 |
+
|
| 75 |
+
# Check for general chart text
|
| 76 |
+
if 'CHART' in section.content.upper() or 'chart' in section.content.lower():
|
| 77 |
+
# logging.info(f"Section '{section.title}': Contains chart-related text")
|
| 78 |
+
pass
|
| 79 |
+
|
| 80 |
+
# logging.info(f"Total charts parsed: {len(charts)}")
|
| 81 |
+
return charts
|
| 82 |
+
|
| 83 |
+
def create_enhanced_chart_style(chart_type, data, title, business_idea):
|
| 84 |
+
"""Create charts with enhanced professional styling"""
|
| 85 |
+
# Set modern style
|
| 86 |
+
plt.style.use('default')
|
| 87 |
+
|
| 88 |
+
# Design palette from image
|
| 89 |
+
colors = ['#6A2EAB', '#5A2E9B', '#C0C0C0', '#FF8C00', '#FFD700', '#696969', '#FF4500']
|
| 90 |
+
|
| 91 |
+
if chart_type == "DC": # Doughnut Chart
|
| 92 |
+
fig, ax = plt.subplots(figsize=(14, 10))
|
| 93 |
+
|
| 94 |
+
# Extract labels and values from data
|
| 95 |
+
labels = [item[0] for item in data]
|
| 96 |
+
values = [item[1] for item in data]
|
| 97 |
+
|
| 98 |
+
# Create doughnut chart with enhanced styling
|
| 99 |
+
wedges, texts, autotexts = ax.pie(values, labels=labels, colors=colors[:len(values)],
|
| 100 |
+
autopct='%1.1f%%', startangle=90,
|
| 101 |
+
wedgeprops=dict(width=0.6, edgecolor='white', linewidth=2),
|
| 102 |
+
textprops={'fontsize': 18})
|
| 103 |
+
|
| 104 |
+
# Enhanced title styling
|
| 105 |
+
ax.set_title(title, fontsize=24, fontweight='bold', pad=20, color='#374151')
|
| 106 |
+
|
| 107 |
+
# Add legend with enhanced styling
|
| 108 |
+
legend = ax.legend(wedges, labels, title="Categories",
|
| 109 |
+
loc="center left", bbox_to_anchor=(1, 0, 0.5, 1),
|
| 110 |
+
fontsize=16, title_fontsize=18)
|
| 111 |
+
legend.get_title().set_color('#FF6B6B')
|
| 112 |
+
|
| 113 |
+
# Add business name watermark
|
| 114 |
+
fig.text(0.5, 0.02, f"Generated for: {business_idea}",
|
| 115 |
+
ha='center', va='bottom', fontsize=8, color='#9CA3AF', style='italic')
|
| 116 |
+
|
| 117 |
+
plt.tight_layout()
|
| 118 |
+
return fig
|
| 119 |
+
|
| 120 |
+
elif chart_type == "BG": # Bar Graph
|
| 121 |
+
fig, ax = plt.subplots(figsize=(16, 10))
|
| 122 |
+
|
| 123 |
+
# Extract labels and values
|
| 124 |
+
labels = [item[0] for item in data]
|
| 125 |
+
values = [item[1] for item in data]
|
| 126 |
+
|
| 127 |
+
# Create bar chart with enhanced styling
|
| 128 |
+
bars = ax.bar(labels, values, color=colors[:len(values)],
|
| 129 |
+
edgecolor='white', linewidth=1, alpha=0.8)
|
| 130 |
+
|
| 131 |
+
# Add value labels on bars
|
| 132 |
+
for bar, value in zip(bars, values):
|
| 133 |
+
height = bar.get_height()
|
| 134 |
+
ax.text(bar.get_x() + bar.get_width()/2., height + max(values)*0.01,
|
| 135 |
+
f'{value}', ha='center', va='bottom', fontweight='bold', fontsize=18)
|
| 136 |
+
|
| 137 |
+
# Enhanced styling
|
| 138 |
+
ax.set_title(title, fontsize=24, fontweight='bold', pad=20, color='#374151')
|
| 139 |
+
ax.set_xlabel('Categories', fontsize=18, color='#6B7280')
|
| 140 |
+
ax.set_ylabel('Values', fontsize=18, color='#6B7280')
|
| 141 |
+
|
| 142 |
+
# Rotate x-axis labels for better readability
|
| 143 |
+
plt.setp(ax.get_xticklabels(), rotation=45, ha='right', fontsize=16)
|
| 144 |
+
plt.setp(ax.get_yticklabels(), fontsize=16)
|
| 145 |
+
|
| 146 |
+
# Add grid for better readability
|
| 147 |
+
ax.yaxis.grid(True, alpha=0.3)
|
| 148 |
+
ax.set_axisbelow(True)
|
| 149 |
+
|
| 150 |
+
# Add business name watermark
|
| 151 |
+
fig.text(0.5, 0.02, f"Generated for: {business_idea}",
|
| 152 |
+
ha='center', va='bottom', fontsize=8, color='#9CA3AF', style='italic')
|
| 153 |
+
|
| 154 |
+
plt.tight_layout()
|
| 155 |
+
return fig
|
| 156 |
+
|
| 157 |
+
elif chart_type == "LG": # Line Graph
|
| 158 |
+
fig, ax = plt.subplots(figsize=(16, 10))
|
| 159 |
+
|
| 160 |
+
# Extract data
|
| 161 |
+
x_values = [item[0] for item in data]
|
| 162 |
+
y_values = [item[1] for item in data]
|
| 163 |
+
|
| 164 |
+
# Create line chart with enhanced styling
|
| 165 |
+
ax.plot(x_values, y_values, color='#FF6B6B', linewidth=3, marker='o',
|
| 166 |
+
markersize=8, markerfacecolor='white', markeredgecolor='#FF6B6B')
|
| 167 |
+
|
| 168 |
+
# Fill area under line
|
| 169 |
+
ax.fill_between(x_values, y_values, alpha=0.3, color='#4ECDC4')
|
| 170 |
+
|
| 171 |
+
# Enhanced styling
|
| 172 |
+
ax.set_title(title, fontsize=24, fontweight='bold', pad=20, color='#374151')
|
| 173 |
+
ax.set_xlabel('Time Period', fontsize=18, color='#6B7280')
|
| 174 |
+
ax.set_ylabel('Values', fontsize=18, color='#6B7280')
|
| 175 |
+
|
| 176 |
+
# Add grid
|
| 177 |
+
ax.grid(True, alpha=0.3)
|
| 178 |
+
ax.set_axisbelow(True)
|
| 179 |
+
|
| 180 |
+
# Increase tick label font sizes
|
| 181 |
+
plt.setp(ax.get_xticklabels(), fontsize=16)
|
| 182 |
+
plt.setp(ax.get_yticklabels(), fontsize=16)
|
| 183 |
+
|
| 184 |
+
# Add business name watermark
|
| 185 |
+
fig.text(0.5, 0.02, f"Generated for: {business_idea}",
|
| 186 |
+
ha='center', va='bottom', fontsize=8, color='#9CA3AF', style='italic')
|
| 187 |
+
|
| 188 |
+
plt.tight_layout()
|
| 189 |
+
return fig
|
| 190 |
+
|
| 191 |
+
elif chart_type == "PG": # Pie Graph
|
| 192 |
+
fig, ax = plt.subplots(figsize=(14, 10))
|
| 193 |
+
|
| 194 |
+
# Extract labels and values
|
| 195 |
+
labels = [item[0] for item in data]
|
| 196 |
+
values = [item[1] for item in data]
|
| 197 |
+
|
| 198 |
+
# Create pie chart with enhanced styling
|
| 199 |
+
wedges, texts, autotexts = ax.pie(values, labels=labels, colors=colors[:len(values)],
|
| 200 |
+
autopct='%1.1f%%', startangle=90,
|
| 201 |
+
wedgeprops=dict(edgecolor='white', linewidth=2),
|
| 202 |
+
textprops={'fontsize': 18})
|
| 203 |
+
|
| 204 |
+
# Enhanced title styling
|
| 205 |
+
ax.set_title(title, fontsize=24, fontweight='bold', pad=20, color='#374151')
|
| 206 |
+
|
| 207 |
+
# Add business name watermark
|
| 208 |
+
fig.text(0.5, 0.02, f"Generated for: {business_idea}",
|
| 209 |
+
ha='center', va='bottom', fontsize=8, color='#9CA3AF', style='italic')
|
| 210 |
+
|
| 211 |
+
plt.tight_layout()
|
| 212 |
+
return fig
|
| 213 |
+
|
| 214 |
+
return None
|
| 215 |
+
|
| 216 |
+
def create_chart_from_data(chart_info: Dict[str, Any], business_idea: str) -> bytes:
|
| 217 |
+
"""Create a matplotlib chart from parsed chart data with enhanced styling"""
|
| 218 |
+
try:
|
| 219 |
+
chart_type = chart_info["type"]
|
| 220 |
+
chart_title = chart_info["title"]
|
| 221 |
+
data = chart_info["data"]
|
| 222 |
+
|
| 223 |
+
# logging.info(f"Creating chart: {chart_type} - {chart_title} with {len(data)} data points")
|
| 224 |
+
|
| 225 |
+
# Use enhanced styling function
|
| 226 |
+
fig = create_enhanced_chart_style(chart_type, data, chart_title, business_idea)
|
| 227 |
+
|
| 228 |
+
if fig is None:
|
| 229 |
+
# Fallback to original method if enhanced styling fails
|
| 230 |
+
if chart_type == "DC": # Doughnut Chart
|
| 231 |
+
fig, ax = plt.subplots(figsize=(14, 10)) # Larger size for better visibility
|
| 232 |
+
|
| 233 |
+
# Extract labels and values from data
|
| 234 |
+
labels = [item[0] for item in data]
|
| 235 |
+
values = [item[1] for item in data]
|
| 236 |
+
|
| 237 |
+
# High contrast color scheme
|
| 238 |
+
colors = ['#6A2EAB', '#5A2E9B', '#C0C0C0', '#FF8C00', '#FFD700', '#696969', '#FF4500']
|
| 239 |
+
|
| 240 |
+
# Create doughnut chart
|
| 241 |
+
wedges, texts, autotexts = ax.pie(values, labels=labels, colors=colors[:len(values)],
|
| 242 |
+
autopct='%1.1f%%', startangle=90,
|
| 243 |
+
wedgeprops=dict(width=0.6, edgecolor='white', linewidth=2),
|
| 244 |
+
textprops={'fontsize': 18})
|
| 245 |
+
|
| 246 |
+
# Enhanced title
|
| 247 |
+
ax.set_title(chart_title, fontsize=22, fontweight='bold', pad=15, color='#374151')
|
| 248 |
+
|
| 249 |
+
# Add legend
|
| 250 |
+
ax.legend(wedges, labels, title="Categories", loc="center left", bbox_to_anchor=(1, 0, 0.5, 1), fontsize=16)
|
| 251 |
+
|
| 252 |
+
plt.tight_layout()
|
| 253 |
+
elif chart_type == "BG": # Bar Graph
|
| 254 |
+
fig, ax = plt.subplots(figsize=(16, 10))
|
| 255 |
+
|
| 256 |
+
# Extract labels and values
|
| 257 |
+
labels = [item[0] for item in data]
|
| 258 |
+
values = [item[1] for item in data]
|
| 259 |
+
|
| 260 |
+
# High contrast color scheme
|
| 261 |
+
colors = ['#6A2EAB', '#5A2E9B', '#C0C0C0', '#FF8C00', '#FFD700', '#696969', '#FF4500']
|
| 262 |
+
|
| 263 |
+
# Create bar chart
|
| 264 |
+
bars = ax.bar(labels, values, color=colors[:len(values)],
|
| 265 |
+
edgecolor='white', linewidth=1, alpha=0.8)
|
| 266 |
+
|
| 267 |
+
# Add value labels on bars
|
| 268 |
+
for bar, value in zip(bars, values):
|
| 269 |
+
height = bar.get_height()
|
| 270 |
+
ax.text(bar.get_x() + bar.get_width()/2., height + max(values)*0.01,
|
| 271 |
+
f'{value}', ha='center', va='bottom', fontweight='bold', fontsize=18)
|
| 272 |
+
|
| 273 |
+
ax.set_title(chart_title, fontsize=22, fontweight='bold', pad=15, color='#374151')
|
| 274 |
+
ax.set_xlabel('Categories', fontsize=18, color='#6B7280')
|
| 275 |
+
ax.set_ylabel('Values', fontsize=18, color='#6B7280')
|
| 276 |
+
|
| 277 |
+
# Rotate x-axis labels
|
| 278 |
+
plt.setp(ax.get_xticklabels(), rotation=45, ha='right', fontsize=16)
|
| 279 |
+
plt.setp(ax.get_yticklabels(), fontsize=16)
|
| 280 |
+
|
| 281 |
+
# Add grid
|
| 282 |
+
ax.yaxis.grid(True, alpha=0.3)
|
| 283 |
+
ax.set_axisbelow(True)
|
| 284 |
+
|
| 285 |
+
plt.tight_layout()
|
| 286 |
+
elif chart_type == "LG": # Line Graph
|
| 287 |
+
fig, ax = plt.subplots(figsize=(16, 10))
|
| 288 |
+
|
| 289 |
+
# Extract data
|
| 290 |
+
x_values = [item[0] for item in data]
|
| 291 |
+
y_values = [item[1] for item in data]
|
| 292 |
+
|
| 293 |
+
# Create line chart
|
| 294 |
+
ax.plot(x_values, y_values, color='#FF6B6B', linewidth=3, marker='o',
|
| 295 |
+
markersize=8, markerfacecolor='white', markeredgecolor='#FF6B6B')
|
| 296 |
+
|
| 297 |
+
# Fill area under line
|
| 298 |
+
ax.fill_between(x_values, y_values, alpha=0.3, color='#4ECDC4')
|
| 299 |
+
|
| 300 |
+
ax.set_title(chart_title, fontsize=22, fontweight='bold', pad=15, color='#374151')
|
| 301 |
+
ax.set_xlabel('Time Period', fontsize=18, color='#6B7280')
|
| 302 |
+
ax.set_ylabel('Values', fontsize=18, color='#6B7280')
|
| 303 |
+
|
| 304 |
+
# Add grid
|
| 305 |
+
ax.grid(True, alpha=0.3)
|
| 306 |
+
ax.set_axisbelow(True)
|
| 307 |
+
|
| 308 |
+
# Increase tick label font sizes
|
| 309 |
+
plt.setp(ax.get_xticklabels(), fontsize=16)
|
| 310 |
+
plt.setp(ax.get_yticklabels(), fontsize=16)
|
| 311 |
+
|
| 312 |
+
plt.tight_layout()
|
| 313 |
+
elif chart_type == "PG": # Pie Graph
|
| 314 |
+
fig, ax = plt.subplots(figsize=(14, 10))
|
| 315 |
+
|
| 316 |
+
# Extract labels and values
|
| 317 |
+
labels = [item[0] for item in data]
|
| 318 |
+
values = [item[1] for item in data]
|
| 319 |
+
|
| 320 |
+
# High contrast color scheme
|
| 321 |
+
colors = ['#6A2EAB', '#5A2E9B', '#C0C0C0', '#FF8C00', '#FFD700', '#696969', '#FF4500']
|
| 322 |
+
|
| 323 |
+
# Create pie chart
|
| 324 |
+
wedges, texts, autotexts = ax.pie(values, labels=labels, colors=colors[:len(values)],
|
| 325 |
+
autopct='%1.1f%%', startangle=90,
|
| 326 |
+
wedgeprops=dict(edgecolor='white', linewidth=2),
|
| 327 |
+
textprops={'fontsize': 18})
|
| 328 |
+
|
| 329 |
+
ax.set_title(chart_title, fontsize=22, fontweight='bold', pad=15, color='#374151')
|
| 330 |
+
|
| 331 |
+
plt.tight_layout()
|
| 332 |
+
else:
|
| 333 |
+
# logging.warning(f"Unknown chart type: {chart_type}")
|
| 334 |
+
return None
|
| 335 |
+
|
| 336 |
+
# Save to bytes
|
| 337 |
+
buffer = io.BytesIO()
|
| 338 |
+
fig.savefig(buffer, format='png', dpi=300, bbox_inches='tight',
|
| 339 |
+
facecolor='white', edgecolor='none')
|
| 340 |
+
buffer.seek(0)
|
| 341 |
+
chart_bytes = buffer.getvalue()
|
| 342 |
+
buffer.close()
|
| 343 |
+
|
| 344 |
+
# Close the figure to free memory
|
| 345 |
+
plt.close(fig)
|
| 346 |
+
|
| 347 |
+
return chart_bytes
|
| 348 |
+
|
| 349 |
+
except Exception as e:
|
| 350 |
+
# logging.error(f"Failed to create chart: {str(e)}")
|
| 351 |
+
return None
|
| 352 |
+
|
| 353 |
+
def fix_base64_padding(base64_string: str) -> str:
|
| 354 |
+
"""Fix common base64 padding issues"""
|
| 355 |
+
# Remove any whitespace or newlines
|
| 356 |
+
base64_string = base64_string.strip()
|
| 357 |
+
|
| 358 |
+
# Add padding if needed
|
| 359 |
+
missing_padding = len(base64_string) % 4
|
| 360 |
+
if missing_padding:
|
| 361 |
+
base64_string += '=' * (4 - missing_padding)
|
| 362 |
+
|
| 363 |
+
return base64_string
|
| 364 |
+
|
| 365 |
+
def robust_base64_decode(base64_string: str) -> Optional[bytes]:
|
| 366 |
+
"""Robustly decode base64 string with error handling and padding correction"""
|
| 367 |
+
try:
|
| 368 |
+
# First try direct decoding
|
| 369 |
+
return base64.b64decode(base64_string)
|
| 370 |
+
except Exception as e1:
|
| 371 |
+
try:
|
| 372 |
+
# Try with padding correction
|
| 373 |
+
fixed_string = fix_base64_padding(base64_string)
|
| 374 |
+
return base64.b64decode(fixed_string)
|
| 375 |
+
except Exception as e2:
|
| 376 |
+
try:
|
| 377 |
+
# Try with URL-safe base64
|
| 378 |
+
return base64.urlsafe_b64decode(fix_base64_padding(base64_string))
|
| 379 |
+
except Exception as e3:
|
| 380 |
+
try:
|
| 381 |
+
# Try with standard base64 ignoring padding
|
| 382 |
+
return base64.b64decode(base64_string + '==', validate=False)
|
| 383 |
+
except Exception as e4:
|
| 384 |
+
return None
|
app/DocumentGeneration/document_helpers.py
ADDED
|
@@ -0,0 +1,159 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Document Helper Functions
|
| 2 |
+
# Contains helper functions like filter_empty_sections(), extract_business_name_from_content(), etc.
|
| 3 |
+
|
| 4 |
+
import re
|
| 5 |
+
import os
|
| 6 |
+
import sys
|
| 7 |
+
from typing import List, Dict, Any
|
| 8 |
+
|
| 9 |
+
# Add the parent directory to sys.path to import from models.py
|
| 10 |
+
sys.path.append(os.path.dirname(os.path.dirname(__file__)))
|
| 11 |
+
from models import ExportSection
|
| 12 |
+
|
| 13 |
+
def extract_business_name_from_content(sections: List[ExportSection]) -> str:
|
| 14 |
+
"""Extract business name from markdown content"""
|
| 15 |
+
for section in sections:
|
| 16 |
+
if section.content:
|
| 17 |
+
lines = section.content.split('\n')
|
| 18 |
+
for i, line in enumerate(lines):
|
| 19 |
+
line = line.strip()
|
| 20 |
+
# Look for "Business Name:" pattern
|
| 21 |
+
if '**Business Name:**' in line or 'Business Name:' in line:
|
| 22 |
+
# Extract the business name from the line
|
| 23 |
+
if '**Business Name:**' in line:
|
| 24 |
+
business_name = line.split('**Business Name:**')[1].strip()
|
| 25 |
+
else:
|
| 26 |
+
business_name = line.split('Business Name:')[1].strip()
|
| 27 |
+
|
| 28 |
+
# Keep markdown formatting - don't strip ** symbols
|
| 29 |
+
# business_name = re.sub(r'[*_`~]', '', business_name)
|
| 30 |
+
business_name = business_name.strip()
|
| 31 |
+
|
| 32 |
+
if business_name:
|
| 33 |
+
return business_name
|
| 34 |
+
|
| 35 |
+
# If not found on same line, check next line
|
| 36 |
+
if i + 1 < len(lines):
|
| 37 |
+
next_line = lines[i + 1].strip()
|
| 38 |
+
business_name = next_line # Keep ** symbols
|
| 39 |
+
business_name = business_name.strip()
|
| 40 |
+
if business_name:
|
| 41 |
+
return business_name
|
| 42 |
+
|
| 43 |
+
return None
|
| 44 |
+
|
| 45 |
+
def extract_subheadings(content: str) -> List[str]:
|
| 46 |
+
"""Extract subheadings from markdown content"""
|
| 47 |
+
subheadings = []
|
| 48 |
+
lines = content.split('\n')
|
| 49 |
+
|
| 50 |
+
for line in lines:
|
| 51 |
+
line = line.strip()
|
| 52 |
+
# Look for various markdown header patterns
|
| 53 |
+
if (line.startswith('##') and not line.startswith('###')) or \
|
| 54 |
+
line.startswith('**') and line.endswith('**') and len(line) > 4:
|
| 55 |
+
# Remove markdown formatting and clean up
|
| 56 |
+
if line.startswith('##'):
|
| 57 |
+
subheading = re.sub(r'^##+\s*', '', line)
|
| 58 |
+
else:
|
| 59 |
+
# Handle bold text that might be headings - keep ** symbols
|
| 60 |
+
subheading = line # Keep ** symbols instead of stripping them
|
| 61 |
+
|
| 62 |
+
# subheading = re.sub(r'[*_`~]', '', subheading) # Commented out to preserve ** symbols
|
| 63 |
+
if subheading.strip() and len(subheading.strip()) > 3: # Avoid very short headings
|
| 64 |
+
subheadings.append(subheading.strip())
|
| 65 |
+
|
| 66 |
+
return subheadings
|
| 67 |
+
|
| 68 |
+
|
| 69 |
+
|
| 70 |
+
|
| 71 |
+
|
| 72 |
+
|
| 73 |
+
|
| 74 |
+
|
| 75 |
+
|
| 76 |
+
|
| 77 |
+
|
| 78 |
+
def filter_empty_sections(sections: List[ExportSection]) -> List[ExportSection]:
|
| 79 |
+
"""Filter out sections with no content to prevent empty pages"""
|
| 80 |
+
filtered_sections = []
|
| 81 |
+
|
| 82 |
+
for section in sections:
|
| 83 |
+
if section.content and section.content.strip():
|
| 84 |
+
# Clean the content to check if it's actually meaningful
|
| 85 |
+
cleaned_content = section.content # No clean_text_for_pdf import, so use raw content
|
| 86 |
+
if cleaned_content and len(cleaned_content.strip()) > 10: # At least 10 characters
|
| 87 |
+
filtered_sections.append(section)
|
| 88 |
+
# logging.info(f"Section '{section.title}' passed filtering with {len(cleaned_content)} characters")
|
| 89 |
+
else:
|
| 90 |
+
# logging.info(f"Section '{section.title}' filtered out - content too short: {len(cleaned_content) if cleaned_content else 0} chars")
|
| 91 |
+
pass
|
| 92 |
+
else:
|
| 93 |
+
# logging.info(f"Section '{section.title}' filtered out - no content")
|
| 94 |
+
pass
|
| 95 |
+
|
| 96 |
+
# logging.info(f"Filtered sections: {len(filtered_sections)} out of {len(sections)} total")
|
| 97 |
+
return filtered_sections
|
| 98 |
+
|
| 99 |
+
|
| 100 |
+
def remove_duplicate_headings(content: str) -> str:
|
| 101 |
+
"""Remove duplicate headings from content"""
|
| 102 |
+
if not content:
|
| 103 |
+
return content
|
| 104 |
+
|
| 105 |
+
lines = content.split('\n')
|
| 106 |
+
cleaned_lines = []
|
| 107 |
+
seen_headings = set()
|
| 108 |
+
|
| 109 |
+
for line in lines:
|
| 110 |
+
line = line.strip()
|
| 111 |
+
if not line:
|
| 112 |
+
cleaned_lines.append('')
|
| 113 |
+
continue
|
| 114 |
+
|
| 115 |
+
# Check if this is any type of heading
|
| 116 |
+
is_heading = False
|
| 117 |
+
heading_text = ""
|
| 118 |
+
|
| 119 |
+
# Markdown headings (# ## ### etc.)
|
| 120 |
+
if line.startswith('#'):
|
| 121 |
+
heading_text = re.sub(r'^#+\s*', '', line).strip()
|
| 122 |
+
is_heading = True
|
| 123 |
+
|
| 124 |
+
# Bold headings (**text**)
|
| 125 |
+
elif line.startswith('**') and line.endswith('**') and len(line) > 4:
|
| 126 |
+
heading_text = line[2:-2].strip() # Remove ** symbols
|
| 127 |
+
is_heading = True
|
| 128 |
+
|
| 129 |
+
# Check for numbered headings (e.g., "8.1 Management Team")
|
| 130 |
+
elif re.match(r'^\d+\.?\d*\s+', line):
|
| 131 |
+
heading_text = re.sub(r'^\d+\.?\d*\s+', '', line).strip()
|
| 132 |
+
is_heading = True
|
| 133 |
+
|
| 134 |
+
if is_heading and heading_text:
|
| 135 |
+
# Normalize the heading text for comparison
|
| 136 |
+
normalized_text = heading_text.lower().strip()
|
| 137 |
+
|
| 138 |
+
# Also check for variations with "and" vs "&"
|
| 139 |
+
variations = [
|
| 140 |
+
normalized_text,
|
| 141 |
+
normalized_text.replace(' & ', ' and '),
|
| 142 |
+
normalized_text.replace(' and ', ' & '),
|
| 143 |
+
normalized_text.replace('&', 'and'),
|
| 144 |
+
normalized_text.replace('and', '&')
|
| 145 |
+
]
|
| 146 |
+
|
| 147 |
+
# Check if any variation has been seen before
|
| 148 |
+
is_duplicate = any(var in seen_headings for var in variations)
|
| 149 |
+
|
| 150 |
+
if not is_duplicate:
|
| 151 |
+
# Add all variations to seen set
|
| 152 |
+
for var in variations:
|
| 153 |
+
seen_headings.add(var)
|
| 154 |
+
cleaned_lines.append(line)
|
| 155 |
+
# Skip duplicate headings
|
| 156 |
+
else:
|
| 157 |
+
cleaned_lines.append(line)
|
| 158 |
+
|
| 159 |
+
return '\n'.join(cleaned_lines)
|
app/DocumentGeneration/grouped.py
ADDED
|
@@ -0,0 +1,305 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import re
|
| 2 |
+
from typing import List, Dict, Any
|
| 3 |
+
|
| 4 |
+
def calculate_group_height(pdf, group: Dict[str, Any]) -> int:
|
| 5 |
+
"""Calculate the estimated height needed for a complete group"""
|
| 6 |
+
subheading_type = group['subheading_type']
|
| 7 |
+
content_lines = group['content'].split('\n') if group['content'] else []
|
| 8 |
+
|
| 9 |
+
# Base height for subheading
|
| 10 |
+
if subheading_type == 'h1':
|
| 11 |
+
subheading_height = 25 # 20pt font + spacing
|
| 12 |
+
elif subheading_type == 'h2':
|
| 13 |
+
subheading_height = 20 # 16pt font + underline + spacing
|
| 14 |
+
elif subheading_type == 'h3':
|
| 15 |
+
subheading_height = 16 # 14pt font + spacing
|
| 16 |
+
elif subheading_type == 'h4':
|
| 17 |
+
subheading_height = 14 # 12pt font + spacing
|
| 18 |
+
elif subheading_type == 'bold':
|
| 19 |
+
subheading_height = 20 # 16pt font + spacing
|
| 20 |
+
elif subheading_type == 'numbered':
|
| 21 |
+
subheading_height = 20 # 16pt font + spacing
|
| 22 |
+
else:
|
| 23 |
+
subheading_height = 16
|
| 24 |
+
|
| 25 |
+
# Calculate content height more accurately
|
| 26 |
+
content_height = 0
|
| 27 |
+
for line in content_lines:
|
| 28 |
+
if line.strip():
|
| 29 |
+
# More accurate line height calculation
|
| 30 |
+
words = line.split()
|
| 31 |
+
if len(words) > 0:
|
| 32 |
+
# Estimate characters per line (roughly 80-90 chars per line)
|
| 33 |
+
chars_per_line = 85
|
| 34 |
+
estimated_lines = max(1, len(line) // chars_per_line + 1)
|
| 35 |
+
line_height = estimated_lines * 8 # 8pt per line
|
| 36 |
+
content_height += line_height
|
| 37 |
+
else:
|
| 38 |
+
content_height += 8 # Empty line
|
| 39 |
+
else:
|
| 40 |
+
content_height += 3 # Empty line spacing
|
| 41 |
+
|
| 42 |
+
# Add spacing between groups
|
| 43 |
+
group_spacing = 8
|
| 44 |
+
|
| 45 |
+
total_height = subheading_height + content_height + group_spacing
|
| 46 |
+
|
| 47 |
+
# print(f"DEBUG: Group '{group.get('subheading_clean', 'Unknown')[:30]}...' height calculation:")
|
| 48 |
+
# print(f" Subheading height: {subheading_height}")
|
| 49 |
+
# print(f" Content height: {content_height}")
|
| 50 |
+
# print(f" Group spacing: {group_spacing}")
|
| 51 |
+
# print(f" Total height: {total_height}")
|
| 52 |
+
|
| 53 |
+
return total_height
|
| 54 |
+
|
| 55 |
+
def should_break_page_for_group(pdf, group: Dict[str, Any], current_y: int, page_height: int = 280) -> bool:
|
| 56 |
+
"""Determine if we should break to a new page for this group"""
|
| 57 |
+
estimated_height = calculate_group_height(pdf, group)
|
| 58 |
+
|
| 59 |
+
# print(f"DEBUG: should_break_page_for_group called")
|
| 60 |
+
# print(f"DEBUG: Subheading: '{group.get('subheading_clean', 'Unknown')[:30]}...'")
|
| 61 |
+
# print(f"DEBUG: Current Y: {current_y}")
|
| 62 |
+
# print(f"DEBUG: Estimated height: {estimated_height}")
|
| 63 |
+
# print(f"DEBUG: Page height: {page_height}")
|
| 64 |
+
# print(f"DEBUG: Available space: {page_height - current_y}")
|
| 65 |
+
# print(f"DEBUG: Will fit? {current_y + estimated_height <= page_height}")
|
| 66 |
+
|
| 67 |
+
# CRITICAL FIX: Only break if the group absolutely won't fit
|
| 68 |
+
# This prevents splitting subheadings from their content
|
| 69 |
+
|
| 70 |
+
# If the group won't fit on current page, break
|
| 71 |
+
if current_y + estimated_height > page_height:
|
| 72 |
+
# print(f"DEBUG: -> BREAKING: Group won't fit ({current_y} + {estimated_height} > {page_height})")
|
| 73 |
+
return True
|
| 74 |
+
|
| 75 |
+
# If we're very close to the end of the page (less than 50mm remaining) and group is significant, break
|
| 76 |
+
if current_y > page_height - 50 and estimated_height > 30:
|
| 77 |
+
# print(f"DEBUG: -> BREAKING: Very close to end of page ({current_y} > {page_height - 50}) and group significant ({estimated_height} > 30)")
|
| 78 |
+
return True
|
| 79 |
+
|
| 80 |
+
# print(f"DEBUG: -> NO BREAK: Group will fit on current page")
|
| 81 |
+
return False
|
| 82 |
+
|
| 83 |
+
def clean_markdown_from_line(line: str) -> str:
|
| 84 |
+
"""Clean markdown formatting from a single line"""
|
| 85 |
+
if not line:
|
| 86 |
+
return ""
|
| 87 |
+
|
| 88 |
+
cleaned = line
|
| 89 |
+
|
| 90 |
+
# Remove bold, italic formatting
|
| 91 |
+
cleaned = re.sub(r'\*\*(.*?)\*\*', r'\1', cleaned) # Remove bold
|
| 92 |
+
cleaned = re.sub(r'\*(.*?)\*', r'\1', cleaned) # Remove italic
|
| 93 |
+
cleaned = re.sub(r'`(.*?)`', r'\1', cleaned) # Remove code
|
| 94 |
+
cleaned = re.sub(r'~~(.*?)~~', r'\1', cleaned) # Remove strikethrough
|
| 95 |
+
|
| 96 |
+
# Remove emphasis markers
|
| 97 |
+
cleaned = re.sub(r'_{1,2}(.*?)_{1,2}', r'\1', cleaned) # Remove underscores
|
| 98 |
+
cleaned = re.sub(r'\*{1,2}(.*?)\*{1,2}', r'\1', cleaned) # Remove asterisks
|
| 99 |
+
|
| 100 |
+
# Remove links (keep text)
|
| 101 |
+
cleaned = re.sub(r'\[([^\]]+)\]\([^)]+\)', r'\1', cleaned) # Remove links, keep text
|
| 102 |
+
|
| 103 |
+
# Remove any remaining HTML-like tags
|
| 104 |
+
cleaned = re.sub(r'<[^>]+>', '', cleaned)
|
| 105 |
+
|
| 106 |
+
# CRITICAL FIX: Replace multiple spaces with single space to prevent double spaces
|
| 107 |
+
cleaned = re.sub(r' +', ' ', cleaned) # Replace multiple spaces with single space
|
| 108 |
+
|
| 109 |
+
return cleaned.strip()
|
| 110 |
+
|
| 111 |
+
def parse_content_into_groups(content: str) -> List[Dict[str, Any]]:
|
| 112 |
+
"""Parse markdown content into subheading-content groups"""
|
| 113 |
+
if not content:
|
| 114 |
+
return []
|
| 115 |
+
|
| 116 |
+
# print(f"DEBUG: parse_content_into_groups called with content length: {len(content)}")
|
| 117 |
+
|
| 118 |
+
lines = content.split('\n')
|
| 119 |
+
groups = []
|
| 120 |
+
current_group = None
|
| 121 |
+
|
| 122 |
+
for i, line in enumerate(lines):
|
| 123 |
+
line = line.strip()
|
| 124 |
+
if not line:
|
| 125 |
+
if current_group:
|
| 126 |
+
current_group['content_lines'].append('') # Preserve empty lines
|
| 127 |
+
continue
|
| 128 |
+
|
| 129 |
+
# Check if this is a heading (H1, H2, H3, H4, or numbered)
|
| 130 |
+
is_heading = (
|
| 131 |
+
line.startswith('# ') or # H1
|
| 132 |
+
(line.startswith('## ') and not line.startswith('### ')) or # H2
|
| 133 |
+
(line.startswith('### ') and not line.startswith('#### ')) or # H3
|
| 134 |
+
(line.startswith('#### ') and not line.startswith('##### ')) or # H4
|
| 135 |
+
re.match(r'^\d+\.\d+\s+', line) # Numbered subheadings like "3.6 SWOT Analysis"
|
| 136 |
+
)
|
| 137 |
+
|
| 138 |
+
# Check if this is bold text
|
| 139 |
+
is_bold_text = (
|
| 140 |
+
(line.startswith('**') and line.endswith('**') and len(line) > 4) or # Bold headings
|
| 141 |
+
(re.match(r'^\*\*.*?\*\*:', line) and len(line) > 4) # Bold text before colon as heading
|
| 142 |
+
)
|
| 143 |
+
|
| 144 |
+
# Check if this is a bold list item (e.g., "- **Item**")
|
| 145 |
+
is_bold_list_item = re.match(r'^[-*]\s*\*\*(.*?)\*\*$', line)
|
| 146 |
+
|
| 147 |
+
# Special handling for bold text following numbered subheadings
|
| 148 |
+
should_create_new_group = False
|
| 149 |
+
|
| 150 |
+
if is_heading:
|
| 151 |
+
# Always create new group for regular headings
|
| 152 |
+
should_create_new_group = True
|
| 153 |
+
# print(f"DEBUG: Line {i+1} '{line[:50]}...' - Regular heading, creating new group")
|
| 154 |
+
elif is_bold_text:
|
| 155 |
+
# Check if we should treat this bold text as content for a numbered subheading
|
| 156 |
+
if groups and groups[-1].get('subheading_type') == 'h3':
|
| 157 |
+
# Check if the previous group's subheading looks like a numbered subheading
|
| 158 |
+
previous_subheading = groups[-1].get('subheading', '')
|
| 159 |
+
if re.match(r'^\d+\.\d+\s+', previous_subheading):
|
| 160 |
+
# This is content for a numbered subheading, add to previous group
|
| 161 |
+
groups[-1]['content_lines'].append(line)
|
| 162 |
+
# print(f"DEBUG: Line {i+1} '{line[:50]}...' - Adding to previous numbered subheading '{previous_subheading[:30]}...'")
|
| 163 |
+
continue # Skip creating new group
|
| 164 |
+
else:
|
| 165 |
+
# Not a numbered subheading, treat as new heading
|
| 166 |
+
should_create_new_group = True
|
| 167 |
+
# print(f"DEBUG: Line {i+1} '{line[:50]}...' - Treating as NEW HEADING (not numbered subheading)")
|
| 168 |
+
else:
|
| 169 |
+
# No previous group or not a numbered subheading, treat as new heading
|
| 170 |
+
should_create_new_group = True
|
| 171 |
+
# print(f"DEBUG: Line {i+1} '{line[:50]}...' - Treating as NEW HEADING (no numbered subheading)")
|
| 172 |
+
elif is_bold_list_item:
|
| 173 |
+
# If it's a bold list item, treat it as a new heading
|
| 174 |
+
should_create_new_group = True
|
| 175 |
+
# print(f"DEBUG: Line {i+1} '{line[:50]}...' - Bold list item, creating new group")
|
| 176 |
+
|
| 177 |
+
if should_create_new_group:
|
| 178 |
+
# Save previous group if it exists
|
| 179 |
+
if current_group:
|
| 180 |
+
current_group['content'] = '\n'.join(current_group['content_lines'])
|
| 181 |
+
groups.append(current_group)
|
| 182 |
+
# print(f"DEBUG: Saved group '{current_group.get('subheading_clean', 'Unknown')[:30]}...' with {len(current_group['content_lines'])} content lines")
|
| 183 |
+
|
| 184 |
+
# Clean markdown symbols BEFORE determining type and storing
|
| 185 |
+
clean_text = line
|
| 186 |
+
heading_type = 'text'
|
| 187 |
+
|
| 188 |
+
if line.startswith('# '):
|
| 189 |
+
clean_text = line[2:].strip() # Remove "# "
|
| 190 |
+
heading_type = 'h1'
|
| 191 |
+
elif line.startswith('## '):
|
| 192 |
+
clean_text = line[3:].strip() # Remove "## "
|
| 193 |
+
heading_type = 'h2'
|
| 194 |
+
elif line.startswith('### '):
|
| 195 |
+
clean_text = line[4:].strip() # Remove "### "
|
| 196 |
+
heading_type = 'h3'
|
| 197 |
+
elif line.startswith('#### '):
|
| 198 |
+
clean_text = line[5:].strip() # Remove "#### "
|
| 199 |
+
heading_type = 'h4'
|
| 200 |
+
elif re.match(r'^\d+\.\d+\s+', line):
|
| 201 |
+
clean_text = line.strip() # Keep numbered headings as-is
|
| 202 |
+
heading_type = 'numbered'
|
| 203 |
+
elif is_bold_text:
|
| 204 |
+
# Remove ** markers from bold text
|
| 205 |
+
clean_text = clean_markdown_from_line(line.strip())
|
| 206 |
+
heading_type = 'bold'
|
| 207 |
+
elif is_bold_list_item:
|
| 208 |
+
# Extract the bold text from list item and treat as H3 subheading
|
| 209 |
+
# Remove the list marker and ** markers
|
| 210 |
+
clean_text = re.sub(r'^[-*]\s*\*\*(.*?)\*\*', r'\1', line.strip())
|
| 211 |
+
heading_type = 'h3' # Treat as H3 subheading
|
| 212 |
+
|
| 213 |
+
# print(f"DEBUG: Creating new group '{clean_text[:30]}...' (type: {heading_type})")
|
| 214 |
+
|
| 215 |
+
# Start new group with cleaned text
|
| 216 |
+
current_group = {
|
| 217 |
+
'subheading': line, # Keep original for reference
|
| 218 |
+
'content_lines': [],
|
| 219 |
+
'content': '',
|
| 220 |
+
'subheading_clean': clean_text, # Pre-cleaned text
|
| 221 |
+
'subheading_type': heading_type
|
| 222 |
+
}
|
| 223 |
+
else:
|
| 224 |
+
# Add to current group's content - clean the line
|
| 225 |
+
if current_group:
|
| 226 |
+
cleaned_line = clean_markdown_from_line(line)
|
| 227 |
+
current_group['content_lines'].append(cleaned_line)
|
| 228 |
+
if i < 5: # Log first few content lines
|
| 229 |
+
# print(f"DEBUG: Added content line to group '{current_group.get('subheading_clean', 'Unknown')[:30]}...': '{cleaned_line[:50]}...'")
|
| 230 |
+
pass
|
| 231 |
+
else:
|
| 232 |
+
# Content before any subheading - create a default group
|
| 233 |
+
if not groups or groups[-1].get('subheading') != '## Introduction':
|
| 234 |
+
cleaned_line = clean_markdown_from_line(line)
|
| 235 |
+
current_group = {
|
| 236 |
+
'subheading': '## Introduction',
|
| 237 |
+
'content_lines': [cleaned_line],
|
| 238 |
+
'content': '',
|
| 239 |
+
'subheading_clean': 'Introduction',
|
| 240 |
+
'subheading_type': 'h2'
|
| 241 |
+
}
|
| 242 |
+
|
| 243 |
+
# Don't forget the last group
|
| 244 |
+
if current_group:
|
| 245 |
+
current_group['content'] = '\n'.join(current_group['content_lines'])
|
| 246 |
+
groups.append(current_group)
|
| 247 |
+
# print(f"DEBUG: Saved final group '{current_group.get('subheading_clean', 'Unknown')[:30]}...' with {len(current_group['content_lines'])} content lines")
|
| 248 |
+
|
| 249 |
+
# Add metadata to each group
|
| 250 |
+
for i, group in enumerate(groups):
|
| 251 |
+
group['group_id'] = f"group_{i}"
|
| 252 |
+
group['content_length'] = len(group['content'])
|
| 253 |
+
group['word_count'] = len(group['content'].split()) if group['content'] else 0
|
| 254 |
+
group['has_charts'] = 'CHART' in group['content'].upper() if group['content'] else False
|
| 255 |
+
group['line_count'] = len([line for line in group['content'].split('\n') if line.strip()])
|
| 256 |
+
|
| 257 |
+
# print(f"DEBUG: Created {len(groups)} groups total")
|
| 258 |
+
return groups
|
| 259 |
+
|
| 260 |
+
def extract_key_phrases(content: str) -> List[tuple]:
|
| 261 |
+
"""Extract key phrases from content for grouping metadata"""
|
| 262 |
+
if not content:
|
| 263 |
+
return []
|
| 264 |
+
|
| 265 |
+
# Simple key phrase extraction
|
| 266 |
+
words = re.findall(r'\b\w+\b', content.lower())
|
| 267 |
+
word_freq = {}
|
| 268 |
+
|
| 269 |
+
for word in words:
|
| 270 |
+
if len(word) > 3: # Only words longer than 3 characters
|
| 271 |
+
word_freq[word] = word_freq.get(word, 0) + 1
|
| 272 |
+
|
| 273 |
+
# Return top 5 most frequent words
|
| 274 |
+
return sorted(word_freq.items(), key=lambda x: x[1], reverse=True)[:5]
|
| 275 |
+
|
| 276 |
+
def get_content_groups_info(content: str) -> Dict[str, Any]:
|
| 277 |
+
"""Get detailed information about content groups for analysis and debugging"""
|
| 278 |
+
if not content:
|
| 279 |
+
return {"groups": [], "summary": {"total_groups": 0, "total_content_length": 0}}
|
| 280 |
+
|
| 281 |
+
groups = parse_content_into_groups(content)
|
| 282 |
+
|
| 283 |
+
# Add key phrases to each group
|
| 284 |
+
for group in groups:
|
| 285 |
+
group['key_phrases'] = extract_key_phrases(group['content'])
|
| 286 |
+
|
| 287 |
+
# Calculate summary statistics
|
| 288 |
+
total_content_length = sum(group['content_length'] for group in groups)
|
| 289 |
+
total_word_count = sum(group['word_count'] for group in groups)
|
| 290 |
+
groups_with_charts = sum(1 for group in groups if group['has_charts'])
|
| 291 |
+
|
| 292 |
+
summary = {
|
| 293 |
+
"total_groups": len(groups),
|
| 294 |
+
"total_content_length": total_content_length,
|
| 295 |
+
"total_word_count": total_word_count,
|
| 296 |
+
"groups_with_charts": groups_with_charts,
|
| 297 |
+
"average_group_size": total_content_length / len(groups) if groups else 0,
|
| 298 |
+
"largest_group": max(groups, key=lambda g: g['content_length'])['subheading_clean'] if groups else None,
|
| 299 |
+
"smallest_group": min(groups, key=lambda g: g['content_length'])['subheading_clean'] if groups else None
|
| 300 |
+
}
|
| 301 |
+
|
| 302 |
+
return {
|
| 303 |
+
"groups": groups,
|
| 304 |
+
"summary": summary
|
| 305 |
+
}
|
app/DocumentGeneration/live_pdf_generator.py
ADDED
|
@@ -0,0 +1,132 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Live PDF Generator Module
|
| 2 |
+
# Enables real-time PDF updates as content changes
|
| 3 |
+
|
| 4 |
+
import asyncio
|
| 5 |
+
import io
|
| 6 |
+
import tempfile
|
| 7 |
+
import os
|
| 8 |
+
from typing import Dict, Any, Optional, AsyncGenerator
|
| 9 |
+
try:
|
| 10 |
+
from fpdf2 import FPDF
|
| 11 |
+
except ImportError:
|
| 12 |
+
try:
|
| 13 |
+
from fpdf import FPDF
|
| 14 |
+
except ImportError:
|
| 15 |
+
raise ImportError("Neither fpdf2 nor fpdf package found. Please install fpdf2.")
|
| 16 |
+
import base64
|
| 17 |
+
|
| 18 |
+
import sys
|
| 19 |
+
import os
|
| 20 |
+
sys.path.append(os.path.dirname(__file__))
|
| 21 |
+
from pdf_generator import create_pdf_document
|
| 22 |
+
from models import ExportRequest
|
| 23 |
+
|
| 24 |
+
class LivePDFGenerator:
|
| 25 |
+
"""Generates and streams PDF updates in real-time"""
|
| 26 |
+
|
| 27 |
+
def __init__(self):
|
| 28 |
+
self.active_sessions: Dict[str, 'PDFSession'] = {}
|
| 29 |
+
|
| 30 |
+
async def create_live_session(self, business_plan_id: str, initial_request: ExportRequest) -> str:
|
| 31 |
+
"""Create a new live PDF session"""
|
| 32 |
+
session_id = f"pdf_session_{business_plan_id}_{int(asyncio.get_event_loop().time())}"
|
| 33 |
+
|
| 34 |
+
session = PDFSession(session_id, business_plan_id, initial_request)
|
| 35 |
+
self.active_sessions[session_id] = session
|
| 36 |
+
|
| 37 |
+
# Generate initial PDF
|
| 38 |
+
await session.update_pdf(initial_request)
|
| 39 |
+
|
| 40 |
+
return session_id
|
| 41 |
+
|
| 42 |
+
async def update_session(self, session_id: str, updated_request: ExportRequest) -> bool:
|
| 43 |
+
"""Update an existing PDF session with new content"""
|
| 44 |
+
if session_id not in self.active_sessions:
|
| 45 |
+
return False
|
| 46 |
+
|
| 47 |
+
session = self.active_sessions[session_id]
|
| 48 |
+
await session.update_pdf(updated_request)
|
| 49 |
+
return True
|
| 50 |
+
|
| 51 |
+
async def stream_pdf_updates(self, session_id: str) -> AsyncGenerator[bytes, None]:
|
| 52 |
+
"""Stream PDF updates as they occur"""
|
| 53 |
+
if session_id not in self.active_sessions:
|
| 54 |
+
return
|
| 55 |
+
|
| 56 |
+
session = self.active_sessions[session_id]
|
| 57 |
+
async for pdf_chunk in session.stream_updates():
|
| 58 |
+
yield pdf_chunk
|
| 59 |
+
|
| 60 |
+
def close_session(self, session_id: str):
|
| 61 |
+
"""Close and cleanup a PDF session"""
|
| 62 |
+
if session_id in self.active_sessions:
|
| 63 |
+
del self.active_sessions[session_id]
|
| 64 |
+
|
| 65 |
+
class PDFSession:
|
| 66 |
+
"""Individual PDF session that manages real-time updates"""
|
| 67 |
+
|
| 68 |
+
def __init__(self, session_id: str, business_plan_id: str, initial_request: ExportRequest):
|
| 69 |
+
self.session_id = session_id
|
| 70 |
+
self.business_plan_id = business_plan_id
|
| 71 |
+
self.current_request = initial_request
|
| 72 |
+
self.update_queue = asyncio.Queue()
|
| 73 |
+
self.is_active = True
|
| 74 |
+
self.last_pdf_bytes: Optional[bytes] = None
|
| 75 |
+
|
| 76 |
+
async def update_pdf(self, new_request: ExportRequest):
|
| 77 |
+
"""Update the PDF with new content"""
|
| 78 |
+
self.current_request = new_request
|
| 79 |
+
|
| 80 |
+
try:
|
| 81 |
+
# Generate new PDF
|
| 82 |
+
pdf_bytes, filename = create_pdf_document(new_request)
|
| 83 |
+
self.last_pdf_bytes = pdf_bytes
|
| 84 |
+
|
| 85 |
+
# Notify subscribers of update
|
| 86 |
+
await self.update_queue.put({
|
| 87 |
+
'type': 'pdf_update',
|
| 88 |
+
'data': base64.b64encode(pdf_bytes).decode('utf-8'),
|
| 89 |
+
'filename': filename,
|
| 90 |
+
'timestamp': asyncio.get_event_loop().time()
|
| 91 |
+
})
|
| 92 |
+
|
| 93 |
+
except Exception as e:
|
| 94 |
+
# Notify subscribers of error
|
| 95 |
+
await self.update_queue.put({
|
| 96 |
+
'type': 'pdf_error',
|
| 97 |
+
'error': str(e),
|
| 98 |
+
'timestamp': asyncio.get_event_loop().time()
|
| 99 |
+
})
|
| 100 |
+
|
| 101 |
+
async def stream_updates(self) -> AsyncGenerator[bytes, None]:
|
| 102 |
+
"""Stream PDF updates as they occur"""
|
| 103 |
+
while self.is_active:
|
| 104 |
+
try:
|
| 105 |
+
# Wait for updates with timeout
|
| 106 |
+
update = await asyncio.wait_for(self.update_queue.get(), timeout=30.0)
|
| 107 |
+
|
| 108 |
+
if update['type'] == 'pdf_update':
|
| 109 |
+
# Send SSE event for PDF update
|
| 110 |
+
event_data = f"event: pdf_update\ndata: {{\"data\": \"{update['data']}\", \"filename\": \"{update['filename']}\"}}\n\n"
|
| 111 |
+
yield event_data.encode('utf-8')
|
| 112 |
+
|
| 113 |
+
elif update['type'] == 'pdf_error':
|
| 114 |
+
# Send SSE event for error
|
| 115 |
+
error_data = f"event: pdf_error\ndata: {{\"error\": \"{update['error']}\"}}\n\n"
|
| 116 |
+
yield error_data.encode('utf-8')
|
| 117 |
+
|
| 118 |
+
except asyncio.TimeoutError:
|
| 119 |
+
# Send keep-alive
|
| 120 |
+
yield b": keep-alive\n\n"
|
| 121 |
+
except Exception as e:
|
| 122 |
+
# Send error event
|
| 123 |
+
error_data = f"event: stream_error\ndata: {str(e)}\n\n"
|
| 124 |
+
yield error_data.encode('utf-8')
|
| 125 |
+
break
|
| 126 |
+
|
| 127 |
+
def close(self):
|
| 128 |
+
"""Close this session"""
|
| 129 |
+
self.is_active = False
|
| 130 |
+
|
| 131 |
+
# Global instance
|
| 132 |
+
live_pdf_generator = LivePDFGenerator()
|
app/DocumentGeneration/markdown_renderer.py
ADDED
|
@@ -0,0 +1,759 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Markdown Renderer Module
|
| 2 |
+
# Contains all markdown rendering functions for both PDF and Word
|
| 3 |
+
|
| 4 |
+
import re
|
| 5 |
+
import base64
|
| 6 |
+
import os
|
| 7 |
+
import sys
|
| 8 |
+
import tempfile
|
| 9 |
+
from typing import List, Dict, Any, Optional
|
| 10 |
+
|
| 11 |
+
# Import from the same directory (Document Generation)
|
| 12 |
+
from grouped import parse_content_into_groups, should_break_page_for_group
|
| 13 |
+
from chart_generator import create_chart_from_data
|
| 14 |
+
|
| 15 |
+
def clean_markdown_text(text: str) -> str:
|
| 16 |
+
"""Clean markdown formatting for PDF generation - PRESERVE HEADINGS"""
|
| 17 |
+
if not text:
|
| 18 |
+
return ""
|
| 19 |
+
|
| 20 |
+
# Remove markdown formatting BUT PRESERVE HEADING STRUCTURE
|
| 21 |
+
cleaned = text
|
| 22 |
+
|
| 23 |
+
# PRESERVE HEADINGS - don't strip the # markers, keep them for rendering
|
| 24 |
+
# We'll handle styling at render time instead of stripping them
|
| 25 |
+
|
| 26 |
+
# Remove bold, italic formatting - strip ** or * symbols
|
| 27 |
+
cleaned = re.sub(r'\*\*(.*?)\*\*', r'\1', cleaned) # Remove bold
|
| 28 |
+
cleaned = re.sub(r'\*(.*?)\*', r'\1', cleaned) # Remove italic
|
| 29 |
+
cleaned = re.sub(r'`(.*?)`', r'\1', cleaned) # Remove code
|
| 30 |
+
cleaned = re.sub(r'~~(.*?)~~', r'\1', cleaned) # Remove strikethrough
|
| 31 |
+
|
| 32 |
+
# Remove links (keep text)
|
| 33 |
+
cleaned = re.sub(r'\[([^\]]+)\]\([^)]+\)', r'\1', cleaned) # Remove links, keep text
|
| 34 |
+
cleaned = re.sub(r'!\[([^\]]*)\]\([^)]+\)', '', cleaned) # Remove images
|
| 35 |
+
|
| 36 |
+
# Remove list markers (more aggressive)
|
| 37 |
+
cleaned = re.sub(r'^\s*[-*+]\s*', '', cleaned, flags=re.MULTILINE) # Remove list markers
|
| 38 |
+
cleaned = re.sub(r'^\s*\d+\.\s*', '', cleaned, flags=re.MULTILINE) # Remove numbered list markers
|
| 39 |
+
cleaned = re.sub(r'^\s*[-*+]\s*', '', cleaned, flags=re.MULTILINE) # Remove any remaining list markers
|
| 40 |
+
|
| 41 |
+
# Remove blockquotes
|
| 42 |
+
cleaned = re.sub(r'^\s*>\s*', '', cleaned, flags=re.MULTILINE) # Remove blockquotes
|
| 43 |
+
|
| 44 |
+
# Remove horizontal rules
|
| 45 |
+
cleaned = re.sub(r'^\s*[-*_]{3,}\s*$', '', cleaned, flags=re.MULTILINE) # Remove horizontal rules
|
| 46 |
+
|
| 47 |
+
# Remove code blocks
|
| 48 |
+
cleaned = re.sub(r'```[\s\S]*?```', '', cleaned) # Remove code blocks
|
| 49 |
+
cleaned = re.sub(r'`.*?`', '', cleaned) # Remove any remaining inline code
|
| 50 |
+
|
| 51 |
+
# Remove emphasis markers - strip ** or * symbols
|
| 52 |
+
cleaned = re.sub(r'_{1,2}(.*?)_{1,2}', r'\1', cleaned) # Remove underscores
|
| 53 |
+
cleaned = re.sub(r'\*{1,2}(.*?)\*{1,2}', r'\1', cleaned) # Remove asterisks
|
| 54 |
+
|
| 55 |
+
# PRESERVE CHART DATA MARKERS for inline processing
|
| 56 |
+
# DO NOT REMOVE chart markers - they will be processed inline
|
| 57 |
+
# cleaned = re.sub(r'<!--CHARTDATASTART-->.*?<!--CHARTDATAEND-->', '', cleaned, flags=re.DOTALL)
|
| 58 |
+
# cleaned = re.sub(r'<!--CHARTDATASTART-->.*$', '', cleaned, flags=re.DOTALL)
|
| 59 |
+
# cleaned = re.sub(r'<!--CHARTDATAEND-->', '', cleaned)
|
| 60 |
+
|
| 61 |
+
# REMOVE CHART DATA MARKERS for PDF rendering since they're not being processed
|
| 62 |
+
cleaned = re.sub(r'<!--CHART_DATA_START-->.*?<!--CHART_DATA_END-->', '', cleaned, flags=re.DOTALL)
|
| 63 |
+
cleaned = re.sub(r'<!--CHARTDATASTART-->.*?<!--CHARTDATAEND-->', '', cleaned, flags=re.DOTALL)
|
| 64 |
+
|
| 65 |
+
# Remove any remaining HTML-like tags
|
| 66 |
+
cleaned = re.sub(r'<[^>]+>', '', cleaned)
|
| 67 |
+
|
| 68 |
+
# Clean up extra whitespace and formatting
|
| 69 |
+
cleaned = re.sub(r'\n\s*\n\s*\n+', '\n\n', cleaned) # Remove excessive line breaks
|
| 70 |
+
cleaned = re.sub(r'^\s+', '', cleaned, flags=re.MULTILINE) # Remove leading whitespace
|
| 71 |
+
cleaned = re.sub(r'\s+$', '', cleaned, flags=re.MULTILINE) # Remove trailing whitespace
|
| 72 |
+
cleaned = re.sub(r' +', ' ', cleaned) # Replace multiple spaces with single space
|
| 73 |
+
|
| 74 |
+
# Final cleanup
|
| 75 |
+
cleaned = cleaned.strip()
|
| 76 |
+
|
| 77 |
+
return cleaned
|
| 78 |
+
|
| 79 |
+
def clean_text_for_pdf(text: str) -> str:
|
| 80 |
+
"""Clean text specifically for PDF generation, handling Unicode issues"""
|
| 81 |
+
if not text:
|
| 82 |
+
return ""
|
| 83 |
+
|
| 84 |
+
# First clean markdown
|
| 85 |
+
cleaned = clean_markdown_text(text)
|
| 86 |
+
|
| 87 |
+
# Replace problematic Unicode characters with ASCII equivalents
|
| 88 |
+
unicode_replacements = {
|
| 89 |
+
'\u2019': "'", # Right single quotation mark
|
| 90 |
+
'\u2018': "'", # Left single quotation mark
|
| 91 |
+
'\u201C': '"', # Left double quotation mark
|
| 92 |
+
'\u201D': '"', # Right double quotation mark
|
| 93 |
+
'\u2013': '-', # En dash
|
| 94 |
+
'\u2014': '--', # Em dash
|
| 95 |
+
'\u2022': '•', # Bullet
|
| 96 |
+
'\u2026': '...', # Horizontal ellipsis
|
| 97 |
+
'\u00A0': ' ', # Non-breaking space
|
| 98 |
+
'\u00B0': '°', # Degree sign
|
| 99 |
+
'\u00AE': '(R)', # Registered trademark
|
| 100 |
+
'\u2122': '(TM)', # Trademark
|
| 101 |
+
'\u00A9': '(C)', # Copyright
|
| 102 |
+
}
|
| 103 |
+
|
| 104 |
+
for unicode_char, replacement in unicode_replacements.items():
|
| 105 |
+
cleaned = cleaned.replace(unicode_char, replacement)
|
| 106 |
+
|
| 107 |
+
# Additional space normalization after Unicode replacements to prevent double spaces
|
| 108 |
+
cleaned = re.sub(r' +', ' ', cleaned) # Replace multiple spaces with single space
|
| 109 |
+
|
| 110 |
+
return cleaned
|
| 111 |
+
|
| 112 |
+
def process_content_with_inline_charts(doc, content: str, business_idea: str):
|
| 113 |
+
"""Process content and render charts inline where they appear in the text"""
|
| 114 |
+
try:
|
| 115 |
+
from docx.shared import Inches
|
| 116 |
+
except ImportError:
|
| 117 |
+
raise Exception("python-docx is not installed. Please install it with: pip install python-docx")
|
| 118 |
+
|
| 119 |
+
# Split content by chart markers - handle both formats
|
| 120 |
+
chart_pattern = r'<!--CHART_DATA_START-->(.*?)<!--CHART_DATA_END-->'
|
| 121 |
+
parts = re.split(chart_pattern, content, flags=re.DOTALL)
|
| 122 |
+
|
| 123 |
+
# If no charts found, try alternative format
|
| 124 |
+
if len(parts) == 1:
|
| 125 |
+
chart_pattern = r'<!--CHARTDATASTART-->(.*?)<!--CHARTDATAEND-->'
|
| 126 |
+
parts = re.split(chart_pattern, content, flags=re.DOTALL)
|
| 127 |
+
|
| 128 |
+
for i, part in enumerate(parts):
|
| 129 |
+
if i % 2 == 0: # Regular content
|
| 130 |
+
if part.strip():
|
| 131 |
+
render_markdown_to_docx_grouped(doc, part.strip())
|
| 132 |
+
else: # Chart data
|
| 133 |
+
try:
|
| 134 |
+
chart_array = eval(part.strip())
|
| 135 |
+
for chart_item in chart_array:
|
| 136 |
+
if len(chart_item) >= 5:
|
| 137 |
+
chart_type = chart_item[1]
|
| 138 |
+
chart_title = chart_item[2]
|
| 139 |
+
data = chart_item[4]
|
| 140 |
+
|
| 141 |
+
# Add chart title
|
| 142 |
+
doc.add_heading(f"{chart_title}", level=2)
|
| 143 |
+
|
| 144 |
+
# Create and add chart
|
| 145 |
+
chart_info = {
|
| 146 |
+
"type": chart_type,
|
| 147 |
+
"title": chart_title,
|
| 148 |
+
"data": data
|
| 149 |
+
}
|
| 150 |
+
|
| 151 |
+
chart_bytes = create_chart_from_data(chart_info, business_idea)
|
| 152 |
+
|
| 153 |
+
if chart_bytes:
|
| 154 |
+
with tempfile.NamedTemporaryFile(delete=False, suffix=".png") as tmp_img:
|
| 155 |
+
tmp_img.write(chart_bytes)
|
| 156 |
+
temp_img_path = tmp_img.name
|
| 157 |
+
|
| 158 |
+
# Add image to Word document
|
| 159 |
+
doc.add_picture(temp_img_path, width=Inches(6.0)) # 6 inches width
|
| 160 |
+
|
| 161 |
+
# Clean up temp file
|
| 162 |
+
os.remove(temp_img_path)
|
| 163 |
+
except Exception as e:
|
| 164 |
+
# If chart processing fails, just render the raw content
|
| 165 |
+
if part.strip():
|
| 166 |
+
render_markdown_to_docx_grouped(doc, part.strip())
|
| 167 |
+
|
| 168 |
+
|
| 169 |
+
def render_markdown_to_pdf(pdf, content: str, primary_color, accent_color, text_color):
|
| 170 |
+
"""Render markdown content to PDF with proper heading styling and page break logic"""
|
| 171 |
+
if not content:
|
| 172 |
+
return
|
| 173 |
+
|
| 174 |
+
lines = content.split('\n')
|
| 175 |
+
current_y = pdf.get_y()
|
| 176 |
+
|
| 177 |
+
for line in lines:
|
| 178 |
+
line = line.strip()
|
| 179 |
+
if not line:
|
| 180 |
+
# Check if we need a page break for empty line
|
| 181 |
+
if current_y + 10 > 280: # 280mm is roughly where we want to break
|
| 182 |
+
pdf.add_page()
|
| 183 |
+
current_y = 25
|
| 184 |
+
else:
|
| 185 |
+
pdf.ln(3) # Consistent space for empty lines
|
| 186 |
+
current_y = pdf.get_y()
|
| 187 |
+
continue
|
| 188 |
+
|
| 189 |
+
# Calculate estimated height needed for this line
|
| 190 |
+
estimated_height = 12 # Base height for text
|
| 191 |
+
|
| 192 |
+
# Handle different heading levels
|
| 193 |
+
if line.startswith("# "): # H1 - Main heading (like "Company Profile")
|
| 194 |
+
estimated_height = 25 # Heading + line + spacing
|
| 195 |
+
if current_y + estimated_height > 280:
|
| 196 |
+
pdf.add_page()
|
| 197 |
+
current_y = 25
|
| 198 |
+
|
| 199 |
+
text = line[2:].strip()
|
| 200 |
+
pdf.set_font("Arial", "B", 20) # Bold, size 20
|
| 201 |
+
pdf.set_text_color(*primary_color) # Black
|
| 202 |
+
pdf.set_xy(25, current_y)
|
| 203 |
+
pdf.multi_cell(160, 10, text)
|
| 204 |
+
|
| 205 |
+
# Calculate text width and make line autofit
|
| 206 |
+
text_width = pdf.get_string_width(text)
|
| 207 |
+
line_width = min(text_width + 12, 160) # Add 12mm padding, max 160mm
|
| 208 |
+
|
| 209 |
+
# Add subtle line under main heading - consistent with section titles
|
| 210 |
+
pdf.set_draw_color(221, 221, 221) # Lighter gray line (#DDDDDD)
|
| 211 |
+
pdf.set_line_width(0.3) # Thin line
|
| 212 |
+
pdf.line(25, current_y + 10, 25 + line_width, current_y + 10)
|
| 213 |
+
|
| 214 |
+
current_y = pdf.get_y()
|
| 215 |
+
pdf.ln(8) # Consistent space after main heading
|
| 216 |
+
current_y = pdf.get_y()
|
| 217 |
+
|
| 218 |
+
elif line.startswith("## ") or re.match(r'^\d+\.\d+\s+', line): # H2 - Subheading or numbered subheading
|
| 219 |
+
estimated_height = 20 # Subheading + line + spacing
|
| 220 |
+
if current_y + estimated_height > 280:
|
| 221 |
+
pdf.add_page()
|
| 222 |
+
current_y = 25
|
| 223 |
+
|
| 224 |
+
text = line[3:].strip() if line.startswith("## ") else line.strip()
|
| 225 |
+
pdf.set_font("Arial", "B", 16) # Bold, size 16
|
| 226 |
+
pdf.set_text_color(*primary_color) # Black
|
| 227 |
+
pdf.set_xy(25, current_y)
|
| 228 |
+
pdf.multi_cell(160, 8, text)
|
| 229 |
+
|
| 230 |
+
# Calculate text width and make line autofit
|
| 231 |
+
text_width = pdf.get_string_width(text)
|
| 232 |
+
line_width = min(text_width + 10, 160) # Add 10mm padding, max 160mm
|
| 233 |
+
|
| 234 |
+
# Add subtle line under subheading - consistent styling
|
| 235 |
+
pdf.set_draw_color(221, 221, 221) # Lighter gray line (#DDDDDD)
|
| 236 |
+
pdf.set_line_width(0.3) # Thin line
|
| 237 |
+
pdf.line(25, current_y + 8, 25 + line_width, current_y + 8)
|
| 238 |
+
|
| 239 |
+
current_y = pdf.get_y()
|
| 240 |
+
pdf.ln(6) # Consistent space after subheading
|
| 241 |
+
current_y = pdf.get_y()
|
| 242 |
+
|
| 243 |
+
elif re.match(r'^\d+\.\d+\s+[A-Z]', line): # Numbered subheadings like "3.6 SWOT Analysis"
|
| 244 |
+
estimated_height = 20 # Subheading + line + spacing
|
| 245 |
+
if current_y + estimated_height > 280:
|
| 246 |
+
pdf.add_page()
|
| 247 |
+
current_y = 25
|
| 248 |
+
|
| 249 |
+
text = line.strip()
|
| 250 |
+
pdf.set_font("Arial", "B", 16) # Bold, size 16
|
| 251 |
+
pdf.set_text_color(*primary_color) # Black
|
| 252 |
+
pdf.set_xy(25, current_y)
|
| 253 |
+
pdf.multi_cell(160, 8, text)
|
| 254 |
+
|
| 255 |
+
|
| 256 |
+
current_y = pdf.get_y()
|
| 257 |
+
pdf.ln(6) # Consistent space after subheading
|
| 258 |
+
current_y = pdf.get_y()
|
| 259 |
+
|
| 260 |
+
elif line.startswith("### ") or line.startswith("#### "): # H3/H4 - Smaller subheadings
|
| 261 |
+
estimated_height = 18 # Subheading + spacing
|
| 262 |
+
if current_y + estimated_height > 280:
|
| 263 |
+
pdf.add_page()
|
| 264 |
+
current_y = 25
|
| 265 |
+
|
| 266 |
+
text = line[4:].strip() if line.startswith("### ") else line[5:].strip()
|
| 267 |
+
pdf.set_font("Arial", "B", 14) # Bold, size 14
|
| 268 |
+
pdf.set_text_color(*primary_color) # Black
|
| 269 |
+
pdf.set_xy(25, current_y)
|
| 270 |
+
pdf.multi_cell(160, 8, text)
|
| 271 |
+
|
| 272 |
+
current_y = pdf.get_y()
|
| 273 |
+
pdf.ln(4) # Consistent space after subheading
|
| 274 |
+
current_y = pdf.get_y()
|
| 275 |
+
|
| 276 |
+
elif line.startswith("**") and line.endswith("**"): # Bold text that might be a heading
|
| 277 |
+
text = line.strip() # Keep ** markers
|
| 278 |
+
# Check if this looks like a main heading (no numbers, not too long)
|
| 279 |
+
if not re.match(r'^\d+\.', text) and len(text) < 50:
|
| 280 |
+
estimated_height = 22 # Heading + line + spacing
|
| 281 |
+
if current_y + estimated_height > 280:
|
| 282 |
+
pdf.add_page()
|
| 283 |
+
current_y = 25
|
| 284 |
+
|
| 285 |
+
pdf.set_font("Arial", "B", 18) # Bold, size 18
|
| 286 |
+
pdf.set_text_color(*primary_color) # Black
|
| 287 |
+
pdf.set_xy(25, current_y)
|
| 288 |
+
pdf.multi_cell(160, 9, text)
|
| 289 |
+
|
| 290 |
+
current_y = pdf.get_y()
|
| 291 |
+
pdf.ln(6) # Consistent space after heading
|
| 292 |
+
current_y = pdf.get_y()
|
| 293 |
+
else:
|
| 294 |
+
# Regular bold text
|
| 295 |
+
estimated_height = 12
|
| 296 |
+
if current_y + estimated_height > 280:
|
| 297 |
+
pdf.add_page()
|
| 298 |
+
current_y = 25
|
| 299 |
+
|
| 300 |
+
pdf.set_font("Arial", "B", 12) # Bold, size 12
|
| 301 |
+
pdf.set_text_color(*text_color) # Dark gray
|
| 302 |
+
pdf.set_xy(25, current_y)
|
| 303 |
+
pdf.multi_cell(160, 8, text)
|
| 304 |
+
current_y = pdf.get_y()
|
| 305 |
+
|
| 306 |
+
else: # Normal paragraph text
|
| 307 |
+
# For long paragraphs, estimate height based on text length
|
| 308 |
+
estimated_height = max(12, len(line) // 80 * 12) # Rough estimate
|
| 309 |
+
if current_y + estimated_height > 280:
|
| 310 |
+
pdf.add_page()
|
| 311 |
+
current_y = 25
|
| 312 |
+
|
| 313 |
+
pdf.set_font("Arial", "", 12) # Regular font, size 12
|
| 314 |
+
pdf.set_text_color(*text_color) # Dark gray
|
| 315 |
+
pdf.set_xy(25, current_y)
|
| 316 |
+
pdf.multi_cell(160, 8, line, align='L') # Left align to prevent text justification
|
| 317 |
+
current_y = pdf.get_y()
|
| 318 |
+
|
| 319 |
+
|
| 320 |
+
def render_markdown_to_pdf_grouped(pdf, content: str, primary_color, accent_color, text_color):
|
| 321 |
+
"""Render markdown content to PDF with subheading-content grouping and smart page breaks"""
|
| 322 |
+
if not content:
|
| 323 |
+
return
|
| 324 |
+
|
| 325 |
+
# print(f"DEBUG: render_markdown_to_pdf_grouped called with content length: {len(content)}")
|
| 326 |
+
# print(f"DEBUG: FULL CONTENT:")
|
| 327 |
+
# print("=" * 80)
|
| 328 |
+
# print(content)
|
| 329 |
+
# print("=" * 80)
|
| 330 |
+
|
| 331 |
+
# Parse content into groups
|
| 332 |
+
groups = parse_content_into_groups(content)
|
| 333 |
+
|
| 334 |
+
for group in groups:
|
| 335 |
+
subheading = group['subheading']
|
| 336 |
+
group_content = group['content']
|
| 337 |
+
subheading_type = group['subheading_type']
|
| 338 |
+
|
| 339 |
+
# Get current Y position from PDF
|
| 340 |
+
current_y = pdf.get_y()
|
| 341 |
+
page_height = 280 # Approximate usable page height
|
| 342 |
+
|
| 343 |
+
# Use smart page break logic
|
| 344 |
+
if should_break_page_for_group(pdf, group, current_y, page_height):
|
| 345 |
+
pdf.add_page()
|
| 346 |
+
current_y = 25
|
| 347 |
+
pdf.set_y(current_y) # Set the PDF's Y position
|
| 348 |
+
|
| 349 |
+
# Render subheading based on type - using original styling
|
| 350 |
+
# Use the pre-cleaned text from the group
|
| 351 |
+
text = group['subheading_clean']
|
| 352 |
+
|
| 353 |
+
if subheading_type == 'h1':
|
| 354 |
+
pdf.set_font("Arial", "B", 20) # Bold, size 20 (original size)
|
| 355 |
+
pdf.set_text_color(*primary_color) # Black
|
| 356 |
+
pdf.set_xy(25, current_y)
|
| 357 |
+
pdf.multi_cell(160, 10, text)
|
| 358 |
+
|
| 359 |
+
# Calculate text width and make line autofit
|
| 360 |
+
text_width = pdf.get_string_width(text)
|
| 361 |
+
line_width = min(text_width + 12, 160) # Add 12mm padding, max 160mm
|
| 362 |
+
|
| 363 |
+
# Add subtle line under main heading - consistent with section titles
|
| 364 |
+
pdf.set_draw_color(221, 221, 221) # Lighter gray line (#DDDDDD)
|
| 365 |
+
pdf.set_line_width(0.3) # Thin line
|
| 366 |
+
pdf.line(25, current_y + 10, 25 + line_width, current_y + 10)
|
| 367 |
+
|
| 368 |
+
current_y = pdf.get_y()
|
| 369 |
+
pdf.ln(8) # Consistent space after main heading
|
| 370 |
+
current_y = pdf.get_y()
|
| 371 |
+
|
| 372 |
+
elif subheading_type == 'h2':
|
| 373 |
+
pdf.set_font("Arial", "B", 18) # Bold, size 18 (original size)
|
| 374 |
+
pdf.set_text_color(*primary_color) # Black
|
| 375 |
+
pdf.set_xy(25, current_y)
|
| 376 |
+
pdf.multi_cell(160, 9, text)
|
| 377 |
+
|
| 378 |
+
# Add underline (original styling)
|
| 379 |
+
text_width = pdf.get_string_width(text)
|
| 380 |
+
line_width = min(text_width + 12, 160)
|
| 381 |
+
pdf.set_draw_color(221, 221, 221) # Light gray line
|
| 382 |
+
pdf.set_line_width(0.3)
|
| 383 |
+
pdf.line(25, current_y + 9, 25 + line_width, current_y + 9)
|
| 384 |
+
|
| 385 |
+
current_y = pdf.get_y()
|
| 386 |
+
pdf.ln(6) # Space after subheading
|
| 387 |
+
current_y = pdf.get_y()
|
| 388 |
+
|
| 389 |
+
elif subheading_type == 'h3':
|
| 390 |
+
pdf.set_font("Arial", "B", 14) # Bold, size 14 (original size)
|
| 391 |
+
pdf.set_text_color(*primary_color) # Black
|
| 392 |
+
pdf.set_xy(25, current_y)
|
| 393 |
+
pdf.multi_cell(160, 8, text)
|
| 394 |
+
|
| 395 |
+
current_y = pdf.get_y()
|
| 396 |
+
pdf.ln(4) # Space after subheading
|
| 397 |
+
current_y = pdf.get_y()
|
| 398 |
+
|
| 399 |
+
elif subheading_type == 'h4':
|
| 400 |
+
pdf.set_font("Arial", "B", 12) # Bold, size 12 (original size)
|
| 401 |
+
pdf.set_text_color(*primary_color) # Black
|
| 402 |
+
pdf.set_xy(25, current_y)
|
| 403 |
+
pdf.multi_cell(160, 7, text)
|
| 404 |
+
|
| 405 |
+
current_y = pdf.get_y()
|
| 406 |
+
pdf.ln(3) # Space after subheading
|
| 407 |
+
current_y = pdf.get_y()
|
| 408 |
+
|
| 409 |
+
elif subheading_type == 'bold':
|
| 410 |
+
line_height_heading = 9 # For size 18 heading
|
| 411 |
+
line_height_text = 8 # For size 12 text
|
| 412 |
+
|
| 413 |
+
if not re.match(r'^\d+\.', text) and len(text) < 80:
|
| 414 |
+
# Main heading style
|
| 415 |
+
pdf.set_font("Arial", "B", 12) # Bold, size 12
|
| 416 |
+
pdf.set_text_color(*primary_color) # Black
|
| 417 |
+
pdf.set_xy(25, current_y)
|
| 418 |
+
pdf.multi_cell(160, 8, text) # Multi-cell for text
|
| 419 |
+
current_y = pdf.get_y() # Get updated Y position
|
| 420 |
+
pdf.ln(6)
|
| 421 |
+
current_y = pdf.get_y()
|
| 422 |
+
else:
|
| 423 |
+
# Regular bold text
|
| 424 |
+
pdf.set_font("Arial", "B", 12) # Bold, size 12
|
| 425 |
+
pdf.set_text_color(*text_color) # Dark gray
|
| 426 |
+
pdf.set_xy(25, current_y)
|
| 427 |
+
pdf.multi_cell(160, 8, text) # Multi-cell for text
|
| 428 |
+
current_y = pdf.get_y() # Get updated Y position
|
| 429 |
+
|
| 430 |
+
elif subheading_type == 'numbered':
|
| 431 |
+
pdf.set_font("Arial", "B", 18) # Bold, size 18 (original size)
|
| 432 |
+
pdf.set_text_color(*primary_color) # Black
|
| 433 |
+
pdf.set_xy(25, current_y)
|
| 434 |
+
pdf.multi_cell(160, 9, text)
|
| 435 |
+
|
| 436 |
+
# Add underline (original styling)
|
| 437 |
+
text_width = pdf.get_string_width(text)
|
| 438 |
+
line_width = min(text_width + 12, 160)
|
| 439 |
+
pdf.set_draw_color(221, 221, 221) # Light gray line
|
| 440 |
+
pdf.set_line_width(0.3)
|
| 441 |
+
pdf.line(25, current_y + 9, 25 + line_width, current_y + 9)
|
| 442 |
+
|
| 443 |
+
current_y = pdf.get_y()
|
| 444 |
+
pdf.ln(6) # Space after subheading
|
| 445 |
+
current_y = pdf.get_y()
|
| 446 |
+
|
| 447 |
+
# Render grouped content with simple styling
|
| 448 |
+
if group_content:
|
| 449 |
+
content_lines = group_content.split('\n')
|
| 450 |
+
for line in content_lines:
|
| 451 |
+
if line.strip():
|
| 452 |
+
# Check if line contains text ending with ":" that should be bold
|
| 453 |
+
if ':' in line:
|
| 454 |
+
# Split the line by ":" to separate the label from the content
|
| 455 |
+
parts = line.split(':', 1) # Split only on first ":"
|
| 456 |
+
if len(parts) == 2:
|
| 457 |
+
label = parts[0].strip()
|
| 458 |
+
content = parts[1].strip()
|
| 459 |
+
|
| 460 |
+
# Render label in bold
|
| 461 |
+
pdf.set_font("Arial", "B", 12) # Bold
|
| 462 |
+
pdf.set_text_color(*text_color)
|
| 463 |
+
pdf.set_xy(25, current_y)
|
| 464 |
+
pdf.multi_cell(160, 8, label + ":")
|
| 465 |
+
current_y = pdf.get_y()
|
| 466 |
+
|
| 467 |
+
# Render content in regular font
|
| 468 |
+
if content:
|
| 469 |
+
pdf.set_font("Arial", "", 12) # Regular
|
| 470 |
+
pdf.set_text_color(*text_color)
|
| 471 |
+
pdf.set_xy(25, current_y)
|
| 472 |
+
pdf.multi_cell(160, 8, content, align='L') # Left align to prevent text justification
|
| 473 |
+
current_y = pdf.get_y()
|
| 474 |
+
else:
|
| 475 |
+
# No content after ":", just render the whole line
|
| 476 |
+
pdf.set_font("Arial", "B", 12) # Bold for labels ending with ":"
|
| 477 |
+
pdf.set_text_color(*text_color)
|
| 478 |
+
pdf.set_xy(25, current_y)
|
| 479 |
+
pdf.multi_cell(160, 8, line, align='L') # Left align to prevent text justification
|
| 480 |
+
current_y = pdf.get_y()
|
| 481 |
+
elif ' - ' in line:
|
| 482 |
+
# Split the line by " - " to separate the label from the content
|
| 483 |
+
parts = line.split(' - ', 1) # Split only on first " - "
|
| 484 |
+
if len(parts) == 2:
|
| 485 |
+
label = parts[0].strip()
|
| 486 |
+
content = parts[1].strip()
|
| 487 |
+
|
| 488 |
+
# Render label in bold
|
| 489 |
+
pdf.set_font("Arial", "B", 12) # Bold
|
| 490 |
+
pdf.set_text_color(*text_color)
|
| 491 |
+
pdf.set_xy(25, current_y)
|
| 492 |
+
pdf.multi_cell(160, 8, label + " -")
|
| 493 |
+
current_y = pdf.get_y()
|
| 494 |
+
|
| 495 |
+
# Render content in regular font
|
| 496 |
+
if content:
|
| 497 |
+
pdf.set_font("Arial", "", 12) # Regular
|
| 498 |
+
pdf.set_text_color(*text_color)
|
| 499 |
+
pdf.set_xy(25, current_y)
|
| 500 |
+
pdf.multi_cell(160, 8, content, align='L') # Left align to prevent text justification
|
| 501 |
+
current_y = pdf.get_y()
|
| 502 |
+
else:
|
| 503 |
+
# No content after " - ", just render the whole line
|
| 504 |
+
pdf.set_font("Arial", "B", 12) # Bold for labels ending with " -"
|
| 505 |
+
pdf.set_text_color(*text_color)
|
| 506 |
+
pdf.set_xy(25, current_y)
|
| 507 |
+
pdf.multi_cell(160, 8, line, align='L') # Left align to prevent text justification
|
| 508 |
+
current_y = pdf.get_y()
|
| 509 |
+
else:
|
| 510 |
+
# Regular paragraph rendering without mixed text processing
|
| 511 |
+
pdf.set_font("Arial", "", 12)
|
| 512 |
+
pdf.set_text_color(*text_color)
|
| 513 |
+
pdf.set_xy(25, current_y)
|
| 514 |
+
pdf.multi_cell(160, 8, line, align='L') # Left align to prevent text justification
|
| 515 |
+
current_y = pdf.get_y() # Update current_y after each line
|
| 516 |
+
else:
|
| 517 |
+
# Empty line spacing
|
| 518 |
+
pdf.ln(3)
|
| 519 |
+
current_y = pdf.get_y() # Update current_y after spacing
|
| 520 |
+
|
| 521 |
+
|
| 522 |
+
def render_markdown_to_docx(doc, content: str):
|
| 523 |
+
"""Render markdown content to Word document with proper heading styling"""
|
| 524 |
+
if not content:
|
| 525 |
+
return
|
| 526 |
+
|
| 527 |
+
lines = content.split('\n')
|
| 528 |
+
|
| 529 |
+
for line in lines:
|
| 530 |
+
line = line.strip()
|
| 531 |
+
if not line:
|
| 532 |
+
doc.add_paragraph() # Empty paragraph for spacing
|
| 533 |
+
continue
|
| 534 |
+
|
| 535 |
+
# Handle different heading levels
|
| 536 |
+
if line.startswith("# "): # H1 - Main heading (like "Company Profile")
|
| 537 |
+
text = line[2:].strip()
|
| 538 |
+
doc.add_heading(text, level=1)
|
| 539 |
+
elif line.startswith("## ") or re.match(r'^\d+\.\d+\s+', line): # H2 - Subheading or numbered subheading
|
| 540 |
+
text = line[3:].strip() if line.startswith("## ") else line.strip()
|
| 541 |
+
doc.add_heading(text, level=2)
|
| 542 |
+
elif re.match(r'^\d+\.\d+\s+[A-Z]', line): # Numbered subheadings like "3.6 SWOT Analysis"
|
| 543 |
+
text = line.strip()
|
| 544 |
+
doc.add_heading(text, level=2)
|
| 545 |
+
elif line.startswith("### ") or line.startswith("#### "): # H3/H4 - Smaller subheadings
|
| 546 |
+
text = line[4:].strip() if line.startswith("### ") else line[5:].strip()
|
| 547 |
+
doc.add_heading(text, level=3)
|
| 548 |
+
elif line.startswith("**") and line.endswith("**"): # Bold text that might be a heading
|
| 549 |
+
text = line.strip() # Keep ** markers
|
| 550 |
+
# Check if this looks like a main heading (no numbers, not too long)
|
| 551 |
+
if not re.match(r'^\d+\.', text) and len(text) < 50:
|
| 552 |
+
doc.add_heading(text, level=1) # Treat as main heading
|
| 553 |
+
else:
|
| 554 |
+
# Regular bold text
|
| 555 |
+
doc.add_paragraph(text)
|
| 556 |
+
else: # Normal paragraph text
|
| 557 |
+
# Check if line contains text ending with ":" that should be bold
|
| 558 |
+
if ':' in line:
|
| 559 |
+
# Split the line by ":" to separate the label from the content
|
| 560 |
+
parts = line.split(':', 1) # Split only on first ":"
|
| 561 |
+
if len(parts) == 2:
|
| 562 |
+
label = parts[0].strip()
|
| 563 |
+
content = parts[1].strip()
|
| 564 |
+
|
| 565 |
+
# Create paragraph with mixed formatting
|
| 566 |
+
paragraph = doc.add_paragraph()
|
| 567 |
+
# Add label in bold
|
| 568 |
+
run = paragraph.add_run(label + ":")
|
| 569 |
+
run.bold = True
|
| 570 |
+
# Add content in regular font
|
| 571 |
+
if content:
|
| 572 |
+
run = paragraph.add_run(" " + content)
|
| 573 |
+
run.bold = False
|
| 574 |
+
else:
|
| 575 |
+
# No content after ":", just render the whole line in bold
|
| 576 |
+
paragraph = doc.add_paragraph(line)
|
| 577 |
+
paragraph.runs[0].bold = True
|
| 578 |
+
elif ' - ' in line:
|
| 579 |
+
# Split the line by " - " to separate the label from the content
|
| 580 |
+
parts = line.split(' - ', 1) # Split only on first " - "
|
| 581 |
+
if len(parts) == 2:
|
| 582 |
+
label = parts[0].strip()
|
| 583 |
+
content = parts[1].strip()
|
| 584 |
+
|
| 585 |
+
# Create paragraph with mixed formatting
|
| 586 |
+
paragraph = doc.add_paragraph()
|
| 587 |
+
# Add label in bold
|
| 588 |
+
run = paragraph.add_run(label + " -")
|
| 589 |
+
run.bold = True
|
| 590 |
+
# Add content in regular font
|
| 591 |
+
if content:
|
| 592 |
+
run = paragraph.add_run(" " + content)
|
| 593 |
+
run.bold = False
|
| 594 |
+
else:
|
| 595 |
+
# No content after " - ", just render the whole line in bold
|
| 596 |
+
paragraph = doc.add_paragraph(line)
|
| 597 |
+
paragraph.runs[0].bold = True
|
| 598 |
+
else:
|
| 599 |
+
doc.add_paragraph(line)
|
| 600 |
+
|
| 601 |
+
|
| 602 |
+
def render_markdown_to_docx_grouped(doc, content: str):
|
| 603 |
+
"""Render markdown content to Word document with subheading-content grouping and smart page breaks"""
|
| 604 |
+
if not content:
|
| 605 |
+
return
|
| 606 |
+
|
| 607 |
+
# Parse content into groups
|
| 608 |
+
groups = parse_content_into_groups(content)
|
| 609 |
+
|
| 610 |
+
for i, group in enumerate(groups):
|
| 611 |
+
subheading = group['subheading']
|
| 612 |
+
group_content = group['content']
|
| 613 |
+
subheading_type = group['subheading_type']
|
| 614 |
+
|
| 615 |
+
# Add page break before group if it's not the first group and the previous group was large
|
| 616 |
+
if i > 0:
|
| 617 |
+
# Check if we should add a page break to keep groups together
|
| 618 |
+
# This is a simple heuristic - in Word, we rely more on the natural flow
|
| 619 |
+
# but we can add manual page breaks for very large groups
|
| 620 |
+
if group['content_length'] > 1000: # Large group threshold
|
| 621 |
+
doc.add_page_break()
|
| 622 |
+
|
| 623 |
+
# Render subheading based on type - using original styling
|
| 624 |
+
# Use the pre-cleaned text from the group
|
| 625 |
+
text = group['subheading_clean']
|
| 626 |
+
|
| 627 |
+
if subheading_type == 'h1':
|
| 628 |
+
doc.add_heading(text, level=1)
|
| 629 |
+
elif subheading_type == 'h2':
|
| 630 |
+
doc.add_heading(text, level=2)
|
| 631 |
+
elif subheading_type == 'h3':
|
| 632 |
+
doc.add_heading(text, level=3)
|
| 633 |
+
elif subheading_type == 'h4':
|
| 634 |
+
doc.add_heading(text, level=4)
|
| 635 |
+
elif subheading_type == 'bold':
|
| 636 |
+
if not re.match(r'^\d+\.', text) and len(text) < 80:
|
| 637 |
+
# Simple paragraph with bold text
|
| 638 |
+
paragraph = doc.add_paragraph(text)
|
| 639 |
+
paragraph.style = doc.styles['Heading 1'] # Apply heading style
|
| 640 |
+
else:
|
| 641 |
+
# Regular paragraph
|
| 642 |
+
doc.add_paragraph(text)
|
| 643 |
+
elif subheading_type == 'numbered':
|
| 644 |
+
doc.add_heading(text, level=2)
|
| 645 |
+
|
| 646 |
+
# Render grouped content with simple styling
|
| 647 |
+
if group_content:
|
| 648 |
+
content_lines = group_content.split('\n')
|
| 649 |
+
for line in content_lines:
|
| 650 |
+
if line.strip():
|
| 651 |
+
# Check if line contains text ending with ":" that should be bold
|
| 652 |
+
if ':' in line:
|
| 653 |
+
# Split the line by ":" to separate the label from the content
|
| 654 |
+
parts = line.split(':', 1) # Split only on first ":"
|
| 655 |
+
if len(parts) == 2:
|
| 656 |
+
label = parts[0].strip()
|
| 657 |
+
content = parts[1].strip()
|
| 658 |
+
|
| 659 |
+
# Create paragraph with mixed formatting
|
| 660 |
+
paragraph = doc.add_paragraph()
|
| 661 |
+
# Add label in bold
|
| 662 |
+
run = paragraph.add_run(label + ":")
|
| 663 |
+
run.bold = True
|
| 664 |
+
# Add content in regular font
|
| 665 |
+
if content:
|
| 666 |
+
run = paragraph.add_run(" " + content)
|
| 667 |
+
run.bold = False
|
| 668 |
+
else:
|
| 669 |
+
# No content after ":", just render the whole line in bold
|
| 670 |
+
paragraph = doc.add_paragraph(line)
|
| 671 |
+
paragraph.runs[0].bold = True
|
| 672 |
+
elif ' - ' in line:
|
| 673 |
+
# Split the line by " - " to separate the label from the content
|
| 674 |
+
parts = line.split(' - ', 1) # Split only on first " - "
|
| 675 |
+
if len(parts) == 2:
|
| 676 |
+
label = parts[0].strip()
|
| 677 |
+
content = parts[1].strip()
|
| 678 |
+
|
| 679 |
+
# Create paragraph with mixed formatting
|
| 680 |
+
paragraph = doc.add_paragraph()
|
| 681 |
+
# Add label in bold
|
| 682 |
+
run = paragraph.add_run(label + " -")
|
| 683 |
+
run.bold = True
|
| 684 |
+
# Add content in regular font
|
| 685 |
+
if content:
|
| 686 |
+
run = paragraph.add_run(" " + content)
|
| 687 |
+
run.bold = False
|
| 688 |
+
else:
|
| 689 |
+
# No content after " - ", just render the whole line in bold
|
| 690 |
+
paragraph = doc.add_paragraph(line)
|
| 691 |
+
paragraph.runs[0].bold = True
|
| 692 |
+
else:
|
| 693 |
+
# Simple paragraph addition without mixed text processing
|
| 694 |
+
doc.add_paragraph(line)
|
| 695 |
+
else:
|
| 696 |
+
doc.add_paragraph() # Empty paragraph for spacing
|
| 697 |
+
|
| 698 |
+
|
| 699 |
+
|
| 700 |
+
def clean_text_for_docx(text: str) -> str:
|
| 701 |
+
# Replace problematic Unicode characters with ASCII equivalents
|
| 702 |
+
unicode_replacements = {
|
| 703 |
+
'\u2019': "'", # Right single quotation mark
|
| 704 |
+
'\u2018': "'", # Left single quotation mark
|
| 705 |
+
'\u201C': '"', # Left double quotation mark
|
| 706 |
+
'\u201D': '"', # Right double quotation mark
|
| 707 |
+
'\u2013': '-', # En dash
|
| 708 |
+
'\u2014': '--', # Em dash
|
| 709 |
+
'\u2022': '•', # Bullet
|
| 710 |
+
'\u2026': '...', # Horizontal ellipsis
|
| 711 |
+
'\u00A0': ' ', # Non-breaking space
|
| 712 |
+
'\u00B0': '°', # Degree sign
|
| 713 |
+
'\u00AE': '(R)', # Registered trademark
|
| 714 |
+
'\u2122': '(TM)', # Trademark
|
| 715 |
+
'\u00A9': '(C)', # Copyright
|
| 716 |
+
}
|
| 717 |
+
|
| 718 |
+
for unicode_char, replacement in unicode_replacements.items():
|
| 719 |
+
text = text.replace(unicode_char, replacement)
|
| 720 |
+
|
| 721 |
+
# Simple text cleaning - keep basic punctuation and common symbols
|
| 722 |
+
text = ''.join(char for char in text if ord(char) < 128 or char in '•°$€£¥₹₩₽₪₺₴₼₸₾֏₲₡₣₦₵₨₱₫₭៛৳؋﷼%')
|
| 723 |
+
|
| 724 |
+
return text
|
| 725 |
+
|
| 726 |
+
|
| 727 |
+
|
| 728 |
+
|
| 729 |
+
|
| 730 |
+
|
| 731 |
+
|
| 732 |
+
|
| 733 |
+
|
| 734 |
+
def clean_unicode_for_pdf(text: str) -> str:
|
| 735 |
+
# Replace problematic Unicode characters with ASCII equivalents
|
| 736 |
+
unicode_replacements = {
|
| 737 |
+
'\u2019': "'", # Right single quotation mark
|
| 738 |
+
'\u2018': "'", # Left single quotation mark
|
| 739 |
+
'\u201C': '"', # Left double quotation mark
|
| 740 |
+
'\u201D': '"', # Right double quotation mark
|
| 741 |
+
'\u2013': '-', # En dash
|
| 742 |
+
'\u2014': '--', # Em dash
|
| 743 |
+
'\u2022': '•', # Bullet
|
| 744 |
+
'\u2026': '...', # Horizontal ellipsis
|
| 745 |
+
'\u00A0': ' ', # Non-breaking space
|
| 746 |
+
'\u00B0': '°', # Degree sign
|
| 747 |
+
'\u00AE': '(R)', # Registered trademark
|
| 748 |
+
'\u2122': '(TM)', # Trademark
|
| 749 |
+
'\u00A9': '(C)', # Copyright
|
| 750 |
+
}
|
| 751 |
+
|
| 752 |
+
for unicode_char, replacement in unicode_replacements.items():
|
| 753 |
+
text = text.replace(unicode_char, replacement)
|
| 754 |
+
|
| 755 |
+
# Simple text cleaning - keep basic punctuation and common symbols
|
| 756 |
+
text = ''.join(char for char in text if ord(char) < 128 or char in '•°$€£¥₹₩₽₪₺₴₼₸₾֏₲₡₣₦₵₨₱₫₭៛৳؋﷼%')
|
| 757 |
+
|
| 758 |
+
return text
|
| 759 |
+
|
app/DocumentGeneration/mixed.py
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# This file has been removed as all its functions are not present in maintest.txt
|
| 2 |
+
# Functions removed:
|
| 3 |
+
# - make_sentences_ending_with_colon_bold()
|
| 4 |
+
# - _render_mixed_text_to_pdf()
|
| 5 |
+
# - clean_bold_text_for_pdf()
|
| 6 |
+
# - process_bold_text_for_docx()
|
app/DocumentGeneration/pdf_generator.py
ADDED
|
@@ -0,0 +1,688 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# PDF Generator Module
|
| 2 |
+
# Contains create_pdf_document() and all PDF-related functions
|
| 3 |
+
|
| 4 |
+
import re
|
| 5 |
+
import os
|
| 6 |
+
import tempfile
|
| 7 |
+
import time
|
| 8 |
+
import sys
|
| 9 |
+
from typing import List, Dict, Any
|
| 10 |
+
|
| 11 |
+
# Add the parent directory to sys.path to import from models.py
|
| 12 |
+
sys.path.append(os.path.dirname(os.path.dirname(__file__)))
|
| 13 |
+
from models import ExportRequest
|
| 14 |
+
|
| 15 |
+
# Import from the same directory (Document Generation)
|
| 16 |
+
from document_helpers import (
|
| 17 |
+
filter_empty_sections,
|
| 18 |
+
extract_business_name_from_content,
|
| 19 |
+
remove_duplicate_headings
|
| 20 |
+
)
|
| 21 |
+
from table_of_content import generate_table_of_contents_after_content, add_table_of_contents_page
|
| 22 |
+
from chart_generator import parse_embedded_chart_data, create_chart_from_data
|
| 23 |
+
from markdown_renderer import (
|
| 24 |
+
clean_text_for_pdf,
|
| 25 |
+
render_markdown_to_pdf_grouped
|
| 26 |
+
)
|
| 27 |
+
|
| 28 |
+
# Add Currency Mapping path
|
| 29 |
+
currency_mapping_path = os.path.join(os.path.dirname(os.path.dirname(__file__)), 'CurrencyMapping')
|
| 30 |
+
if currency_mapping_path not in sys.path:
|
| 31 |
+
sys.path.append(currency_mapping_path)
|
| 32 |
+
from CurrencyMapping.currency_mapping import convert_currency_symbols_to_iso
|
| 33 |
+
|
| 34 |
+
# Import sort_sections_by_order from utils module
|
| 35 |
+
from utils import sort_sections_by_order
|
| 36 |
+
|
| 37 |
+
def calculate_real_page_numbers(request: ExportRequest, filtered_sections):
|
| 38 |
+
"""Calculate real page numbers by creating a temporary PDF with all content INCLUDING charts"""
|
| 39 |
+
try:
|
| 40 |
+
from fpdf2 import FPDF
|
| 41 |
+
except ImportError:
|
| 42 |
+
try:
|
| 43 |
+
from fpdf import FPDF
|
| 44 |
+
except ImportError:
|
| 45 |
+
raise Exception("Neither fpdf2 nor fpdf package found. Please install fpdf2.")
|
| 46 |
+
|
| 47 |
+
# Create temporary PDF to calculate page numbers
|
| 48 |
+
temp_pdf = FPDF()
|
| 49 |
+
temp_pdf.set_auto_page_break(auto=True, margin=15)
|
| 50 |
+
|
| 51 |
+
# Add cover page
|
| 52 |
+
temp_pdf.add_page()
|
| 53 |
+
|
| 54 |
+
# Add TOC page (placeholder)
|
| 55 |
+
temp_pdf.add_page()
|
| 56 |
+
|
| 57 |
+
# Parse charts from embedded chart data in section content
|
| 58 |
+
embedded_charts = parse_embedded_chart_data(filtered_sections)
|
| 59 |
+
|
| 60 |
+
# Create charts and map them to sections
|
| 61 |
+
chart_info_map = {}
|
| 62 |
+
if embedded_charts:
|
| 63 |
+
for chart_info in embedded_charts:
|
| 64 |
+
chart_bytes = create_chart_from_data(chart_info, request.businessIdea or "Business")
|
| 65 |
+
if chart_bytes:
|
| 66 |
+
source_section = chart_info["source_section"]
|
| 67 |
+
if source_section not in chart_info_map:
|
| 68 |
+
chart_info_map[source_section] = []
|
| 69 |
+
chart_info_map[source_section].append({
|
| 70 |
+
"chart_bytes": chart_bytes,
|
| 71 |
+
"title": chart_info["title"],
|
| 72 |
+
"type": chart_info["type"]
|
| 73 |
+
})
|
| 74 |
+
|
| 75 |
+
# Add all sections to calculate real page numbers INCLUDING charts
|
| 76 |
+
section_page_map = {}
|
| 77 |
+
sorted_sections = sort_sections_by_order(filtered_sections)
|
| 78 |
+
|
| 79 |
+
for section in sorted_sections:
|
| 80 |
+
temp_pdf.add_page()
|
| 81 |
+
current_page = temp_pdf.page_no()
|
| 82 |
+
section_page_map[section.title] = current_page
|
| 83 |
+
|
| 84 |
+
# Don't add section title manually - let the markdown content handle headings
|
| 85 |
+
|
| 86 |
+
# Add section content
|
| 87 |
+
temp_pdf.set_font("Arial", "", 12)
|
| 88 |
+
temp_pdf.set_text_color(51, 51, 51)
|
| 89 |
+
temp_pdf.set_xy(25, temp_pdf.get_y())
|
| 90 |
+
|
| 91 |
+
# Process content
|
| 92 |
+
try:
|
| 93 |
+
content_text = clean_text_for_pdf(section.content)
|
| 94 |
+
except Exception as e:
|
| 95 |
+
content_text = section.content or ""
|
| 96 |
+
|
| 97 |
+
# Remove chart data markers
|
| 98 |
+
content_text = re.sub(r'<!--CHARTDATASTART-->.*?<!--CHARTDATAEND-->', '', content_text, flags=re.DOTALL)
|
| 99 |
+
content_text = re.sub(r'<!--CHARTDATASTART-->.*$', '', content_text, flags=re.DOTALL)
|
| 100 |
+
|
| 101 |
+
# No need to filter section titles since we're not adding them manually
|
| 102 |
+
|
| 103 |
+
# Add content with proper markdown rendering
|
| 104 |
+
if content_text and content_text.strip():
|
| 105 |
+
render_markdown_to_pdf_grouped(temp_pdf, content_text, (0,0,0), (75,0,130), (51,51,51))
|
| 106 |
+
|
| 107 |
+
# Add charts for this section (same logic as in actual PDF generation)
|
| 108 |
+
section_charts = chart_info_map.get(section.title, [])
|
| 109 |
+
|
| 110 |
+
# Try different matching strategies for charts
|
| 111 |
+
if not section_charts:
|
| 112 |
+
# Case-insensitive match
|
| 113 |
+
for chart_source, charts in chart_info_map.items():
|
| 114 |
+
if chart_source.lower() == section.title.lower():
|
| 115 |
+
section_charts = charts
|
| 116 |
+
break
|
| 117 |
+
|
| 118 |
+
# Partial match
|
| 119 |
+
if not section_charts:
|
| 120 |
+
for chart_source, charts in chart_info_map.items():
|
| 121 |
+
if (section.title.lower() in chart_source.lower() or
|
| 122 |
+
chart_source.lower() in section.title.lower()):
|
| 123 |
+
section_charts = charts
|
| 124 |
+
break
|
| 125 |
+
|
| 126 |
+
# Key-based match
|
| 127 |
+
if not section_charts and hasattr(section, 'key'):
|
| 128 |
+
for chart_source, charts in chart_info_map.items():
|
| 129 |
+
if section.key and section.key.replace('prompt_', '') in chart_source.lower():
|
| 130 |
+
section_charts = charts
|
| 131 |
+
break
|
| 132 |
+
|
| 133 |
+
# Add charts with same page break logic as actual PDF
|
| 134 |
+
if section_charts:
|
| 135 |
+
charts_per_page = 0
|
| 136 |
+
max_charts_per_page = 5
|
| 137 |
+
|
| 138 |
+
for chart_info in section_charts:
|
| 139 |
+
try:
|
| 140 |
+
chart_height_needed = 140 # Chart height (120) + title (20) + minimal spacing
|
| 141 |
+
current_y = temp_pdf.get_y()
|
| 142 |
+
charts_per_page += 1
|
| 143 |
+
|
| 144 |
+
# Only force page break if absolutely necessary
|
| 145 |
+
if (current_y + chart_height_needed > 290) or (charts_per_page > 5):
|
| 146 |
+
temp_pdf.add_page()
|
| 147 |
+
y_title = 25
|
| 148 |
+
charts_per_page = 1
|
| 149 |
+
else:
|
| 150 |
+
y_title = current_y
|
| 151 |
+
|
| 152 |
+
# Add minimal spacing before chart if not at top of page
|
| 153 |
+
if y_title > 25:
|
| 154 |
+
temp_pdf.ln(3)
|
| 155 |
+
|
| 156 |
+
# Chart title is already included in the chart itself, no need for separate title
|
| 157 |
+
|
| 158 |
+
# Add chart image
|
| 159 |
+
img_width = 160 # Much larger chart width
|
| 160 |
+
x = (210 - img_width) / 2
|
| 161 |
+
y_img = y_title
|
| 162 |
+
|
| 163 |
+
# Save image to temp file
|
| 164 |
+
with tempfile.NamedTemporaryFile(delete=False, suffix=".png") as tmp_img:
|
| 165 |
+
tmp_img.write(chart_info["chart_bytes"])
|
| 166 |
+
temp_img_path = tmp_img.name
|
| 167 |
+
|
| 168 |
+
# Add chart
|
| 169 |
+
chart_height = 120 # Much larger chart height
|
| 170 |
+
temp_pdf.image(temp_img_path, x=x, y=y_img, w=img_width)
|
| 171 |
+
temp_pdf.ln(chart_height + 5)
|
| 172 |
+
|
| 173 |
+
# Clean up temp file
|
| 174 |
+
try:
|
| 175 |
+
os.remove(temp_img_path)
|
| 176 |
+
except Exception:
|
| 177 |
+
pass
|
| 178 |
+
|
| 179 |
+
except Exception:
|
| 180 |
+
# Skip faulty chart and continue
|
| 181 |
+
continue
|
| 182 |
+
|
| 183 |
+
return section_page_map
|
| 184 |
+
|
| 185 |
+
def create_pdf_document(request: ExportRequest):
|
| 186 |
+
"""Create a PROFESSIONAL PDF document from the business plan data using FPDF2
|
| 187 |
+
NOTE: Now uses matplotlib charts exclusively - old base64 chart logic commented out"""
|
| 188 |
+
try:
|
| 189 |
+
from fpdf2 import FPDF
|
| 190 |
+
except ImportError:
|
| 191 |
+
try:
|
| 192 |
+
from fpdf import FPDF
|
| 193 |
+
except ImportError:
|
| 194 |
+
raise Exception("Neither fpdf2 nor fpdf package found. Please install fpdf2.")
|
| 195 |
+
|
| 196 |
+
# Filter out empty sections to prevent blank pages
|
| 197 |
+
try:
|
| 198 |
+
filtered_sections = filter_empty_sections(request.sections)
|
| 199 |
+
except Exception as e:
|
| 200 |
+
raise Exception(f"Failed to filter sections: {str(e)}")
|
| 201 |
+
if not filtered_sections:
|
| 202 |
+
raise Exception("No valid sections found after filtering. All sections appear to be empty.")
|
| 203 |
+
|
| 204 |
+
# First pass: Calculate real page numbers by creating a temporary PDF
|
| 205 |
+
section_page_map = calculate_real_page_numbers(request, filtered_sections)
|
| 206 |
+
|
| 207 |
+
# Generate TOC with real page numbers
|
| 208 |
+
toc_items = generate_table_of_contents_after_content(filtered_sections, section_page_map, request.businessIdea)
|
| 209 |
+
|
| 210 |
+
# Second pass: Create final PDF with TOC on page 2
|
| 211 |
+
pdf = FPDF()
|
| 212 |
+
pdf.set_auto_page_break(auto=True, margin=15) # Reduced margin to allow more charts per page
|
| 213 |
+
|
| 214 |
+
# Add confidentiality footer to every page
|
| 215 |
+
def add_confidentiality_footer(pdf):
|
| 216 |
+
pdf.set_font("Arial", "", 8)
|
| 217 |
+
pdf.set_text_color(100, 100, 100) # Gray text
|
| 218 |
+
pdf.set_y(-15) # 15mm from bottom
|
| 219 |
+
pdf.cell(0, 5, "CONFIDENTIAL - This document contains proprietary information and may not be shared without consent", 0, 0, 'C')
|
| 220 |
+
|
| 221 |
+
# Override the footer method
|
| 222 |
+
pdf.footer = lambda: add_confidentiality_footer(pdf)
|
| 223 |
+
pdf.add_page()
|
| 224 |
+
|
| 225 |
+
# Professional color scheme - Refined for bank/investor presentation
|
| 226 |
+
primary_color = (0, 0, 0) # Black - for headings
|
| 227 |
+
accent_color = (75, 0, 130) # Navy purple - for accent lines and highlights
|
| 228 |
+
text_color = (51, 51, 51) # Dark gray - for body text
|
| 229 |
+
light_gray = (221, 221, 221) # Lighter gray - for subtle lines (#DDDDDD)
|
| 230 |
+
navy_color = (25, 25, 112) # Navy blue - for TOC and emphasis
|
| 231 |
+
|
| 232 |
+
# Professional fonts - Arial for body, optional serif for main titles
|
| 233 |
+
title_font = 'Arial' # Keep Arial for consistency
|
| 234 |
+
body_font = 'Arial' # Modern business feel
|
| 235 |
+
|
| 236 |
+
# Helper functions for consistent styling
|
| 237 |
+
def add_header(pdf, text, size=24, y_offset=25, primary_color=(0,0,0), accent_color=(75,0,130)):
|
| 238 |
+
pdf.set_font("Arial", "B", size)
|
| 239 |
+
pdf.set_text_color(*primary_color)
|
| 240 |
+
pdf.set_xy(25, y_offset)
|
| 241 |
+
pdf.cell(0, 18, text, ln=True)
|
| 242 |
+
|
| 243 |
+
# Calculate text width and make line autofit
|
| 244 |
+
text_width = pdf.get_string_width(text)
|
| 245 |
+
line_width = min(text_width + 10, 160) # Add 10mm padding, max 160mm (page width - margins)
|
| 246 |
+
|
| 247 |
+
# Removed purple accent line
|
| 248 |
+
return y_offset + 25
|
| 249 |
+
|
| 250 |
+
def add_subheader(pdf, text, size=16, y_offset=None, primary_color=(0,0,0)):
|
| 251 |
+
if y_offset is None:
|
| 252 |
+
y_offset = pdf.get_y() + 10
|
| 253 |
+
pdf.set_font("Arial", "B", size)
|
| 254 |
+
pdf.set_text_color(*primary_color)
|
| 255 |
+
pdf.set_xy(25, y_offset)
|
| 256 |
+
pdf.cell(0, 14, text, ln=True)
|
| 257 |
+
|
| 258 |
+
# Calculate text width and make line autofit
|
| 259 |
+
text_width = pdf.get_string_width(text)
|
| 260 |
+
line_width = min(text_width + 8, 160) # Add 8mm padding, max 160mm (page width - margins)
|
| 261 |
+
|
| 262 |
+
pdf.set_draw_color(221, 221, 221) # Lighter gray (#DDDDDD) for subtle lines
|
| 263 |
+
pdf.set_line_width(0.3) # Thinner line
|
| 264 |
+
pdf.line(25, y_offset + 14, 25 + line_width, y_offset + 14)
|
| 265 |
+
return y_offset + 20
|
| 266 |
+
|
| 267 |
+
def add_body_text(pdf, text, y_offset=None, text_color=(51,51,51)):
|
| 268 |
+
if y_offset is None:
|
| 269 |
+
y_offset = pdf.get_y() + 5
|
| 270 |
+
pdf.set_font("Arial", "", 12)
|
| 271 |
+
pdf.set_text_color(*text_color)
|
| 272 |
+
pdf.set_xy(25, y_offset)
|
| 273 |
+
pdf.multi_cell(160, 8, text, align='L') # Left align to prevent text justification
|
| 274 |
+
return pdf.get_y()
|
| 275 |
+
|
| 276 |
+
def check_page_break(pdf, required_height=120, bottom_margin=300):
|
| 277 |
+
"""Check if we need a page break and return the Y position to use"""
|
| 278 |
+
y = pdf.get_y()
|
| 279 |
+
if y + required_height > bottom_margin:
|
| 280 |
+
pdf.add_page()
|
| 281 |
+
return 25
|
| 282 |
+
return y
|
| 283 |
+
|
| 284 |
+
# ===== COVER PAGE =====
|
| 285 |
+
def add_cover_page():
|
| 286 |
+
# Clean white background - no purple background
|
| 287 |
+
|
| 288 |
+
# Add company logo at the top
|
| 289 |
+
try:
|
| 290 |
+
# Use the MBD logo PNG file - now in app folder for HF deployment
|
| 291 |
+
app_root = os.path.dirname(os.path.dirname(__file__))
|
| 292 |
+
logo_path = os.path.join(app_root, 'assets', 'logos', 'MBD logo.png')
|
| 293 |
+
|
| 294 |
+
if os.path.exists(logo_path):
|
| 295 |
+
# Add logo to PDF (centered, top of page) - LOGO FIRST, THEN TITLE BELOW
|
| 296 |
+
# Center the logo: (210 - 70) / 2 = 70, so logo starts at x=70
|
| 297 |
+
pdf.image(logo_path, x=70, y=20, w=70) # Even larger size to show full logo
|
| 298 |
+
print(f"Logo added successfully from: {logo_path}")
|
| 299 |
+
else:
|
| 300 |
+
print(f"Logo file not found at: {logo_path}")
|
| 301 |
+
except Exception as e:
|
| 302 |
+
# Log any errors with logo
|
| 303 |
+
print(f"Logo error: {e}")
|
| 304 |
+
pass
|
| 305 |
+
|
| 306 |
+
# Extract business name from content, fallback to businessIdea
|
| 307 |
+
extracted_business_name = extract_business_name_from_content(filtered_sections)
|
| 308 |
+
business_name = extracted_business_name or request.businessIdea or "Business Plan"
|
| 309 |
+
|
| 310 |
+
# Company/Project name - DARK TEXT (moved down below logo)
|
| 311 |
+
pdf.set_font(title_font, "B", 32)
|
| 312 |
+
pdf.set_text_color(0, 0, 0) # Black text for visibility on white background
|
| 313 |
+
pdf.ln(60) # More space to move title below logo
|
| 314 |
+
pdf.cell(0, 25, business_name, ln=True, align='C')
|
| 315 |
+
|
| 316 |
+
# Subtitle - DARK GRAY, BOLD, NO ITALICS
|
| 317 |
+
pdf.set_font(title_font, "B", 16) # Reduced from 18pt, removed italics
|
| 318 |
+
pdf.set_text_color(51, 51, 51) # Dark gray for visibility on white background
|
| 319 |
+
pdf.cell(0, 12, "Business Plan", ln=True, align='C')
|
| 320 |
+
|
| 321 |
+
# Add some space
|
| 322 |
+
pdf.ln(40)
|
| 323 |
+
|
| 324 |
+
|
| 325 |
+
pdf.set_font(body_font, "", 12)
|
| 326 |
+
pdf.set_text_color(51, 51, 51) # Dark text for content
|
| 327 |
+
pdf.set_xy(35, 145)
|
| 328 |
+
pdf.cell(0, 8, f"Created: {time.strftime('%B %d, %Y')}", ln=True)
|
| 329 |
+
pdf.set_xy(35, 153)
|
| 330 |
+
pdf.cell(0, 8, f"Format: {request.format.upper()}", ln=True)
|
| 331 |
+
pdf.set_xy(35, 161)
|
| 332 |
+
pdf.cell(0, 8, f"Sections: {len(request.sections)}", ln=True)
|
| 333 |
+
|
| 334 |
+
# Footer - simple footer since confidentiality is now in page footer
|
| 335 |
+
pdf.set_font(body_font, "", 10)
|
| 336 |
+
pdf.set_text_color(51, 51, 51) # Dark gray text for visibility on white background
|
| 337 |
+
pdf.set_xy(0, 280)
|
| 338 |
+
pdf.cell(0, 8, "Business Plan Document", ln=True, align='C')
|
| 339 |
+
|
| 340 |
+
|
| 341 |
+
|
| 342 |
+
# ===== SECTION PAGES =====
|
| 343 |
+
def add_sections_with_charts():
|
| 344 |
+
# Parse charts from embedded chart data in section content
|
| 345 |
+
embedded_charts = parse_embedded_chart_data(filtered_sections)
|
| 346 |
+
|
| 347 |
+
if embedded_charts:
|
| 348 |
+
# Create actual charts from the parsed data
|
| 349 |
+
matplotlib_charts = []
|
| 350 |
+
chart_info_map = {} # Map section names to their charts
|
| 351 |
+
|
| 352 |
+
# logging.info(f"Chart info map keys: {list(chart_info_map.keys())}")
|
| 353 |
+
|
| 354 |
+
for chart_info in embedded_charts:
|
| 355 |
+
chart_bytes = create_chart_from_data(chart_info, request.businessIdea or "Business")
|
| 356 |
+
if chart_bytes:
|
| 357 |
+
matplotlib_charts.append(chart_bytes)
|
| 358 |
+
# Map this chart to its source section
|
| 359 |
+
source_section = chart_info["source_section"]
|
| 360 |
+
# logging.info(f"Chart '{chart_info['title']}' has source_section: '{source_section}'")
|
| 361 |
+
|
| 362 |
+
if source_section not in chart_info_map:
|
| 363 |
+
chart_info_map[source_section] = []
|
| 364 |
+
chart_info_map[source_section].append({
|
| 365 |
+
"chart_bytes": chart_bytes,
|
| 366 |
+
"title": chart_info["title"],
|
| 367 |
+
"type": chart_info["type"]
|
| 368 |
+
})
|
| 369 |
+
# logging.info(f"Mapping chart '{chart_info['title']}' to source section: '{source_section}'")
|
| 370 |
+
|
| 371 |
+
# CRITICAL DEBUG: Show the exact mapping being created
|
| 372 |
+
# logging.info(f" -> Chart '{chart_info['title']}' mapped to key '{source_section}' in chart_info_map")
|
| 373 |
+
else:
|
| 374 |
+
pass
|
| 375 |
+
|
| 376 |
+
# logging.info(f"Total charts created: {len(matplotlib_charts)}")
|
| 377 |
+
# logging.info(f"Final chart_info_map: {chart_info_map}")
|
| 378 |
+
|
| 379 |
+
# Debug: Show all chart mappings
|
| 380 |
+
for source_section, charts in chart_info_map.items():
|
| 381 |
+
# logging.info(f"Source section '{source_section}' has {len(charts)} charts:")
|
| 382 |
+
for chart in charts:
|
| 383 |
+
# logging.info(f" - {chart['title']} ({chart['type']})")
|
| 384 |
+
pass
|
| 385 |
+
|
| 386 |
+
else:
|
| 387 |
+
matplotlib_charts = []
|
| 388 |
+
chart_info_map = {}
|
| 389 |
+
|
| 390 |
+
total_charts_placed = 0
|
| 391 |
+
|
| 392 |
+
# Sort sections according to predefined order
|
| 393 |
+
sorted_sections = sort_sections_by_order(filtered_sections)
|
| 394 |
+
# logging.info(f"Section titles in sorted order: {[s.title for s in sorted_sections]}")
|
| 395 |
+
|
| 396 |
+
# CRITICAL DEBUG: Show exact section titles and their keys
|
| 397 |
+
for i, section in enumerate(sorted_sections):
|
| 398 |
+
# logging.info(f"Section {i+1}: '{section.title}' (key: {getattr(section, 'key', 'NO_KEY')})")
|
| 399 |
+
pass
|
| 400 |
+
|
| 401 |
+
for i, section in enumerate(sorted_sections):
|
| 402 |
+
|
| 403 |
+
# Always start each section on a new page
|
| 404 |
+
pdf.add_page()
|
| 405 |
+
|
| 406 |
+
# Don't add section title manually - let the markdown content handle headings
|
| 407 |
+
|
| 408 |
+
# Section content (properly positioned)
|
| 409 |
+
pdf.set_font(body_font, "", 12)
|
| 410 |
+
pdf.set_text_color(*text_color)
|
| 411 |
+
pdf.set_xy(25, pdf.get_y()) # Use current Y position for consistent spacing
|
| 412 |
+
|
| 413 |
+
# Process content
|
| 414 |
+
try:
|
| 415 |
+
content_text = clean_text_for_pdf(section.content)
|
| 416 |
+
except Exception as e:
|
| 417 |
+
content_text = section.content or ""
|
| 418 |
+
try:
|
| 419 |
+
content_text = convert_currency_symbols_to_iso(content_text)
|
| 420 |
+
except Exception:
|
| 421 |
+
# If currency conversion fails, keep original text
|
| 422 |
+
pass
|
| 423 |
+
|
| 424 |
+
# Remove chart data markers
|
| 425 |
+
content_text = re.sub(r'<!--CHARTDATASTART-->.*?<!--CHARTDATAEND-->', '', content_text, flags=re.DOTALL)
|
| 426 |
+
content_text = re.sub(r'<!--CHARTDATASTART-->.*$', '', content_text, flags=re.DOTALL)
|
| 427 |
+
|
| 428 |
+
# Remove chart titles from content to prevent duplicate headings
|
| 429 |
+
# Get chart titles for this section
|
| 430 |
+
section_charts = chart_info_map.get(section.title, [])
|
| 431 |
+
if not section_charts:
|
| 432 |
+
# Try case-insensitive match
|
| 433 |
+
for chart_source, charts in chart_info_map.items():
|
| 434 |
+
if chart_source.lower() == section.title.lower():
|
| 435 |
+
section_charts = charts
|
| 436 |
+
break
|
| 437 |
+
|
| 438 |
+
# Remove chart titles from content
|
| 439 |
+
for chart_info in section_charts:
|
| 440 |
+
chart_title = chart_info["title"]
|
| 441 |
+
# Remove various markdown heading formats for the chart title
|
| 442 |
+
patterns_to_remove = [
|
| 443 |
+
rf'^#+\s*{re.escape(chart_title)}\s*$', # # Title, ## Title, etc.
|
| 444 |
+
rf'^\*\*{re.escape(chart_title)}\*\*\s*$', # **Title**
|
| 445 |
+
rf'^{re.escape(chart_title)}\s*$', # Just the title
|
| 446 |
+
]
|
| 447 |
+
for pattern in patterns_to_remove:
|
| 448 |
+
content_text = re.sub(pattern, '', content_text, flags=re.MULTILINE | re.IGNORECASE)
|
| 449 |
+
|
| 450 |
+
# No need to filter section titles since we're not adding them manually
|
| 451 |
+
|
| 452 |
+
# Skip enhance_subheadings_with_bold for grouped rendering to preserve ** markers
|
| 453 |
+
# content_text = enhance_subheadings_with_bold(content_text)
|
| 454 |
+
content_text = remove_duplicate_headings(content_text)
|
| 455 |
+
|
| 456 |
+
# Add content with proper markdown rendering using grouped approach
|
| 457 |
+
if content_text and content_text.strip():
|
| 458 |
+
render_markdown_to_pdf_grouped(pdf, content_text, primary_color, accent_color, text_color)
|
| 459 |
+
else:
|
| 460 |
+
# Add placeholder text to prevent blank page
|
| 461 |
+
pdf.multi_cell(160, 8, f"Content for {section.title} section is being processed...", align='L')
|
| 462 |
+
|
| 463 |
+
# Add visual separation between content and charts
|
| 464 |
+
if content_text and content_text.strip():
|
| 465 |
+
pdf.ln(15) # More generous spacing for better visual separation
|
| 466 |
+
|
| 467 |
+
# ---- Charts for this section ----
|
| 468 |
+
charts_added = 0 # IMPORTANT: initialize to avoid NameError
|
| 469 |
+
|
| 470 |
+
# CRITICAL DEBUG: Show exactly what charts exist and their source_section values
|
| 471 |
+
if section.title == "Financial Plan & Funding":
|
| 472 |
+
# logging.info(f"=== LAST SECTION DEBUG ===")
|
| 473 |
+
# logging.info(f"Section title: '{section.title}'")
|
| 474 |
+
# logging.info(f"All chart_info_map keys: {list(chart_info_map.keys())}")
|
| 475 |
+
for key, charts in chart_info_map.items():
|
| 476 |
+
# logging.info(f" Key '{key}' has {len(charts)} charts")
|
| 477 |
+
for chart in charts:
|
| 478 |
+
# logging.info(f" Chart: {chart['title']} ({chart['type']})")
|
| 479 |
+
pass
|
| 480 |
+
# logging.info(f"=== END LAST SECTION DEBUG ===")
|
| 481 |
+
pass
|
| 482 |
+
|
| 483 |
+
section_charts = chart_info_map.get(section.title, [])
|
| 484 |
+
# logging.info(f"Section '{section.title}': Direct lookup found {len(section_charts)} charts")
|
| 485 |
+
|
| 486 |
+
# Strategy 1: Exact title match
|
| 487 |
+
if not section_charts:
|
| 488 |
+
# logging.info(f"No exact match found for '{section.title}'")
|
| 489 |
+
pass
|
| 490 |
+
|
| 491 |
+
# Strategy 2: Case-insensitive match
|
| 492 |
+
for chart_source, charts in chart_info_map.items():
|
| 493 |
+
if chart_source.lower() == section.title.lower():
|
| 494 |
+
section_charts = charts
|
| 495 |
+
# logging.info(f"Found charts via case-insensitive match: '{chart_source}' -> {len(charts)} charts")
|
| 496 |
+
break
|
| 497 |
+
|
| 498 |
+
# Strategy 3: Partial match
|
| 499 |
+
if not section_charts:
|
| 500 |
+
for chart_source, charts in chart_info_map.items():
|
| 501 |
+
if (section.title.lower() in chart_source.lower() or
|
| 502 |
+
chart_source.lower() in section.title.lower()):
|
| 503 |
+
section_charts = charts
|
| 504 |
+
# logging.info(f"Found charts via partial match: '{chart_source}' -> {len(charts)} charts")
|
| 505 |
+
break
|
| 506 |
+
|
| 507 |
+
# Strategy 4: Key-based match (for prompt_ prefixed sections)
|
| 508 |
+
if not section_charts and hasattr(section, 'key'):
|
| 509 |
+
for chart_source, charts in chart_info_map.items():
|
| 510 |
+
if section.key and section.key.replace('prompt_', '') in chart_source.lower():
|
| 511 |
+
section_charts = charts
|
| 512 |
+
# logging.info(f"Found charts via key match: '{section.key}' -> '{chart_source}' -> {len(charts)} charts")
|
| 513 |
+
break
|
| 514 |
+
|
| 515 |
+
# logging.info(f"Section '{section.title}': Final chart count: {len(section_charts)}")
|
| 516 |
+
|
| 517 |
+
if section_charts:
|
| 518 |
+
|
| 519 |
+
# Group charts that can fit on the same page
|
| 520 |
+
charts_per_page = 0
|
| 521 |
+
max_charts_per_page = 5 # Allow up to 5 charts per page
|
| 522 |
+
|
| 523 |
+
for chart_info in section_charts:
|
| 524 |
+
try:
|
| 525 |
+
# Check if we need a new page for this chart
|
| 526 |
+
chart_height_needed = 140 # Chart height (120) + title (20) + minimal spacing
|
| 527 |
+
|
| 528 |
+
# Smart page break logic - allow multiple charts per page
|
| 529 |
+
current_y = pdf.get_y()
|
| 530 |
+
charts_per_page += 1
|
| 531 |
+
|
| 532 |
+
# Only force page break if absolutely necessary - allow up to 5 charts per page
|
| 533 |
+
if (current_y + chart_height_needed > 290) or (charts_per_page > 5):
|
| 534 |
+
pdf.add_page()
|
| 535 |
+
# Header/footer removed for now
|
| 536 |
+
y_title = 25 # Start at normal position
|
| 537 |
+
charts_per_page = 1 # Reset counter for new page
|
| 538 |
+
else:
|
| 539 |
+
y_title = current_y
|
| 540 |
+
|
| 541 |
+
# Add minimal spacing before chart if not at top of page
|
| 542 |
+
if y_title > 25: # If not at top of page, add minimal space
|
| 543 |
+
pdf.ln(3) # Minimal spacing to fit more charts per page
|
| 544 |
+
|
| 545 |
+
# Chart title is already included in the chart itself, no need for separate subheader
|
| 546 |
+
|
| 547 |
+
# Centered image with larger dimensions for better visibility
|
| 548 |
+
img_width = 160 # Much larger chart width for better visibility
|
| 549 |
+
x = (210 - img_width) / 2
|
| 550 |
+
y_img = y_title
|
| 551 |
+
|
| 552 |
+
# Save image to temp file
|
| 553 |
+
with tempfile.NamedTemporaryFile(delete=False, suffix=".png") as tmp_img:
|
| 554 |
+
tmp_img.write(chart_info["chart_bytes"])
|
| 555 |
+
temp_img_path = tmp_img.name
|
| 556 |
+
|
| 557 |
+
# Chart dimensions (no border needed)
|
| 558 |
+
chart_height = 120 # Much larger height for better visibility
|
| 559 |
+
chart_width = img_width
|
| 560 |
+
|
| 561 |
+
# Image
|
| 562 |
+
pdf.image(temp_img_path, x=x, y=y_img, w=chart_width)
|
| 563 |
+
|
| 564 |
+
# Move below image and clean up (spacing based on actual chart height)
|
| 565 |
+
pdf.ln(chart_height + 5) # Chart height (120) + minimal spacing between charts
|
| 566 |
+
try:
|
| 567 |
+
os.remove(temp_img_path)
|
| 568 |
+
except Exception:
|
| 569 |
+
pass
|
| 570 |
+
|
| 571 |
+
charts_added += 1
|
| 572 |
+
total_charts_placed += 1
|
| 573 |
+
except Exception:
|
| 574 |
+
# Skip faulty chart and continue rendering others
|
| 575 |
+
continue
|
| 576 |
+
else:
|
| 577 |
+
# Debug: Check if there are any charts that might match this section
|
| 578 |
+
potential_matches = []
|
| 579 |
+
for chart_source, charts in chart_info_map.items():
|
| 580 |
+
if (section.title.lower() in chart_source.lower() or
|
| 581 |
+
chart_source.lower() in section.title.lower()):
|
| 582 |
+
potential_matches.append(f"'{chart_source}' -> {len(charts)} charts")
|
| 583 |
+
|
| 584 |
+
if potential_matches:
|
| 585 |
+
# logging.info(f"Section '{section.title}': Potential chart matches: {potential_matches}")
|
| 586 |
+
pass
|
| 587 |
+
else:
|
| 588 |
+
# logging.info(f"Section '{section.title}': No charts found and no potential matches")
|
| 589 |
+
pass
|
| 590 |
+
|
| 591 |
+
# Each section is already on its own page, no need for complex page break logic
|
| 592 |
+
|
| 593 |
+
|
| 594 |
+
|
| 595 |
+
|
| 596 |
+
# ===== BUILD THE PDF =====
|
| 597 |
+
try:
|
| 598 |
+
# Add all sections
|
| 599 |
+
try:
|
| 600 |
+
add_cover_page()
|
| 601 |
+
except Exception as e:
|
| 602 |
+
raise Exception(f"Cover page failed: {str(e)}")
|
| 603 |
+
|
| 604 |
+
# Add TOC on page 2 with REAL page numbers (already calculated above)
|
| 605 |
+
try:
|
| 606 |
+
if toc_items:
|
| 607 |
+
add_table_of_contents_page(pdf, toc_items, request.businessIdea)
|
| 608 |
+
else:
|
| 609 |
+
# Add a basic TOC page if no items were generated
|
| 610 |
+
pdf.add_page()
|
| 611 |
+
pdf.set_font("Arial", "B", 20)
|
| 612 |
+
pdf.set_text_color(0, 0, 0)
|
| 613 |
+
pdf.set_xy(25, 25)
|
| 614 |
+
pdf.cell(0, 18, "Table of Contents", ln=True)
|
| 615 |
+
pdf.set_font("Arial", "", 12)
|
| 616 |
+
pdf.set_text_color(51, 51, 51)
|
| 617 |
+
pdf.set_xy(25, 50)
|
| 618 |
+
pdf.multi_cell(160, 8, "No sections available for table of contents.", align='L')
|
| 619 |
+
except Exception as e:
|
| 620 |
+
# logging.error(f"Table of contents failed: {str(e)}")
|
| 621 |
+
pass
|
| 622 |
+
# Add a basic TOC page as fallback
|
| 623 |
+
pdf.add_page()
|
| 624 |
+
pdf.set_font("Arial", "B", 20)
|
| 625 |
+
pdf.set_text_color(0, 0, 0)
|
| 626 |
+
pdf.set_xy(25, 25)
|
| 627 |
+
pdf.cell(0, 18, "Table of Contents", ln=True)
|
| 628 |
+
pdf.set_font("Arial", "", 12)
|
| 629 |
+
pdf.set_text_color(51, 51, 51)
|
| 630 |
+
pdf.set_xy(25, 50)
|
| 631 |
+
pdf.multi_cell(160, 8, "Table of contents could not be generated.", align='L')
|
| 632 |
+
|
| 633 |
+
# Executive summary is now handled as part of the sections
|
| 634 |
+
# No separate page needed - the first section contains the executive summary
|
| 635 |
+
|
| 636 |
+
try:
|
| 637 |
+
add_sections_with_charts()
|
| 638 |
+
except Exception as e:
|
| 639 |
+
raise Exception(f"Sections with charts failed: {str(e)}")
|
| 640 |
+
|
| 641 |
+
# CRITICAL CHECK: Ensure we have content in the PDF
|
| 642 |
+
total_pages = pdf.page_no()
|
| 643 |
+
|
| 644 |
+
if total_pages < 2: # Should have at least cover + 1 content page
|
| 645 |
+
# Add a content page to prevent blank PDF
|
| 646 |
+
pdf.add_page()
|
| 647 |
+
pdf.set_font("Arial", "B", 16)
|
| 648 |
+
pdf.set_text_color(255, 0, 0)
|
| 649 |
+
pdf.cell(0, 20, "Content Processing Issue", ln=True, align='C')
|
| 650 |
+
pdf.set_font("Arial", "", 12)
|
| 651 |
+
pdf.set_text_color(0, 0, 0)
|
| 652 |
+
pdf.cell(0, 10, "The PDF generation encountered an issue with content processing.", ln=True, align='C')
|
| 653 |
+
pdf.cell(0, 10, "Please check the server logs for detailed information.", ln=True, align='C')
|
| 654 |
+
|
| 655 |
+
# Get PDF output and convert to bytes
|
| 656 |
+
try:
|
| 657 |
+
pdf_output = pdf.output(dest='S')
|
| 658 |
+
if isinstance(pdf_output, bytearray):
|
| 659 |
+
pdf_bytes = bytes(pdf_output)
|
| 660 |
+
else:
|
| 661 |
+
pdf_bytes = pdf_output.encode('latin-1') if isinstance(pdf_output, str) else pdf_output
|
| 662 |
+
|
| 663 |
+
# CRITICAL SAFETY CHECK: Ensure PDF is not empty
|
| 664 |
+
if len(pdf_bytes) < 1000: # PDF should be at least 1KB
|
| 665 |
+
# Try to add some content to prevent blank PDF
|
| 666 |
+
pdf.add_page()
|
| 667 |
+
pdf.set_font("Arial", "B", 16)
|
| 668 |
+
pdf.set_text_color(255, 0, 0) # Red text
|
| 669 |
+
pdf.cell(0, 20, "PDF Generation Issue Detected", ln=True, align='C')
|
| 670 |
+
pdf.set_font("Arial", "", 12)
|
| 671 |
+
pdf.set_text_color(0, 0, 0)
|
| 672 |
+
pdf.cell(0, 10, "If you see this message, there was an issue with content processing.", ln=True, align='C')
|
| 673 |
+
pdf.cell(0, 10, "Please check the server logs for details.", ln=True, align='C')
|
| 674 |
+
|
| 675 |
+
# Regenerate PDF with error message
|
| 676 |
+
pdf_output = pdf.output(dest='S')
|
| 677 |
+
if isinstance(pdf_output, bytearray):
|
| 678 |
+
pdf_bytes = bytes(pdf_output)
|
| 679 |
+
else:
|
| 680 |
+
pdf_bytes = pdf_output.encode('latin-1') if isinstance(pdf_output, str) else pdf_output
|
| 681 |
+
|
| 682 |
+
return pdf_bytes, f"business_plan_{request.businessIdea or 'export'}.pdf"
|
| 683 |
+
except Exception as e:
|
| 684 |
+
raise Exception(f"PDF conversion failed: {str(e)}")
|
| 685 |
+
|
| 686 |
+
except Exception as e:
|
| 687 |
+
# logging.error(f"PDF generation failed: {e}")
|
| 688 |
+
raise Exception(f"Failed to generate professional PDF: {str(e)}")
|
app/DocumentGeneration/table_of_content.py
ADDED
|
@@ -0,0 +1,205 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Table of Contents Module
|
| 2 |
+
# Contains functions to generate TOC AFTER content is placed with real page numbers
|
| 3 |
+
|
| 4 |
+
import re
|
| 5 |
+
import os
|
| 6 |
+
import sys
|
| 7 |
+
from typing import List, Dict, Any
|
| 8 |
+
|
| 9 |
+
# Add the parent directory to sys.path to import from models.py and constants.py
|
| 10 |
+
sys.path.append(os.path.dirname(os.path.dirname(__file__)))
|
| 11 |
+
from models import ExportSection
|
| 12 |
+
from constants import SECTION_ORDER
|
| 13 |
+
|
| 14 |
+
def extract_first_heading_from_content(content: str) -> str:
|
| 15 |
+
"""Extract the first heading from markdown content"""
|
| 16 |
+
if not content:
|
| 17 |
+
return ""
|
| 18 |
+
|
| 19 |
+
lines = content.split('\n')
|
| 20 |
+
for line in lines:
|
| 21 |
+
line = line.strip()
|
| 22 |
+
if not line:
|
| 23 |
+
continue
|
| 24 |
+
|
| 25 |
+
# Check for markdown headings (# ## ### etc.)
|
| 26 |
+
if line.startswith('#'):
|
| 27 |
+
# Extract heading text without the # symbols
|
| 28 |
+
heading_text = re.sub(r'^#+\s*', '', line).strip()
|
| 29 |
+
if heading_text:
|
| 30 |
+
return heading_text
|
| 31 |
+
|
| 32 |
+
# Check for bold headings (**text**)
|
| 33 |
+
elif line.startswith('**') and line.endswith('**') and len(line) > 4:
|
| 34 |
+
# Extract text from bold markers
|
| 35 |
+
heading_text = line[2:-2].strip()
|
| 36 |
+
if heading_text:
|
| 37 |
+
return heading_text
|
| 38 |
+
|
| 39 |
+
return ""
|
| 40 |
+
|
| 41 |
+
def generate_table_of_contents_after_content(sections: List[ExportSection], section_page_map: Dict[str, int], business_idea: str) -> List[Dict[str, Any]]:
|
| 42 |
+
"""Generate TOC AFTER all content is placed, using REAL page numbers from section_page_map
|
| 43 |
+
This ensures accurate page numbers based on actual content placement"""
|
| 44 |
+
toc_items = []
|
| 45 |
+
|
| 46 |
+
# Create a mapping from section titles to section objects for easy lookup
|
| 47 |
+
section_map = {section.title: section for section in sections}
|
| 48 |
+
|
| 49 |
+
# Add business plan sections in the predefined SECTION_ORDER
|
| 50 |
+
for i, expected_title in enumerate(SECTION_ORDER):
|
| 51 |
+
# Find the corresponding section - try multiple matching strategies
|
| 52 |
+
section = None
|
| 53 |
+
|
| 54 |
+
# Strategy 1: Exact title match
|
| 55 |
+
section = section_map.get(expected_title)
|
| 56 |
+
|
| 57 |
+
# Strategy 2: Case-insensitive match
|
| 58 |
+
if not section:
|
| 59 |
+
for section_title, section_obj in section_map.items():
|
| 60 |
+
if section_title.lower() == expected_title.lower():
|
| 61 |
+
section = section_obj
|
| 62 |
+
break
|
| 63 |
+
|
| 64 |
+
# Strategy 3: Partial match (e.g., "Company Profile" matches "Company Profile Section")
|
| 65 |
+
if not section:
|
| 66 |
+
for section_title, section_obj in section_map.items():
|
| 67 |
+
if (expected_title.lower() in section_title.lower() or
|
| 68 |
+
section_title.lower() in expected_title.lower()):
|
| 69 |
+
section = section_obj
|
| 70 |
+
break
|
| 71 |
+
|
| 72 |
+
# Strategy 4: Key-based match (remove 'prompt_' prefix and match)
|
| 73 |
+
if not section:
|
| 74 |
+
for section_obj in sections:
|
| 75 |
+
if section_obj.key:
|
| 76 |
+
key_clean = section_obj.key.replace('prompt_', '') if section_obj.key.startswith('prompt_') else section_obj.key
|
| 77 |
+
if key_clean.lower() == expected_title.lower().replace(' ', '').replace('&', ''):
|
| 78 |
+
section = section_obj
|
| 79 |
+
break
|
| 80 |
+
|
| 81 |
+
# Only add sections that have actual content AND real page numbers - NO DUMMY DATA
|
| 82 |
+
has_content = bool(section and section.content and section.content.strip())
|
| 83 |
+
if has_content:
|
| 84 |
+
# Get the REAL page number from section_page_map
|
| 85 |
+
real_page = section_page_map.get(section.title, 0)
|
| 86 |
+
if real_page > 0: # Only add if we have a real page number
|
| 87 |
+
# Extract the actual heading from markdown content
|
| 88 |
+
actual_heading = extract_first_heading_from_content(section.content)
|
| 89 |
+
|
| 90 |
+
# Use the actual heading from markdown content, fallback to expected title
|
| 91 |
+
toc_title = actual_heading if actual_heading else expected_title
|
| 92 |
+
|
| 93 |
+
toc_items.append({
|
| 94 |
+
"title": toc_title, # Use actual heading from markdown content
|
| 95 |
+
"level": 1,
|
| 96 |
+
"page": real_page, # REAL page number from actual content placement
|
| 97 |
+
"section_type": "section",
|
| 98 |
+
"has_content": True,
|
| 99 |
+
"section_obj": section # Store reference to actual section
|
| 100 |
+
})
|
| 101 |
+
|
| 102 |
+
return toc_items
|
| 103 |
+
|
| 104 |
+
def add_table_of_contents_page(pdf, toc_items: List[Dict[str, Any]], business_idea: str):
|
| 105 |
+
"""Add a professional table of contents page with REAL page numbers"""
|
| 106 |
+
|
| 107 |
+
# TOC items are already in the correct order and have real page numbers
|
| 108 |
+
sorted_toc_items = toc_items
|
| 109 |
+
|
| 110 |
+
# Professional color scheme - Refined for bank/investor presentation
|
| 111 |
+
primary_color = (0, 0, 0) # Black - for headings
|
| 112 |
+
accent_color = (75, 0, 130) # Navy purple - for accent lines and highlights
|
| 113 |
+
text_color = (51, 51, 51) # Dark gray - for body text
|
| 114 |
+
light_gray = (221, 221, 221) # Lighter gray - for subtle lines (#DDDDDD)
|
| 115 |
+
navy_color = (25, 25, 112) # Navy blue - for TOC and emphasis
|
| 116 |
+
|
| 117 |
+
# Page title - more compact
|
| 118 |
+
pdf.set_font("Arial", "B", 20) # Reduced from 24 to 20
|
| 119 |
+
pdf.set_text_color(*primary_color)
|
| 120 |
+
pdf.set_xy(25, 20) # Reduced from 25 to 20
|
| 121 |
+
pdf.cell(0, 15, "Table of Contents", ln=True) # Reduced from 18 to 15
|
| 122 |
+
|
| 123 |
+
# Calculate text width and make line autofit
|
| 124 |
+
text_width = pdf.get_string_width("Table of Contents")
|
| 125 |
+
line_width = min(text_width + 12, 160) # Add 12mm padding, max 160mm
|
| 126 |
+
|
| 127 |
+
# Removed purple accent line under TOC title
|
| 128 |
+
|
| 129 |
+
# TOC content - optimized for single page layout
|
| 130 |
+
y_position = 50 # Reduced from 60 to 50
|
| 131 |
+
pages_used = 1
|
| 132 |
+
|
| 133 |
+
for item in sorted_toc_items:
|
| 134 |
+
# Only add new page if absolutely necessary (very close to bottom)
|
| 135 |
+
if y_position > 290: # Very close to bottom margin
|
| 136 |
+
pdf.add_page()
|
| 137 |
+
y_position = 25
|
| 138 |
+
pages_used += 1
|
| 139 |
+
|
| 140 |
+
# Indent based on level - better indentation
|
| 141 |
+
indent = 0 if item["level"] == 1 else 20 # Increased indent for subsections
|
| 142 |
+
|
| 143 |
+
# Font and color based on level - ALL sections same color
|
| 144 |
+
if item["level"] == 1:
|
| 145 |
+
pdf.set_font("Arial", "B", 12)
|
| 146 |
+
pdf.set_text_color(*navy_color) # Navy blue for all main entries
|
| 147 |
+
else:
|
| 148 |
+
pdf.set_font("Arial", "", 10)
|
| 149 |
+
pdf.set_text_color(*text_color)
|
| 150 |
+
|
| 151 |
+
# Title with hyperlink
|
| 152 |
+
title_width = pdf.get_string_width(item["title"])
|
| 153 |
+
pdf.set_xy(25 + indent, y_position)
|
| 154 |
+
try:
|
| 155 |
+
# FPDF2 hyperlinks: test both 0-based and 1-based indexing
|
| 156 |
+
# First try 1-based (no subtraction)
|
| 157 |
+
target_page = item["page"]
|
| 158 |
+
# Try different FPDF2 link methods
|
| 159 |
+
if hasattr(pdf, 'add_link'):
|
| 160 |
+
link_id = pdf.add_link()
|
| 161 |
+
pdf.set_link(link_id, page=target_page)
|
| 162 |
+
pdf.link(25 + indent, y_position, title_width, 8, link_id)
|
| 163 |
+
else:
|
| 164 |
+
pdf.link(25 + indent, y_position, title_width, 8, dest=target_page)
|
| 165 |
+
except:
|
| 166 |
+
pass # If hyperlink fails, continue without it
|
| 167 |
+
pdf.cell(title_width, 8, item["title"], ln=False)
|
| 168 |
+
|
| 169 |
+
# Dots leading to page number
|
| 170 |
+
pdf.set_font("Arial", "", 8)
|
| 171 |
+
pdf.set_text_color(128, 128, 128) # Medium gray for dots
|
| 172 |
+
|
| 173 |
+
# Calculate dots position
|
| 174 |
+
title_width = pdf.get_string_width(item["title"])
|
| 175 |
+
dots_start = 25 + indent + title_width + 5
|
| 176 |
+
dots_end = 160
|
| 177 |
+
|
| 178 |
+
# Draw dots
|
| 179 |
+
for x in range(int(dots_start), int(dots_end), 3):
|
| 180 |
+
pdf.set_xy(x, y_position + 3)
|
| 181 |
+
pdf.cell(1, 1, ".", ln=False)
|
| 182 |
+
|
| 183 |
+
# Page number with hyperlink - NOW USING REAL PAGE NUMBERS
|
| 184 |
+
page_number_width = pdf.get_string_width(str(item["page"]))
|
| 185 |
+
pdf.set_xy(160, y_position)
|
| 186 |
+
try:
|
| 187 |
+
# FPDF2 hyperlinks: test both 0-based and 1-based indexing
|
| 188 |
+
# First try 1-based (no subtraction)
|
| 189 |
+
target_page = item["page"]
|
| 190 |
+
# Try different FPDF2 link methods
|
| 191 |
+
if hasattr(pdf, 'add_link'):
|
| 192 |
+
link_id = pdf.add_link()
|
| 193 |
+
pdf.set_link(link_id, page=target_page)
|
| 194 |
+
pdf.link(160, y_position, page_number_width, 8, link_id)
|
| 195 |
+
else:
|
| 196 |
+
pdf.link(160, y_position, page_number_width, 8, dest=target_page)
|
| 197 |
+
except:
|
| 198 |
+
pass # If hyperlink fails, continue without it
|
| 199 |
+
pdf.cell(0, 8, str(item["page"]), ln=False)
|
| 200 |
+
|
| 201 |
+
# Reduced spacing between items for better page utilization
|
| 202 |
+
y_position += 8 # Reduced from 10 to 8 for even tighter spacing
|
| 203 |
+
|
| 204 |
+
# Add some spacing at the end
|
| 205 |
+
pdf.ln(20)
|
app/DocumentGeneration/word_generator.py
ADDED
|
@@ -0,0 +1,253 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Word Generator Module
|
| 2 |
+
# Contains create_word_document() and all Word-related functions
|
| 3 |
+
|
| 4 |
+
import re
|
| 5 |
+
import os
|
| 6 |
+
import tempfile
|
| 7 |
+
import time
|
| 8 |
+
import io
|
| 9 |
+
import sys
|
| 10 |
+
from typing import List, Dict, Any
|
| 11 |
+
|
| 12 |
+
# Add the parent directory to sys.path to import from models.py
|
| 13 |
+
sys.path.append(os.path.dirname(os.path.dirname(__file__)))
|
| 14 |
+
from models import ExportRequest
|
| 15 |
+
|
| 16 |
+
# Import from the same directory (Document Generation)
|
| 17 |
+
from document_helpers import (
|
| 18 |
+
filter_empty_sections,
|
| 19 |
+
extract_business_name_from_content
|
| 20 |
+
)
|
| 21 |
+
# from table_of_content import generate_table_of_contents # Not used in word generator
|
| 22 |
+
from chart_generator import parse_embedded_chart_data, create_chart_from_data
|
| 23 |
+
from markdown_renderer import (
|
| 24 |
+
render_markdown_to_docx_grouped,
|
| 25 |
+
process_content_with_inline_charts,
|
| 26 |
+
clean_text_for_pdf
|
| 27 |
+
)
|
| 28 |
+
|
| 29 |
+
# Add Currency Mapping path
|
| 30 |
+
sys.path.append(os.path.join(os.path.dirname(os.path.dirname(__file__)), 'CurrencyMapping'))
|
| 31 |
+
from CurrencyMapping.currency_mapping import convert_currency_symbols_to_iso
|
| 32 |
+
|
| 33 |
+
# Import sort_sections_by_order from utils module
|
| 34 |
+
from utils import sort_sections_by_order
|
| 35 |
+
|
| 36 |
+
def create_word_document(request: ExportRequest):
|
| 37 |
+
"""Create a Word document from the business plan data with front page, TOC, and proper page breaks"""
|
| 38 |
+
try:
|
| 39 |
+
from docx import Document
|
| 40 |
+
from docx.shared import Inches, Pt, RGBColor
|
| 41 |
+
from docx.enum.text import WD_ALIGN_PARAGRAPH, WD_TAB_ALIGNMENT
|
| 42 |
+
from docx.oxml import OxmlElement
|
| 43 |
+
from docx.oxml.ns import qn
|
| 44 |
+
except ImportError:
|
| 45 |
+
raise Exception("python-docx is not installed. Please install it with: pip install python-docx")
|
| 46 |
+
|
| 47 |
+
# Filter out empty sections to prevent blank pages
|
| 48 |
+
try:
|
| 49 |
+
filtered_sections = filter_empty_sections(request.sections)
|
| 50 |
+
except Exception as e:
|
| 51 |
+
raise Exception(f"Failed to filter sections: {str(e)}")
|
| 52 |
+
if not filtered_sections:
|
| 53 |
+
raise Exception("No valid sections found after filtering. All sections appear to be empty.")
|
| 54 |
+
|
| 55 |
+
doc = Document()
|
| 56 |
+
|
| 57 |
+
# Add confidentiality footer to every page
|
| 58 |
+
section = doc.sections[0]
|
| 59 |
+
footer = section.footer
|
| 60 |
+
footer_para = footer.paragraphs[0]
|
| 61 |
+
footer_para.alignment = WD_ALIGN_PARAGRAPH.CENTER
|
| 62 |
+
footer_run = footer_para.add_run("CONFIDENTIAL - This document contains proprietary information and may not be shared without consent")
|
| 63 |
+
footer_run.font.size = Pt(8)
|
| 64 |
+
footer_run.font.color.rgb = RGBColor(100, 100, 100) # Gray text
|
| 65 |
+
|
| 66 |
+
# ===== FRONT PAGE =====
|
| 67 |
+
# Add company logo at the top
|
| 68 |
+
try:
|
| 69 |
+
# Use the MBD logo PNG file - now in app folder for HF deployment
|
| 70 |
+
app_root = os.path.dirname(os.path.dirname(__file__))
|
| 71 |
+
logo_path = os.path.join(app_root, 'assets', 'logos', 'MBD logo.png')
|
| 72 |
+
|
| 73 |
+
if os.path.exists(logo_path):
|
| 74 |
+
# Add logo to Word document (centered, top of page)
|
| 75 |
+
logo_para = doc.add_paragraph()
|
| 76 |
+
logo_para.alignment = WD_ALIGN_PARAGRAPH.CENTER
|
| 77 |
+
logo_run = logo_para.add_run()
|
| 78 |
+
logo_run.add_picture(logo_path, width=Inches(3.0)) # Even larger size to show full logo
|
| 79 |
+
print(f"Logo added successfully from: {logo_path}")
|
| 80 |
+
else:
|
| 81 |
+
print(f"Logo file not found at: {logo_path}")
|
| 82 |
+
except Exception as e:
|
| 83 |
+
# Log any errors with logo
|
| 84 |
+
print(f"Logo error: {e}")
|
| 85 |
+
pass
|
| 86 |
+
|
| 87 |
+
# Add top spacing
|
| 88 |
+
doc.add_paragraph()
|
| 89 |
+
doc.add_paragraph()
|
| 90 |
+
|
| 91 |
+
# Extract business name from content, fallback to businessIdea
|
| 92 |
+
extracted_business_name = extract_business_name_from_content(filtered_sections)
|
| 93 |
+
business_name = extracted_business_name or request.businessIdea or "Business Plan"
|
| 94 |
+
|
| 95 |
+
# Business name as main title with enhanced styling - PURPLE THEME
|
| 96 |
+
title = doc.add_heading(business_name, 0)
|
| 97 |
+
title.alignment = WD_ALIGN_PARAGRAPH.CENTER
|
| 98 |
+
|
| 99 |
+
# Style the title with larger font and PURPLE color
|
| 100 |
+
for run in title.runs:
|
| 101 |
+
run.font.size = Pt(36)
|
| 102 |
+
run.font.color.rgb = RGBColor(139, 92, 246) # PURPLE
|
| 103 |
+
|
| 104 |
+
# Removed decorative line under title
|
| 105 |
+
|
| 106 |
+
# Add subtitle with enhanced styling - PURPLE
|
| 107 |
+
doc.add_paragraph()
|
| 108 |
+
subtitle = doc.add_paragraph("Professional Business Plan Document")
|
| 109 |
+
subtitle.alignment = WD_ALIGN_PARAGRAPH.CENTER
|
| 110 |
+
subtitle_run = subtitle.runs[0]
|
| 111 |
+
subtitle_run.font.size = Pt(18)
|
| 112 |
+
subtitle_run.font.color.rgb = RGBColor(124, 58, 237) # DARKER PURPLE
|
| 113 |
+
subtitle_run.italic = True
|
| 114 |
+
|
| 115 |
+
# Add more spacing
|
| 116 |
+
doc.add_paragraph()
|
| 117 |
+
doc.add_paragraph()
|
| 118 |
+
doc.add_paragraph()
|
| 119 |
+
|
| 120 |
+
# Add document info in a styled box - PURPLE BORDER
|
| 121 |
+
info_para = doc.add_paragraph()
|
| 122 |
+
info_para.paragraph_format.alignment = WD_ALIGN_PARAGRAPH.CENTER
|
| 123 |
+
|
| 124 |
+
# Add background color and border styling
|
| 125 |
+
info_run = info_para.add_run("Prepared by: Business Plan Generator")
|
| 126 |
+
info_run.font.size = Pt(14)
|
| 127 |
+
info_run.font.color.rgb = RGBColor(139, 92, 246) # PURPLE
|
| 128 |
+
|
| 129 |
+
doc.add_paragraph()
|
| 130 |
+
date_para = doc.add_paragraph()
|
| 131 |
+
date_para.paragraph_format.alignment = WD_ALIGN_PARAGRAPH.CENTER
|
| 132 |
+
date_run = date_para.add_run(f"Date: {time.strftime('%B %Y')}")
|
| 133 |
+
date_run.font.size = Pt(14)
|
| 134 |
+
date_run.font.color.rgb = RGBColor(139, 92, 246) # PURPLE
|
| 135 |
+
|
| 136 |
+
# Confidentiality statement removed - now in footer of every page
|
| 137 |
+
|
| 138 |
+
# Add more spacing before footer
|
| 139 |
+
doc.add_paragraph()
|
| 140 |
+
doc.add_paragraph()
|
| 141 |
+
doc.add_paragraph()
|
| 142 |
+
doc.add_paragraph()
|
| 143 |
+
|
| 144 |
+
# Add footer with enhanced styling - PURPLE
|
| 145 |
+
footer = doc.add_paragraph("Confidential Business Information")
|
| 146 |
+
footer.alignment = WD_ALIGN_PARAGRAPH.CENTER
|
| 147 |
+
footer_run = footer.runs[0]
|
| 148 |
+
footer_run.font.size = Pt(12)
|
| 149 |
+
footer_run.font.color.rgb = RGBColor(139, 92, 246) # PURPLE
|
| 150 |
+
footer_run.bold = True
|
| 151 |
+
|
| 152 |
+
# Removed decorative line above footer
|
| 153 |
+
|
| 154 |
+
# ===== TABLE OF CONTENTS =====
|
| 155 |
+
# Add page break before TOC
|
| 156 |
+
doc.add_page_break()
|
| 157 |
+
|
| 158 |
+
# Add TOC title
|
| 159 |
+
toc_title = doc.add_heading("Table of Contents", level=1)
|
| 160 |
+
toc_title.alignment = WD_ALIGN_PARAGRAPH.CENTER
|
| 161 |
+
|
| 162 |
+
# Removed decorative line under TOC title
|
| 163 |
+
|
| 164 |
+
# Add spacing before TOC entries
|
| 165 |
+
doc.add_paragraph()
|
| 166 |
+
|
| 167 |
+
# Sort sections according to predefined order (do this before TOC generation)
|
| 168 |
+
try:
|
| 169 |
+
sorted_sections = sort_sections_by_order(filtered_sections)
|
| 170 |
+
except Exception as e:
|
| 171 |
+
# Fallback: use filtered order if sorting fails
|
| 172 |
+
sorted_sections = filtered_sections
|
| 173 |
+
|
| 174 |
+
# Generate TOC entries with actual page numbers
|
| 175 |
+
# Since each section starts on a new page, we can calculate page numbers
|
| 176 |
+
toc_items = []
|
| 177 |
+
current_page = 3 # Start after cover (1) and TOC (2)
|
| 178 |
+
|
| 179 |
+
for section in sorted_sections:
|
| 180 |
+
if section.content and section.content.strip():
|
| 181 |
+
# Add main section
|
| 182 |
+
toc_items.append({
|
| 183 |
+
"title": section.title,
|
| 184 |
+
"level": 1,
|
| 185 |
+
"page": current_page
|
| 186 |
+
})
|
| 187 |
+
|
| 188 |
+
# Skip subheadings and chart titles - only include main section headings
|
| 189 |
+
|
| 190 |
+
current_page += 1 # Each section starts on a new page
|
| 191 |
+
|
| 192 |
+
# Add the actual TOC entries
|
| 193 |
+
for item in toc_items:
|
| 194 |
+
# Create TOC entry paragraph
|
| 195 |
+
toc_para = doc.add_paragraph()
|
| 196 |
+
toc_para.paragraph_format.space_after = Pt(6)
|
| 197 |
+
|
| 198 |
+
# Add section title
|
| 199 |
+
title_run = toc_para.add_run(item["title"])
|
| 200 |
+
title_run.font.size = Pt(11)
|
| 201 |
+
|
| 202 |
+
# Add dots and page number
|
| 203 |
+
if item["level"] == 1:
|
| 204 |
+
# Main section - bold
|
| 205 |
+
title_run.bold = True
|
| 206 |
+
# Add dots
|
| 207 |
+
dots_run = toc_para.add_run(" " + "." * (60 - len(item["title"])) + " ")
|
| 208 |
+
dots_run.font.size = Pt(11)
|
| 209 |
+
dots_run.font.color.rgb = RGBColor(128, 128, 128) # Gray
|
| 210 |
+
# Add page number
|
| 211 |
+
page_run = toc_para.add_run(str(item["page"]))
|
| 212 |
+
page_run.font.size = Pt(11)
|
| 213 |
+
page_run.bold = True
|
| 214 |
+
else:
|
| 215 |
+
# Subsection - indented
|
| 216 |
+
toc_para.paragraph_format.left_indent = Inches(0.3)
|
| 217 |
+
# Add dots
|
| 218 |
+
dots_run = toc_para.add_run(" " + "." * (50 - len(item["title"])) + " ")
|
| 219 |
+
dots_run.font.size = Pt(11)
|
| 220 |
+
dots_run.font.color.rgb = RGBColor(128, 128, 128) # Gray
|
| 221 |
+
# Add page number
|
| 222 |
+
page_run = toc_para.add_run(str(item["page"]))
|
| 223 |
+
page_run.font.size = Pt(11)
|
| 224 |
+
|
| 225 |
+
# logging.info(f"Generated {len(toc_items)} TOC items with actual page numbers")
|
| 226 |
+
|
| 227 |
+
# Executive Summary is now handled as a regular section above
|
| 228 |
+
|
| 229 |
+
# ===== MAIN SECTIONS =====
|
| 230 |
+
for i, section in enumerate(sorted_sections):
|
| 231 |
+
if section.content.strip():
|
| 232 |
+
# Add page break before each section (including Executive Summary)
|
| 233 |
+
doc.add_page_break()
|
| 234 |
+
|
| 235 |
+
# Don't add section heading here - it's already in the TOC
|
| 236 |
+
# The section title is already displayed in the Table of Contents
|
| 237 |
+
|
| 238 |
+
# Process content with inline charts - use ORIGINAL content to preserve chart markers
|
| 239 |
+
process_content_with_inline_charts(doc, section.content, request.businessIdea or "Business")
|
| 240 |
+
|
| 241 |
+
doc.add_paragraph()
|
| 242 |
+
|
| 243 |
+
# Charts are now handled inline with the sections above
|
| 244 |
+
|
| 245 |
+
# Save to bytes
|
| 246 |
+
buffer = io.BytesIO()
|
| 247 |
+
doc.save(buffer)
|
| 248 |
+
buffer.seek(0)
|
| 249 |
+
doc_bytes = buffer.getvalue()
|
| 250 |
+
buffer.close()
|
| 251 |
+
|
| 252 |
+
filename = f"business_plan_{request.businessIdea or 'export'}.docx"
|
| 253 |
+
return doc_bytes, filename
|
app/__init__.py
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# App Package
|
| 2 |
+
# This file makes the app directory a proper Python package
|
| 3 |
+
|
| 4 |
+
import os
|
| 5 |
+
import sys
|
| 6 |
+
|
| 7 |
+
# Add the current directory to sys.path
|
| 8 |
+
current_dir = os.path.dirname(__file__)
|
| 9 |
+
if current_dir not in sys.path:
|
| 10 |
+
sys.path.insert(0, current_dir)
|
| 11 |
+
|
| 12 |
+
# Import main modules for easy access
|
| 13 |
+
from models import ExportRequest, ExportSection, ExportResponse, SectionJob, BatchGenerateRequest, GenerateRequest, CreatePlanRequest
|
| 14 |
+
from constants import SECTION_ORDER, SECTION_MAP, SECTION_COLUMNS
|
| 15 |
+
from utils import sort_sections_by_order
|
| 16 |
+
|
| 17 |
+
__all__ = [
|
| 18 |
+
'ExportRequest',
|
| 19 |
+
'ExportSection',
|
| 20 |
+
'ExportResponse',
|
| 21 |
+
'SectionJob',
|
| 22 |
+
'BatchGenerateRequest',
|
| 23 |
+
'GenerateRequest',
|
| 24 |
+
'CreatePlanRequest',
|
| 25 |
+
'SECTION_ORDER',
|
| 26 |
+
'SECTION_MAP',
|
| 27 |
+
'SECTION_COLUMNS',
|
| 28 |
+
'sort_sections_by_order'
|
| 29 |
+
]
|
app/assets/logos/MBD logo.png
ADDED
|
app/constants.py
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Constants and Configuration
|
| 2 |
+
# Contains constants and configuration values to avoid circular imports
|
| 3 |
+
|
| 4 |
+
# Human-readable section titles for TOC and display
|
| 5 |
+
SECTION_ORDER = [
|
| 6 |
+
"Executive Summary",
|
| 7 |
+
"Company Profile",
|
| 8 |
+
"Market Analysis",
|
| 9 |
+
"Product or Service",
|
| 10 |
+
"Business Model",
|
| 11 |
+
"Marketing & Growth",
|
| 12 |
+
"Operations Plan",
|
| 13 |
+
"Management Team",
|
| 14 |
+
"Financial Plan & Funding"
|
| 15 |
+
]
|
| 16 |
+
|
| 17 |
+
# Database helper constants
|
| 18 |
+
SECTION_MAP = {
|
| 19 |
+
"prompt_ExecutiveSummary": ("executiveSummary", "executiveSummaryComplete", "executiveSummaryGenerating"),
|
| 20 |
+
"prompt_CompanyProfile": ("companyProfile", "companyProfileComplete", "companyProfileGenerating"),
|
| 21 |
+
"prompt_MarketAnalysis": ("marketAnalysis", "marketAnalysisComplete", "marketAnalysisGenerating"),
|
| 22 |
+
"prompt_ProductOrService": ("productOrService", "productOrServiceComplete", "productOrServiceGenerating"),
|
| 23 |
+
"prompt_BusinessModel": ("businessModel", "businessModelComplete", "businessModelGenerating"),
|
| 24 |
+
"prompt_MarketingGrowth": ("marketingGrowth", "marketingGrowthComplete", "marketingGrowthGenerating"),
|
| 25 |
+
"prompt_OperationsPlan": ("operationsPlan", "operationsPlanComplete", "operationsPlanGenerating"),
|
| 26 |
+
"prompt_ManagementTeam": ("managementTeam", "managementTeamComplete", "managementTeamGenerating"),
|
| 27 |
+
"prompt_FinancialPlanFunding": ("financialPlanFunding", "financialPlanFundingComplete", "financialPlanFundingGenerating"),
|
| 28 |
+
}
|
| 29 |
+
|
| 30 |
+
# Database column names for sections
|
| 31 |
+
SECTION_COLUMNS = [
|
| 32 |
+
"executiveSummary",
|
| 33 |
+
"companyProfile",
|
| 34 |
+
"marketAnalysis",
|
| 35 |
+
"productOrService",
|
| 36 |
+
"businessModel",
|
| 37 |
+
"marketingGrowth",
|
| 38 |
+
"operationsPlan",
|
| 39 |
+
"managementTeam",
|
| 40 |
+
"financialPlanFunding",
|
| 41 |
+
]
|
app/main.py
ADDED
|
@@ -0,0 +1,1874 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
##########################
|
| 2 |
+
# NEW MAIN APP #
|
| 3 |
+
##########################
|
| 4 |
+
import os
|
| 5 |
+
import asyncio
|
| 6 |
+
import asyncpg
|
| 7 |
+
import logging
|
| 8 |
+
from typing import List, Dict, Any, Optional, Literal
|
| 9 |
+
import re
|
| 10 |
+
import time
|
| 11 |
+
import ssl
|
| 12 |
+
import json
|
| 13 |
+
from datetime import datetime
|
| 14 |
+
import pathlib as Path
|
| 15 |
+
import uuid
|
| 16 |
+
import base64
|
| 17 |
+
import io
|
| 18 |
+
import tempfile
|
| 19 |
+
|
| 20 |
+
# ──────────────────────────────────────────────────────────────────────────────
|
| 21 |
+
# CUID generator (25-char, starts with "c")
|
| 22 |
+
# ──────────────────────────────────────────────────────────────────────────────
|
| 23 |
+
import hashlib, socket, random
|
| 24 |
+
import matplotlib
|
| 25 |
+
|
| 26 |
+
# Fix matplotlib permission issues by setting cache directory to a writable location
|
| 27 |
+
os.environ['MPLCONFIGDIR'] = '/tmp/matplotlib_cache'
|
| 28 |
+
matplotlib.use('Agg') # Use non-interactive backend for server environments
|
| 29 |
+
|
| 30 |
+
import matplotlib.pyplot as plt
|
| 31 |
+
import numpy as np
|
| 32 |
+
|
| 33 |
+
_CUID_COUNTER = 0
|
| 34 |
+
def _base36(n: int) -> str:
|
| 35 |
+
chars = "0123456789abcdefghijklmnopqrstuvwxyz"
|
| 36 |
+
if n == 0: return "0"
|
| 37 |
+
s = []
|
| 38 |
+
nn = abs(n)
|
| 39 |
+
while nn:
|
| 40 |
+
nn, r = divmod(nn, 36)
|
| 41 |
+
s.append(chars[r])
|
| 42 |
+
s = "".join(reversed(s))
|
| 43 |
+
return "-" + s if n < 0 else s
|
| 44 |
+
|
| 45 |
+
def _pad36(n: int, width: int) -> str:
|
| 46 |
+
s = _base36(n)
|
| 47 |
+
return s.rjust(width, "0")[-width:]
|
| 48 |
+
|
| 49 |
+
def _fingerprint_block() -> str:
|
| 50 |
+
src = f"{socket.gethostname()}-{os.getpid()}"
|
| 51 |
+
h = int(hashlib.md5(src.encode()).hexdigest(), 16)
|
| 52 |
+
return _pad36(h, 4)
|
| 53 |
+
|
| 54 |
+
def _rand_block() -> str:
|
| 55 |
+
# 36^4 = 1,679,616; use 20 random bits then base36-pad to 4 chars
|
| 56 |
+
return _pad36(random.SystemRandom().getrandbits(20), 4)
|
| 57 |
+
|
| 58 |
+
def new_cuid() -> str:
|
| 59 |
+
global _CUID_COUNTER
|
| 60 |
+
ts = int(time.time() * 1000)
|
| 61 |
+
_CUID_COUNTER = (_CUID_COUNTER + 1) % (36**4) # 0..36^4-1
|
| 62 |
+
return "c" + _pad36(ts, 8) + _pad36(_CUID_COUNTER, 4) + _fingerprint_block() + _rand_block() + _rand_block()
|
| 63 |
+
|
| 64 |
+
from dotenv import load_dotenv
|
| 65 |
+
load_dotenv() # Load environment variables from .env into os.environ
|
| 66 |
+
|
| 67 |
+
# Supabase client
|
| 68 |
+
from supabase import create_client, Client
|
| 69 |
+
|
| 70 |
+
from fastapi import FastAPI, HTTPException, Request
|
| 71 |
+
from fastapi.middleware.cors import CORSMiddleware
|
| 72 |
+
from fastapi import Query
|
| 73 |
+
from fastapi.responses import FileResponse
|
| 74 |
+
from pydantic import BaseModel
|
| 75 |
+
from starlette.responses import StreamingResponse
|
| 76 |
+
from typing import AsyncIterator
|
| 77 |
+
from collections import defaultdict
|
| 78 |
+
from fastapi import Depends, HTTPException, status
|
| 79 |
+
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
|
| 80 |
+
|
| 81 |
+
import asyncpg
|
| 82 |
+
from jose import jwt, JWTError
|
| 83 |
+
|
| 84 |
+
# LangChain / LLMs
|
| 85 |
+
from langchain_openai import ChatOpenAI
|
| 86 |
+
from langchain.prompts import PromptTemplate
|
| 87 |
+
from langchain.schema.runnable import RunnableSequence
|
| 88 |
+
from langchain.schema import AIMessage
|
| 89 |
+
from langchain.llms.base import LLM
|
| 90 |
+
|
| 91 |
+
from huggingface_hub import InferenceClient
|
| 92 |
+
|
| 93 |
+
# ──────────────────────────────────────────────────────────────────────────────
|
| 94 |
+
# Document Generation Imports
|
| 95 |
+
# ──────────────────────────────────────────────────────────────────────────────
|
| 96 |
+
import sys
|
| 97 |
+
import os
|
| 98 |
+
|
| 99 |
+
# Add the Document Generation and Currency Mapping paths to sys.path
|
| 100 |
+
sys.path.append(os.path.join(os.path.dirname(__file__), 'DocumentGeneration'))
|
| 101 |
+
sys.path.append(os.path.join(os.path.dirname(__file__), 'CurrencyMapping'))
|
| 102 |
+
|
| 103 |
+
# Import models
|
| 104 |
+
from models import ExportRequest, ExportSection, ExportResponse, SectionJob, BatchGenerateRequest, GenerateRequest, CreatePlanRequest
|
| 105 |
+
|
| 106 |
+
# Import constants
|
| 107 |
+
from constants import SECTION_ORDER, SECTION_MAP, SECTION_COLUMNS
|
| 108 |
+
|
| 109 |
+
# Import utilities
|
| 110 |
+
from utils import sort_sections_by_order
|
| 111 |
+
|
| 112 |
+
# Now import the modules
|
| 113 |
+
import sys
|
| 114 |
+
import os
|
| 115 |
+
|
| 116 |
+
# Add the Document Generation and Currency Mapping paths to sys.path
|
| 117 |
+
sys.path.append(os.path.join(os.path.dirname(__file__), 'DocumentGeneration'))
|
| 118 |
+
sys.path.append(os.path.join(os.path.dirname(__file__), 'CurrencyMapping'))
|
| 119 |
+
|
| 120 |
+
from DocumentGeneration.pdf_generator import create_pdf_document
|
| 121 |
+
from DocumentGeneration.word_generator import create_word_document
|
| 122 |
+
from DocumentGeneration.chart_generator import parse_embedded_chart_data, create_chart_from_data
|
| 123 |
+
from DocumentGeneration.table_of_content import generate_table_of_contents_after_content, add_table_of_contents_page
|
| 124 |
+
from DocumentGeneration.document_helpers import (
|
| 125 |
+
extract_business_name_from_content,
|
| 126 |
+
filter_empty_sections,
|
| 127 |
+
extract_subheadings,
|
| 128 |
+
remove_duplicate_headings
|
| 129 |
+
)
|
| 130 |
+
from DocumentGeneration.grouped import parse_content_into_groups, extract_key_phrases, get_content_groups_info
|
| 131 |
+
from DocumentGeneration.markdown_renderer import (
|
| 132 |
+
clean_text_for_pdf,
|
| 133 |
+
render_markdown_to_pdf,
|
| 134 |
+
render_markdown_to_pdf_grouped,
|
| 135 |
+
render_markdown_to_docx,
|
| 136 |
+
render_markdown_to_docx_grouped,
|
| 137 |
+
clean_text_for_docx
|
| 138 |
+
)
|
| 139 |
+
from CurrencyMapping.currency_mapping import convert_currency_symbols_to_iso
|
| 140 |
+
|
| 141 |
+
# ──────────────────────────────────────────────────────────────────────────────
|
| 142 |
+
# Logging
|
| 143 |
+
# ──────────────────────────────────────────────────────────────────────────────
|
| 144 |
+
logging.basicConfig(level=logging.INFO)
|
| 145 |
+
|
| 146 |
+
# ──────────────────────────────────────────────────────────────────────────────
|
| 147 |
+
# SSE broker
|
| 148 |
+
# ──────────────────────────────────────────────────────────────────────────────
|
| 149 |
+
# --- In-memory SSE broker keyed by businessPlanId ---
|
| 150 |
+
SUBSCRIBERS: dict[str, set[asyncio.Queue[str]]] = defaultdict(set)
|
| 151 |
+
|
| 152 |
+
def _sse_format(event: str, payload: dict) -> str:
|
| 153 |
+
return f"event: {event}\n" f"data: {json.dumps(payload, ensure_ascii=False)}\n\n"
|
| 154 |
+
|
| 155 |
+
async def _publish(plan_id: str, event: str, payload: dict) -> None:
|
| 156 |
+
msg = _sse_format(event, payload)
|
| 157 |
+
for q in list(SUBSCRIBERS.get(plan_id, set())):
|
| 158 |
+
try:
|
| 159 |
+
q.put_nowait(msg)
|
| 160 |
+
except Exception:
|
| 161 |
+
SUBSCRIBERS[plan_id].discard(q)
|
| 162 |
+
|
| 163 |
+
# ──────────────────────────────────────────────────────────────────────────────
|
| 164 |
+
# Environment checks
|
| 165 |
+
# ──────────────────────────────────────────────────────────────────────────────
|
| 166 |
+
def check_environment_variables():
|
| 167 |
+
hf_token = os.getenv("HUGGINGFACEHUB_API_TOKEN")
|
| 168 |
+
if not hf_token:
|
| 169 |
+
logging.warning("⚠️ HUGGINGFACEHUB_API_TOKEN not set. Hugging Face models will not work properly.")
|
| 170 |
+
else:
|
| 171 |
+
logging.info("✅ HUGGINGFACEHUB_API_TOKEN is set")
|
| 172 |
+
|
| 173 |
+
openai_api_key = os.getenv("OPENAI_API_KEY")
|
| 174 |
+
if not openai_api_key:
|
| 175 |
+
logging.warning("⚠️ OPENAI_API_KEY not set. OpenAI GPT models will not work properly.")
|
| 176 |
+
else:
|
| 177 |
+
logging.info("✅ OPENAI_API_KEY is set")
|
| 178 |
+
|
| 179 |
+
check_environment_variables()
|
| 180 |
+
|
| 181 |
+
logging.info("=" * 80)
|
| 182 |
+
logging.info("MODEL SIZE LIMITATIONS:")
|
| 183 |
+
logging.info("The free tier of Hugging Face Inference API limits models to 10GB.")
|
| 184 |
+
logging.info("Large models like Qwen-2.5-7B (15GB) and Llama-2-7B (13GB) exceed this limit.")
|
| 185 |
+
logging.info("We've configured smaller alternative models as replacements.")
|
| 186 |
+
logging.info("For full-sized models, upgrade to Hugging Face Pro subscription.")
|
| 187 |
+
logging.info("=" * 80)
|
| 188 |
+
|
| 189 |
+
# ──────────────────────────────────────────────────────────────────────────────
|
| 190 |
+
# FastAPI app & CORS
|
| 191 |
+
# ──────────────────────────────────────────────────────────────────────────────
|
| 192 |
+
app = FastAPI()
|
| 193 |
+
|
| 194 |
+
@app.middleware("http")
|
| 195 |
+
async def log_requests(request: Request, call_next):
|
| 196 |
+
start_time = time.time()
|
| 197 |
+
response = await call_next(request)
|
| 198 |
+
process_time = time.time() - start_time
|
| 199 |
+
logging.info(f"Request: {request.method} {request.url.path} - Status: {response.status_code} - Time: {process_time:.2f}s")
|
| 200 |
+
return response
|
| 201 |
+
|
| 202 |
+
app.add_middleware(
|
| 203 |
+
CORSMiddleware,
|
| 204 |
+
allow_origins=["*"],
|
| 205 |
+
allow_credentials=True,
|
| 206 |
+
allow_methods=["*"],
|
| 207 |
+
allow_headers=["*"],
|
| 208 |
+
expose_headers=["*"]
|
| 209 |
+
)
|
| 210 |
+
|
| 211 |
+
# ──────────────────────────────────────────────────────────────────────────────
|
| 212 |
+
# Allow all origins (adjust for production usage)
|
| 213 |
+
# app.add_middleware(
|
| 214 |
+
# CORSMiddleware,
|
| 215 |
+
# allow_origins=["*"],
|
| 216 |
+
# allow_credentials=True,
|
| 217 |
+
# allow_methods=["*"],
|
| 218 |
+
# allow_headers=["*"],
|
| 219 |
+
# )
|
| 220 |
+
# Fixed list of business questions (order matters)
|
| 221 |
+
# ──────────────────────────────────────────────────────────────────────────────
|
| 222 |
+
QUESTIONS = [
|
| 223 |
+
"What is your business name?",
|
| 224 |
+
"What product or service do you offer?",
|
| 225 |
+
"Who is your target customer?",
|
| 226 |
+
"What problem does your business solve?",
|
| 227 |
+
"Who are your competitors?",
|
| 228 |
+
"What is your unique value proposition?",
|
| 229 |
+
"What is your pricing strategy?",
|
| 230 |
+
"What are your short-term goals?",
|
| 231 |
+
"What are your long-term goals?",
|
| 232 |
+
"How will you acquire customers?",
|
| 233 |
+
]
|
| 234 |
+
|
| 235 |
+
# ──────────────────────────────────────────────────────────────────────────────
|
| 236 |
+
# SectionKey type
|
| 237 |
+
# ──────────────────────────────────────────────────────────────────────────────
|
| 238 |
+
SectionKey = Literal[
|
| 239 |
+
"prompt_ExecutiveSummary","prompt_CompanyProfile","prompt_MarketAnalysis",
|
| 240 |
+
"prompt_ProductOrService","prompt_BusinessModel","prompt_MarketingGrowth",
|
| 241 |
+
"prompt_OperationsPlan","prompt_ManagementTeam","prompt_FinancialPlanFunding"
|
| 242 |
+
]
|
| 243 |
+
|
| 244 |
+
class SectionJob(BaseModel):
|
| 245 |
+
sectionKey: SectionKey
|
| 246 |
+
# The server will load prompt/model defaults from GeneratePrompt.
|
| 247 |
+
# Client MAY override but is not required to send these.
|
| 248 |
+
prompt: Optional[str] = None
|
| 249 |
+
promptVariables: Dict[str, str] = {}
|
| 250 |
+
model: Optional[str] = None
|
| 251 |
+
provider: Optional[str] = None
|
| 252 |
+
temperature: Optional[float] = None
|
| 253 |
+
maxTokens: Optional[int] = None
|
| 254 |
+
|
| 255 |
+
# Database helper constants
|
| 256 |
+
|
| 257 |
+
# Database helper functions
|
| 258 |
+
|
| 259 |
+
|
| 260 |
+
|
| 261 |
+
def extract_summary_from_markdown(markdown_text: str, max_length: int = 200) -> str:
|
| 262 |
+
"""Extract a summary from markdown text, removing headers and formatting"""
|
| 263 |
+
if not markdown_text:
|
| 264 |
+
return ""
|
| 265 |
+
|
| 266 |
+
# Try to find the first header as a title
|
| 267 |
+
lines = markdown_text.split('\n')
|
| 268 |
+
for line in lines:
|
| 269 |
+
if line.strip().startswith('#'):
|
| 270 |
+
header_text = re.sub(r'^#+\s*', '', line.strip())
|
| 271 |
+
if header_text:
|
| 272 |
+
return header_text[:max_length]
|
| 273 |
+
|
| 274 |
+
# Fallback to first non-empty line
|
| 275 |
+
for line in lines:
|
| 276 |
+
clean_line = re.sub(r'[*_`~#]', '', line.strip())
|
| 277 |
+
if clean_line:
|
| 278 |
+
return clean_line[:max_length]
|
| 279 |
+
|
| 280 |
+
return "Generated Business Plan"
|
| 281 |
+
|
| 282 |
+
# ──────────────────────────────────────────────────────────────────────────────
|
| 283 |
+
# Helpers for status flags
|
| 284 |
+
# ──────────────────────────────────────────────────────────────────────────────
|
| 285 |
+
async def _ensure_sections_row(pool, business_plan_id: str) -> None:
|
| 286 |
+
async with pool.acquire() as conn:
|
| 287 |
+
await conn.execute(
|
| 288 |
+
'INSERT INTO "BusinessPlanSection" ("id","businessPlanId","updatedAt") '
|
| 289 |
+
'VALUES ($1,$2, now()) ON CONFLICT ("businessPlanId") DO NOTHING',
|
| 290 |
+
new_cuid(), business_plan_id
|
| 291 |
+
)
|
| 292 |
+
|
| 293 |
+
async def charge_full_plan_once(
|
| 294 |
+
pool,
|
| 295 |
+
*,
|
| 296 |
+
business_plan_id: str,
|
| 297 |
+
user_id: Optional[str],
|
| 298 |
+
plan_type: Optional[str],
|
| 299 |
+
) -> tuple[bool, int]:
|
| 300 |
+
"""
|
| 301 |
+
Charges ONE credit for a full-plan generation, once per business_plan_id.
|
| 302 |
+
Returns (charged_now, current_credits_after).
|
| 303 |
+
- Only runs for plan_type == 'full' and authenticated users.
|
| 304 |
+
- Idempotent via CreditCharge(businessPlanId) unique constraint.
|
| 305 |
+
- Updates User.currentCredits -= 1 and User.totalUsedCredits += 1 atomically.
|
| 306 |
+
"""
|
| 307 |
+
if plan_type != "full":
|
| 308 |
+
return (False, -1)
|
| 309 |
+
if not user_id:
|
| 310 |
+
# Full plans require a signed-in user (credits live on users)
|
| 311 |
+
raise HTTPException(status_code=401, detail="Sign in required to generate a full plan.")
|
| 312 |
+
|
| 313 |
+
|
| 314 |
+
async with pool.acquire() as conn:
|
| 315 |
+
# If we've already charged this plan, just return current credits tally.
|
| 316 |
+
already = await conn.fetchval(
|
| 317 |
+
'SELECT 1 FROM "CreditCharge" WHERE "businessPlanId"=$1',
|
| 318 |
+
business_plan_id,
|
| 319 |
+
)
|
| 320 |
+
if already:
|
| 321 |
+
# return current balance for UI if you want to display it
|
| 322 |
+
bal = await conn.fetchval(
|
| 323 |
+
'SELECT "currentCredits" FROM "User" WHERE "id"=$1',
|
| 324 |
+
user_id,
|
| 325 |
+
)
|
| 326 |
+
return (False, int(bal) if bal is not None else -1)
|
| 327 |
+
|
| 328 |
+
# Charge once in a transaction; guards concurrency
|
| 329 |
+
async with conn.transaction():
|
| 330 |
+
# Lock the user row
|
| 331 |
+
row = await conn.fetchrow(
|
| 332 |
+
'SELECT "currentCredits","totalUsedCredits" FROM "User" WHERE "id"=$1 FOR UPDATE',
|
| 333 |
+
user_id,
|
| 334 |
+
)
|
| 335 |
+
if not row:
|
| 336 |
+
raise HTTPException(status_code=404, detail="User not found.")
|
| 337 |
+
current = int(row["currentCredits"] or 0)
|
| 338 |
+
if current <= 0:
|
| 339 |
+
raise HTTPException(status_code=402, detail="Insufficient credits.")
|
| 340 |
+
|
| 341 |
+
# Deduct and increment usage
|
| 342 |
+
await conn.execute(
|
| 343 |
+
'UPDATE "User" SET "currentCredits" = "currentCredits" - 1, "totalUsedCredits" = "totalUsedCredits" + 1 WHERE "id"=$1',
|
| 344 |
+
user_id,
|
| 345 |
+
)
|
| 346 |
+
# Mark this plan as charged
|
| 347 |
+
await conn.execute(
|
| 348 |
+
'INSERT INTO "CreditCharge" ("id","businessPlanId","userId") VALUES ($1,$2,$3)',
|
| 349 |
+
new_cuid(), business_plan_id, user_id,
|
| 350 |
+
)
|
| 351 |
+
|
| 352 |
+
# Return new balance
|
| 353 |
+
new_bal = await conn.fetchval(
|
| 354 |
+
'SELECT "currentCredits" FROM "User" WHERE "id"=$1',
|
| 355 |
+
user_id,
|
| 356 |
+
)
|
| 357 |
+
return (True, int(new_bal) if new_bal is not None else -1)
|
| 358 |
+
|
| 359 |
+
async def _set_section_status(pool, *, business_plan_id: str, section_key: str, generating: bool, complete: bool) -> None:
|
| 360 |
+
if section_key not in SECTION_MAP:
|
| 361 |
+
return
|
| 362 |
+
_, col_complete, col_generating = SECTION_MAP[section_key]
|
| 363 |
+
async with pool.acquire() as conn:
|
| 364 |
+
await conn.execute(
|
| 365 |
+
f'''
|
| 366 |
+
UPDATE "BusinessPlanSection"
|
| 367 |
+
SET "{col_generating}"=$2,
|
| 368 |
+
"{col_complete}"=$3,
|
| 369 |
+
"updatedAt"=now()
|
| 370 |
+
WHERE "businessPlanId"=$1
|
| 371 |
+
''',
|
| 372 |
+
business_plan_id, generating, complete
|
| 373 |
+
)
|
| 374 |
+
|
| 375 |
+
# ──────────────────────────────────────────────────────────────────────────────
|
| 376 |
+
# Request model
|
| 377 |
+
# ──────────────────────────────────────────────────────────────────────────────
|
| 378 |
+
class GenerateRequest(BaseModel):
|
| 379 |
+
answers: List[str]
|
| 380 |
+
model: str
|
| 381 |
+
prompt: str
|
| 382 |
+
promptVariables: Dict[str, str]
|
| 383 |
+
provider: str
|
| 384 |
+
temperature: float
|
| 385 |
+
maxTokens: int
|
| 386 |
+
# NEW — optional, for persistence & ownership
|
| 387 |
+
businessPlanId: Optional[str] = None
|
| 388 |
+
planType: Optional[str] = "free"
|
| 389 |
+
userId: Optional[str] = None # will be overridden by verified JWT
|
| 390 |
+
anonymousId: Optional[str] = None
|
| 391 |
+
countryCode: Optional[str] = None
|
| 392 |
+
sectionKey: Optional[str] = None # e.g. "prompt_ExecutiveSummary"
|
| 393 |
+
returnContent: Optional[bool] = False # NEW — default to id-only responses
|
| 394 |
+
|
| 395 |
+
# ──────────────────────────────────────────────────────────────────────────────
|
| 396 |
+
# LLM plumbing
|
| 397 |
+
# ──────────────────────────────────────────────────────────────────────────────
|
| 398 |
+
def clean_markdown_text(text: str) -> str:
|
| 399 |
+
"""Clean markdown formatting for PDF generation - PRESERVE HEADINGS"""
|
| 400 |
+
if not text:
|
| 401 |
+
return ""
|
| 402 |
+
|
| 403 |
+
# Remove markdown formatting BUT PRESERVE HEADING STRUCTURE
|
| 404 |
+
cleaned = text
|
| 405 |
+
|
| 406 |
+
# PRESERVE HEADINGS - don't strip the # markers, keep them for rendering
|
| 407 |
+
# We'll handle styling at render time instead of stripping them
|
| 408 |
+
|
| 409 |
+
# Keep bold, italic formatting - don't strip ** or * symbols
|
| 410 |
+
# cleaned = re.sub(r'\*\*(.*?)\*\*', r'\1', cleaned) # Remove bold - COMMENTED OUT
|
| 411 |
+
# cleaned = re.sub(r'\*(.*?)\*', r'\1', cleaned) # Remove italic - COMMENTED OUT
|
| 412 |
+
cleaned = re.sub(r'`(.*?)`', r'\1', cleaned) # Remove code
|
| 413 |
+
cleaned = re.sub(r'~~(.*?)~~', r'\1', cleaned) # Remove strikethrough
|
| 414 |
+
|
| 415 |
+
# Remove links (keep text)
|
| 416 |
+
cleaned = re.sub(r'\[([^\]]+)\]\([^)]+\)', r'\1', cleaned) # Remove links, keep text
|
| 417 |
+
cleaned = re.sub(r'!\[([^\]]*)\]\([^)]+\)', '', cleaned) # Remove images
|
| 418 |
+
|
| 419 |
+
# Remove list markers (more aggressive)
|
| 420 |
+
cleaned = re.sub(r'^\s*[-*+]\s*', '', cleaned, flags=re.MULTILINE) # Remove list markers
|
| 421 |
+
cleaned = re.sub(r'^\s*\d+\.\s*', '', cleaned, flags=re.MULTILINE) # Remove numbered list markers
|
| 422 |
+
cleaned = re.sub(r'^\s*[-*+]\s*', '', cleaned, flags=re.MULTILINE) # Remove any remaining list markers
|
| 423 |
+
|
| 424 |
+
# Remove blockquotes
|
| 425 |
+
cleaned = re.sub(r'^\s*>\s*', '', cleaned, flags=re.MULTILINE) # Remove blockquotes
|
| 426 |
+
|
| 427 |
+
# Remove horizontal rules
|
| 428 |
+
cleaned = re.sub(r'^\s*[-*_]{3,}\s*$', '', cleaned, flags=re.MULTILINE) # Remove horizontal rules
|
| 429 |
+
|
| 430 |
+
# Remove code blocks
|
| 431 |
+
cleaned = re.sub(r'```[\s\S]*?```', '', cleaned) # Remove code blocks
|
| 432 |
+
cleaned = re.sub(r'`.*?`', '', cleaned) # Remove any remaining inline code
|
| 433 |
+
|
| 434 |
+
# Keep emphasis markers - don't strip ** or * symbols
|
| 435 |
+
# cleaned = re.sub(r'_{1,2}(.*?)_{1,2}', r'\1', cleaned) # Remove underscores - COMMENTED OUT
|
| 436 |
+
# cleaned = re.sub(r'\*{1,2}(.*?)\*{1,2}', r'\1', cleaned) # Remove asterisks - COMMENTED OUT
|
| 437 |
+
|
| 438 |
+
# REMOVE RAW CHART DATA MARKERS (CRITICAL FIX)
|
| 439 |
+
cleaned = re.sub(r'<!--CHARTDATASTART-->.*?<!--CHARTDATAEND-->', '', cleaned, flags=re.DOTALL)
|
| 440 |
+
cleaned = re.sub(r'<!--CHARTDATASTART-->.*$', '', cleaned, flags=re.DOTALL)
|
| 441 |
+
cleaned = re.sub(r'<!--CHARTDATAEND-->', '', cleaned)
|
| 442 |
+
|
| 443 |
+
# Remove any remaining HTML-like tags
|
| 444 |
+
cleaned = re.sub(r'<[^>]+>', '', cleaned)
|
| 445 |
+
|
| 446 |
+
# Clean up extra whitespace and formatting
|
| 447 |
+
cleaned = re.sub(r'\n\s*\n\s*\n+', '\n\n', cleaned) # Remove excessive line breaks
|
| 448 |
+
cleaned = re.sub(r'^\s+', '', cleaned, flags=re.MULTILINE) # Remove leading whitespace
|
| 449 |
+
cleaned = re.sub(r'\s+$', '', cleaned, flags=re.MULTILINE) # Remove trailing whitespace
|
| 450 |
+
cleaned = re.sub(r' +', ' ', cleaned) # Replace multiple spaces with single space
|
| 451 |
+
|
| 452 |
+
# Final cleanup
|
| 453 |
+
cleaned = cleaned.strip()
|
| 454 |
+
|
| 455 |
+
return cleaned
|
| 456 |
+
|
| 457 |
+
|
| 458 |
+
def generate_model_output(model: str, provider: str, api_key: str, prompt: str, max_tokens: int = 4000) -> str:
|
| 459 |
+
try:
|
| 460 |
+
logging.info(f"Initializing InferenceClient with provider: {provider}")
|
| 461 |
+
client = InferenceClient(provider=provider, api_key=api_key)
|
| 462 |
+
logging.info(f"Sending request to model: {model} with prompt length: {len(prompt)} and max_tokens: {max_tokens}")
|
| 463 |
+
completion = client.chat.completions.create(
|
| 464 |
+
model=model,
|
| 465 |
+
messages=[{"role": "user", "content": prompt}],
|
| 466 |
+
max_tokens=max_tokens,
|
| 467 |
+
)
|
| 468 |
+
logging.info(f"Successfully received response from model: {model} with content length: {len(completion.choices[0].message.content)}")
|
| 469 |
+
return completion.choices[0].message.content
|
| 470 |
+
except Exception as e:
|
| 471 |
+
error_message = f"Error generating output with model {model}: {str(e)}"
|
| 472 |
+
logging.error(error_message)
|
| 473 |
+
raise Exception(error_message) from e
|
| 474 |
+
|
| 475 |
+
class HFInferenceLLM(LLM):
|
| 476 |
+
model: str = None
|
| 477 |
+
provider: str = "hf-inference"
|
| 478 |
+
api_key: str = ""
|
| 479 |
+
max_tokens: int = 4000
|
| 480 |
+
|
| 481 |
+
def __init__(self, model: str, provider: str = "hf-inference", api_key: str = "", max_tokens: int = 4000):
|
| 482 |
+
super().__init__()
|
| 483 |
+
self.model = model
|
| 484 |
+
self.provider = provider
|
| 485 |
+
self.api_key = api_key
|
| 486 |
+
self.max_tokens = max_tokens
|
| 487 |
+
|
| 488 |
+
if not api_key:
|
| 489 |
+
logging.error(f"No API key provided for model {model}")
|
| 490 |
+
raise ValueError(f"API key is required for Hugging Face Inference API access to {model}")
|
| 491 |
+
try:
|
| 492 |
+
_ = InferenceClient(provider=provider, api_key=api_key)
|
| 493 |
+
logging.info(f"Successfully initialized client for model: {model}")
|
| 494 |
+
except Exception as e:
|
| 495 |
+
logging.error(f"Failed to initialize client for model {model}: {str(e)}")
|
| 496 |
+
raise ValueError(f"Could not initialize Hugging Face client for {model}: {str(e)}")
|
| 497 |
+
|
| 498 |
+
@property
|
| 499 |
+
def _llm_type(self) -> str:
|
| 500 |
+
return "hf_inference"
|
| 501 |
+
|
| 502 |
+
def _call(self, prompt: str, stop: Optional[List[str]] = None) -> str:
|
| 503 |
+
return generate_model_output(
|
| 504 |
+
model=self.model,
|
| 505 |
+
provider=self.provider,
|
| 506 |
+
api_key=self.api_key,
|
| 507 |
+
prompt=prompt,
|
| 508 |
+
max_tokens=self.max_tokens
|
| 509 |
+
)
|
| 510 |
+
|
| 511 |
+
def get_num_tokens(self, prompt: str) -> int:
|
| 512 |
+
return len(prompt.split())
|
| 513 |
+
|
| 514 |
+
# def get_llm(model_name: str, provider: str = "hf-inference"):
|
| 515 |
+
# hf_token = os.getenv("HUGGINGFACEHUB_API_TOKEN")
|
| 516 |
+
# if not hf_token:
|
| 517 |
+
# logging.error("HUGGINGFACEHUB_API_TOKEN environment variable not set")
|
| 518 |
+
# raise ValueError("HUGGINGFACEHUB_API_TOKEN environment variable is required for Hugging Face models")
|
| 519 |
+
|
| 520 |
+
# if model_name == "BPGenerateAI":
|
| 521 |
+
# openai_api_key = os.getenv("OPENAI_API_KEY")
|
| 522 |
+
# if not openai_api_key:
|
| 523 |
+
# logging.error("OPENAI_API_KEY environment variable not set")
|
| 524 |
+
# raise ValueError("OPENAI_API_KEY environment variable is required for GPT models")
|
| 525 |
+
# return ChatOpenAI(
|
| 526 |
+
# model_name="gpt-4.1-mini",
|
| 527 |
+
# temperature=0.7,
|
| 528 |
+
# max_tokens=6000,
|
| 529 |
+
# openai_api_key=openai_api_key
|
| 530 |
+
# )
|
| 531 |
+
# elif model_name == "BPSuggestionsAI":
|
| 532 |
+
# openai_api_key = os.getenv("OPENAI_API_KEY")
|
| 533 |
+
# if not openai_api_key:
|
| 534 |
+
# logging.error("OPENAI_API_KEY environment variable not set")
|
| 535 |
+
# raise ValueError("OPENAI_API_KEY environment variable is required for GPT models")
|
| 536 |
+
# return ChatOpenAI(
|
| 537 |
+
# model_name="gpt-4.1-nano",
|
| 538 |
+
# temperature=0.7,
|
| 539 |
+
# max_tokens=4000,
|
| 540 |
+
# openai_api_key=openai_api_key
|
| 541 |
+
# )
|
| 542 |
+
# elif model_name.lower() == "gpt-4.1-nano":
|
| 543 |
+
# openai_api_key = os.getenv("OPENAI_API_KEY")
|
| 544 |
+
# if not openai_api_key:
|
| 545 |
+
# raise ValueError("OPENAI_API_KEY is required for GPT models")
|
| 546 |
+
# return ChatOpenAI(
|
| 547 |
+
# model_name="gpt-4.1-nano",
|
| 548 |
+
# temperature=0.7,
|
| 549 |
+
# max_tokens=4000,
|
| 550 |
+
# openai_api_key=openai_api_key
|
| 551 |
+
# )
|
| 552 |
+
# else:
|
| 553 |
+
# return HFInferenceLLM(
|
| 554 |
+
# model=model_name,
|
| 555 |
+
# provider=provider,
|
| 556 |
+
# api_key=hf_token,
|
| 557 |
+
# max_tokens=4000
|
| 558 |
+
# )
|
| 559 |
+
def get_llm(model_name: str, provider: str):
|
| 560 |
+
"""
|
| 561 |
+
Strict provider routing.
|
| 562 |
+
- provider='openai' -> use OpenAI (requires OPENAI_API_KEY)
|
| 563 |
+
- provider in {'hf-inference','together'} -> use Hugging Face InferenceClient (requires HUGGINGFACEHUB_API_TOKEN)
|
| 564 |
+
- No automatic fallbacks.
|
| 565 |
+
"""
|
| 566 |
+
if not provider:
|
| 567 |
+
raise ValueError("No provider specified. Set provider to 'openai', 'hf-inference', or 'together'.")
|
| 568 |
+
provider = provider.lower()
|
| 569 |
+
|
| 570 |
+
supported = {"openai", "hf-inference", "together"}
|
| 571 |
+
if provider not in supported:
|
| 572 |
+
raise ValueError(f"Unsupported provider '{provider}'. Must be one of {sorted(supported)}.")
|
| 573 |
+
|
| 574 |
+
# ─────────────────────────────────────────────────────────────
|
| 575 |
+
# OPENAI
|
| 576 |
+
# ─────────────────────────────────────────────────────────────
|
| 577 |
+
if provider == "openai":
|
| 578 |
+
openai_api_key = os.getenv("OPENAI_API_KEY")
|
| 579 |
+
if not openai_api_key:
|
| 580 |
+
raise ValueError("OPENAI_API_KEY is required when provider='openai'.")
|
| 581 |
+
|
| 582 |
+
# Map your aliases to concrete OpenAI models; allow raw names too.
|
| 583 |
+
if model_name == "BPGenerateAI":
|
| 584 |
+
model = "gpt-4.1-mini"
|
| 585 |
+
max_tokens = 6000
|
| 586 |
+
elif model_name in ("BPSuggestionsAI", "gpt-4.1-nano"):
|
| 587 |
+
model = "gpt-4.1-nano"
|
| 588 |
+
max_tokens = 4000
|
| 589 |
+
else:
|
| 590 |
+
model = model_name
|
| 591 |
+
max_tokens = 4000
|
| 592 |
+
|
| 593 |
+
return ChatOpenAI(
|
| 594 |
+
model_name=model,
|
| 595 |
+
temperature=0.7,
|
| 596 |
+
max_tokens=max_tokens,
|
| 597 |
+
openai_api_key=openai_api_key,
|
| 598 |
+
)
|
| 599 |
+
|
| 600 |
+
# ─────────────────────────────────────────────────────────────
|
| 601 |
+
# HF Inference (and compatible providers like 'together')
|
| 602 |
+
# ─────────────────────────────────────────────────────────────
|
| 603 |
+
hf_token = os.getenv("HUGGINGFACEHUB_API_TOKEN")
|
| 604 |
+
if not hf_token:
|
| 605 |
+
raise ValueError(f"HUGGINGFACEHUB_API_TOKEN is required when provider='{provider}'.")
|
| 606 |
+
|
| 607 |
+
# Map your aliases to HF models; allow raw names too.
|
| 608 |
+
if model_name == "BPGenerateAI":
|
| 609 |
+
model = "mistralai/Mistral-7B-Instruct-v0.3"
|
| 610 |
+
max_tokens = 4000
|
| 611 |
+
elif model_name == "BPSuggestionsAI":
|
| 612 |
+
model = "Qwen/Qwen2.5-3B-Instruct" # smaller/faster for suggestions
|
| 613 |
+
max_tokens = 4000
|
| 614 |
+
else:
|
| 615 |
+
model = model_name
|
| 616 |
+
max_tokens = 4000
|
| 617 |
+
|
| 618 |
+
return HFInferenceLLM(
|
| 619 |
+
model=model,
|
| 620 |
+
provider=provider, # 'hf-inference' or 'together'
|
| 621 |
+
api_key=hf_token,
|
| 622 |
+
max_tokens=max_tokens
|
| 623 |
+
)
|
| 624 |
+
|
| 625 |
+
# ──────────────────────────────────────────────────────────────────────────────
|
| 626 |
+
# Supabase JWT verification
|
| 627 |
+
# ──────────────────────────────────────────────────────────────────────────────
|
| 628 |
+
SUPABASE_JWT_SECRET = os.getenv("SUPABASE_JWT_SECRET")
|
| 629 |
+
SUPABASE_JWT_AUD = os.getenv("SUPABASE_JWT_AUD") # optional; e.g. "authenticated"
|
| 630 |
+
|
| 631 |
+
def _get_bearer_token(request: Request) -> Optional[str]:
|
| 632 |
+
auth = request.headers.get("Authorization", "")
|
| 633 |
+
if auth.startswith("Bearer "):
|
| 634 |
+
return auth.split(" ", 1)[1].strip()
|
| 635 |
+
return None
|
| 636 |
+
|
| 637 |
+
def get_user_id_from_request(request: Request) -> Optional[str]:
|
| 638 |
+
token = _get_bearer_token(request)
|
| 639 |
+
if not token or not SUPABASE_JWT_SECRET:
|
| 640 |
+
return None
|
| 641 |
+
# basic shape check: header.payload.signature
|
| 642 |
+
if token.count(".") != 2:
|
| 643 |
+
logging.info("Authorization header present but not a JWT; skipping verification.")
|
| 644 |
+
return None
|
| 645 |
+
try:
|
| 646 |
+
if SUPABASE_JWT_AUD:
|
| 647 |
+
payload = jwt.decode(token, SUPABASE_JWT_SECRET, algorithms=["HS256"], audience=SUPABASE_JWT_AUD)
|
| 648 |
+
else:
|
| 649 |
+
payload = jwt.decode(token, SUPABASE_JWT_SECRET, algorithms=["HS256"])
|
| 650 |
+
return payload.get("sub")
|
| 651 |
+
except JWTError as e:
|
| 652 |
+
logging.warning(f"JWT verification failed: {e}")
|
| 653 |
+
return None
|
| 654 |
+
|
| 655 |
+
def verify_supabase_jwt(token: str) -> Optional[str]:
|
| 656 |
+
"""
|
| 657 |
+
Verify a Supabase JWT token and return the user ID (sub claim).
|
| 658 |
+
|
| 659 |
+
Args:
|
| 660 |
+
token: The JWT token string
|
| 661 |
+
|
| 662 |
+
Returns:
|
| 663 |
+
The user ID from the 'sub' claim, or None if verification fails
|
| 664 |
+
"""
|
| 665 |
+
if not token or not SUPABASE_JWT_SECRET:
|
| 666 |
+
return None
|
| 667 |
+
|
| 668 |
+
# basic shape check: header.payload.signature
|
| 669 |
+
if token.count(".") != 2:
|
| 670 |
+
logging.info("Token is not a valid JWT format; skipping verification.")
|
| 671 |
+
return None
|
| 672 |
+
|
| 673 |
+
try:
|
| 674 |
+
if SUPABASE_JWT_AUD:
|
| 675 |
+
payload = jwt.decode(token, SUPABASE_JWT_SECRET, algorithms=["HS256"], audience=SUPABASE_JWT_AUD)
|
| 676 |
+
else:
|
| 677 |
+
payload = jwt.decode(token, SUPABASE_JWT_SECRET, algorithms=["HS256"])
|
| 678 |
+
return payload.get("sub")
|
| 679 |
+
except JWTError as e:
|
| 680 |
+
logging.warning(f"JWT verification failed: {e}")
|
| 681 |
+
return None
|
| 682 |
+
|
| 683 |
+
# --- Ownership resolver: prefer verified user; else anonymous ---
|
| 684 |
+
def resolve_owner(request: Request, provided_anonymous_id: Optional[str]) -> tuple[Optional[str], Optional[str]]:
|
| 685 |
+
"""
|
| 686 |
+
Returns (user_id, anonymous_id):
|
| 687 |
+
- If Authorization JWT is present and valid -> (user_id, None)
|
| 688 |
+
- Else -> (None, provided_anonymous_id or None)
|
| 689 |
+
"""
|
| 690 |
+
user_id = get_user_id_from_request(request)
|
| 691 |
+
if user_id:
|
| 692 |
+
return user_id, None
|
| 693 |
+
return None, (provided_anonymous_id or None)
|
| 694 |
+
|
| 695 |
+
# --- Optional: guard plan ownership to prevent hijacking ---
|
| 696 |
+
async def assert_plan_ownership(pool, *, business_plan_id: str, user_id: Optional[str], anonymous_id: Optional[str]) -> None:
|
| 697 |
+
"""
|
| 698 |
+
- If plan has a userId: only that user can modify it.
|
| 699 |
+
- If plan has an anonymousId: only the same anonymousId can modify it.
|
| 700 |
+
- If plan has neither: allow (legacy).
|
| 701 |
+
Raises HTTPException(403) if not allowed.
|
| 702 |
+
"""
|
| 703 |
+
if not business_plan_id:
|
| 704 |
+
return
|
| 705 |
+
async with pool.acquire() as conn:
|
| 706 |
+
row = await conn.fetchrow('SELECT "userId","anonymousId" FROM "BusinessPlan" WHERE id=$1', business_plan_id)
|
| 707 |
+
if not row:
|
| 708 |
+
return
|
| 709 |
+
plan_user = row["userId"]
|
| 710 |
+
plan_anon = row["anonymousId"]
|
| 711 |
+
|
| 712 |
+
# If plan belongs to a user, you must be that user
|
| 713 |
+
if plan_user:
|
| 714 |
+
if not user_id or user_id != plan_user:
|
| 715 |
+
raise HTTPException(status_code=403, detail="Not allowed to modify this plan (user mismatch).")
|
| 716 |
+
|
| 717 |
+
# If plan belongs to an anonymous identity, you must present the same anonymousId
|
| 718 |
+
if plan_anon and not plan_user:
|
| 719 |
+
if anonymous_id != plan_anon:
|
| 720 |
+
raise HTTPException(status_code=403, detail="Not allowed to modify this plan (anonymous mismatch).")
|
| 721 |
+
# ──────────────────────────────────────────────────────────────────────────────
|
| 722 |
+
# Database (asyncpg pooled)
|
| 723 |
+
# ──────────────────────────────────────────────────────────────────────────────
|
| 724 |
+
DATABASE_URL = os.getenv("DATABASE_URL") # pooled endpoint recommended (6543)
|
| 725 |
+
DB_CA_CERT_PEM = os.getenv("DB_CA_CERT_PEM")
|
| 726 |
+
|
| 727 |
+
@app.on_event("startup")
|
| 728 |
+
async def startup_event():
|
| 729 |
+
if not DATABASE_URL:
|
| 730 |
+
logging.error("DATABASE_URL not set; DB writes disabled.")
|
| 731 |
+
return
|
| 732 |
+
|
| 733 |
+
if not DB_CA_CERT_PEM:
|
| 734 |
+
raise RuntimeError("DB_CA_CERT_PEM is not set. Provide the PEM certificate content in the environment.")
|
| 735 |
+
|
| 736 |
+
# Convert '\n' sequences to real newlines if needed
|
| 737 |
+
pem = DB_CA_CERT_PEM.replace("\\n", "\n")
|
| 738 |
+
|
| 739 |
+
ssl_ctx = ssl.create_default_context()
|
| 740 |
+
# Load the PEM content directly from env
|
| 741 |
+
ssl_ctx.load_verify_locations(cadata=pem)
|
| 742 |
+
ssl_ctx.check_hostname = True
|
| 743 |
+
ssl_ctx.verify_mode = ssl.CERT_REQUIRED
|
| 744 |
+
|
| 745 |
+
app.state.db_pool = await asyncpg.create_pool(
|
| 746 |
+
dsn=DATABASE_URL,
|
| 747 |
+
ssl=ssl_ctx,
|
| 748 |
+
min_size=0,
|
| 749 |
+
max_size=8,
|
| 750 |
+
command_timeout=15.0,
|
| 751 |
+
statement_cache_size=0,
|
| 752 |
+
)
|
| 753 |
+
logging.info("✅ DB pool initialized")
|
| 754 |
+
|
| 755 |
+
# Create CreditCharge table for tracking one-time charges per plan
|
| 756 |
+
async with app.state.db_pool.acquire() as conn:
|
| 757 |
+
await conn.execute(
|
| 758 |
+
'''
|
| 759 |
+
CREATE TABLE IF NOT EXISTS "CreditCharge" (
|
| 760 |
+
"id" text PRIMARY KEY,
|
| 761 |
+
"businessPlanId" text UNIQUE NOT NULL,
|
| 762 |
+
"userId" text NOT NULL,
|
| 763 |
+
"createdAt" timestamptz NOT NULL DEFAULT now()
|
| 764 |
+
);
|
| 765 |
+
'''
|
| 766 |
+
)
|
| 767 |
+
|
| 768 |
+
@app.on_event("shutdown")
|
| 769 |
+
async def shutdown_event():
|
| 770 |
+
pool = getattr(app.state, "db_pool", None)
|
| 771 |
+
if pool:
|
| 772 |
+
await pool.close()
|
| 773 |
+
logging.info("✅ DB pool closed")
|
| 774 |
+
# ──────────────────────────────────────────────────────────────────────────────
|
| 775 |
+
# Section mapping & helpers
|
| 776 |
+
# ──────────────────────────────────────────────────────────────────────────────
|
| 777 |
+
SECTION_MAP = {
|
| 778 |
+
"prompt_ExecutiveSummary": ("executiveSummary", "isExecutiveSummaryComplete", "isExecutiveSummaryGenerating"),
|
| 779 |
+
"prompt_CompanyProfile": ("companyProfile", "isCompanyProfileComplete", "isCompanyProfileGenerating"),
|
| 780 |
+
"prompt_MarketAnalysis": ("marketAnalysis", "isMarketAnalysisComplete", "isMarketAnalysisGenerating"),
|
| 781 |
+
"prompt_ProductOrService": ("productOrService", "isProductOrServiceComplete", "isProductOrServiceGenerating"),
|
| 782 |
+
"prompt_BusinessModel": ("businessModel", "isBusinessModelComplete", "isBusinessModelGenerating"),
|
| 783 |
+
"prompt_MarketingGrowth": ("marketingGrowth", "isMarketingGrowthComplete", "isMarketingGrowthGenerating"),
|
| 784 |
+
"prompt_OperationsPlan": ("operationsPlan", "isOperationsPlanComplete", "isOperationsPlanGenerating"),
|
| 785 |
+
"prompt_ManagementTeam": ("managementTeam", "isManagementTeamComplete", "isManagementTeamGenerating"),
|
| 786 |
+
"prompt_FinancialPlanFunding": ("financialPlanFunding", "isFinancialPlanFundingComplete", "isFinancialPlanFundingGenerating"),
|
| 787 |
+
}
|
| 788 |
+
|
| 789 |
+
# Database column names for sections (using the consolidated definition above)
|
| 790 |
+
|
| 791 |
+
def extract_summary_from_markdown(md: str) -> str:
|
| 792 |
+
m = re.search(r"^\s*#{1,2}\s+(.+)", md, flags=re.MULTILINE)
|
| 793 |
+
if m:
|
| 794 |
+
return m.group(1).strip()[:250]
|
| 795 |
+
return (md.strip().split("\n", 1)[0] if md.strip() else "Generated Business Plan")[:250]
|
| 796 |
+
|
| 797 |
+
async def _get_generate_prompt(pool, key: str):
|
| 798 |
+
async with pool.acquire() as conn:
|
| 799 |
+
row = await conn.fetchrow(
|
| 800 |
+
'SELECT "key","title","promptTemplate","inputVariables","defaultModel","defaultProvider",'
|
| 801 |
+
'"defaultTemp","defaultMaxTokens","defaultCountry","isActive" '
|
| 802 |
+
'FROM "GeneratePrompt" WHERE "key"=$1 AND "isActive"=TRUE',
|
| 803 |
+
key,
|
| 804 |
+
)
|
| 805 |
+
return row
|
| 806 |
+
|
| 807 |
+
def _plan_key_for_section(base_key: str, plan_type: str) -> str:
|
| 808 |
+
# free plan uses *_Free prompts except FinancialPlanFunding
|
| 809 |
+
if plan_type == "free" and base_key != "prompt_FinancialPlanFunding":
|
| 810 |
+
return f"{base_key}_Free"
|
| 811 |
+
return base_key
|
| 812 |
+
|
| 813 |
+
# --- helpers: country/currency mapping ---
|
| 814 |
+
|
| 815 |
+
def _country_name_from_code(code: Optional[str], default_name: Optional[str]) -> str:
|
| 816 |
+
if not code:
|
| 817 |
+
return default_name or "South Africa"
|
| 818 |
+
m = {
|
| 819 |
+
"ZA": "South Africa","US": "United States","GB": "United Kingdom","DE": "Germany","FR": "France",
|
| 820 |
+
"NG": "Nigeria","KE": "Kenya","IN": "India","AU": "Australia","CA": "Canada","BR": "Brazil",
|
| 821 |
+
"NL": "Netherlands","ES": "Spain","IT": "Italy","IE": "Ireland","SG": "Singapore","AE": "United Arab Emirates"
|
| 822 |
+
}
|
| 823 |
+
return m.get(code.upper(), default_name or code)
|
| 824 |
+
|
| 825 |
+
def _currency_from_country_code(code: Optional[str], fallback_country_name: Optional[str]) -> str:
|
| 826 |
+
# prefer code, else derive from country name, else USD
|
| 827 |
+
if code:
|
| 828 |
+
m = {
|
| 829 |
+
"ZA": "ZAR","US": "USD","GB": "GBP","DE": "EUR","FR": "EUR","NL": "EUR","ES": "EUR","IT": "EUR","IE":"EUR",
|
| 830 |
+
"NG": "NGN","KE": "KES","IN": "INR","AU": "AUD","CA": "CAD","BR": "BRL","SG":"SGD","AE":"AED"
|
| 831 |
+
}
|
| 832 |
+
if code.upper() in m:
|
| 833 |
+
return m[code.upper()]
|
| 834 |
+
if fallback_country_name:
|
| 835 |
+
name = fallback_country_name.lower()
|
| 836 |
+
if "south africa" in name: return "ZAR"
|
| 837 |
+
if any(x in name for x in ["united states","usa","america"]): return "USD"
|
| 838 |
+
if "united kingdom" in name or "uk" in name: return "GBP"
|
| 839 |
+
if any(x in name for x in ["germany","france","netherlands","spain","italy","ireland"]): return "EUR"
|
| 840 |
+
if "nigeria" in name: return "NGN"
|
| 841 |
+
if "kenya" in name: return "KES"
|
| 842 |
+
if "india" in name: return "INR"
|
| 843 |
+
if "australia" in name: return "AUD"
|
| 844 |
+
if "canada" in name: return "CAD"
|
| 845 |
+
if "brazil" in name: return "BRL"
|
| 846 |
+
if "singapore" in name: return "SGD"
|
| 847 |
+
if "emirates" in name or "uae" in name: return "AED"
|
| 848 |
+
return "USD"
|
| 849 |
+
|
| 850 |
+
def _compose_prompt_vars(
|
| 851 |
+
*,
|
| 852 |
+
gp_row,
|
| 853 |
+
answers: List[str],
|
| 854 |
+
business_idea: Optional[str],
|
| 855 |
+
country_code: Optional[str],
|
| 856 |
+
section_title: str,
|
| 857 |
+
feedback: Optional[str],
|
| 858 |
+
) -> Dict[str, str]:
|
| 859 |
+
"""
|
| 860 |
+
Build exactly the variables that the GeneratePrompt row declares in inputVariables.
|
| 861 |
+
Examples we've seen: ["business_context","country","currency","q_and_a"].
|
| 862 |
+
"""
|
| 863 |
+
|
| 864 |
+
# Parse declared input variables from DB (stringified JSON)
|
| 865 |
+
try:
|
| 866 |
+
declared = json.loads(gp_row["inputVariables"] or "[]")
|
| 867 |
+
except Exception:
|
| 868 |
+
declared = []
|
| 869 |
+
|
| 870 |
+
# Precompute building blocks we might map into the declared names
|
| 871 |
+
now_iso = datetime.utcnow().isoformat()
|
| 872 |
+
country_name = _country_name_from_code(country_code, gp_row.get("defaultCountry"))
|
| 873 |
+
currency = _currency_from_country_code(country_code, country_name)
|
| 874 |
+
|
| 875 |
+
# Build a Q&A string (or JSON) from your QUESTIONS & answers array
|
| 876 |
+
# (safe against length mismatch; unanswered = "N/A")
|
| 877 |
+
qa_lines = []
|
| 878 |
+
for i, q in enumerate(QUESTIONS):
|
| 879 |
+
a = answers[i] if i < len(answers) and answers[i] else "N/A"
|
| 880 |
+
qa_lines.append(f"{q} -> {a}")
|
| 881 |
+
qa_text = "\n".join(qa_lines)
|
| 882 |
+
|
| 883 |
+
# Business context: prefer explicit business_idea, else stitch from first answers
|
| 884 |
+
business_context = (business_idea or "").strip()
|
| 885 |
+
if not business_context:
|
| 886 |
+
# use first two answers as a minimal context
|
| 887 |
+
name = answers[0] if len(answers) > 0 else ""
|
| 888 |
+
offering = answers[1] if len(answers) > 1 else ""
|
| 889 |
+
business_context = (name + " — " + offering).strip(" —")
|
| 890 |
+
|
| 891 |
+
# We'll fill only what the prompt expects. If nothing declared, we fall back
|
| 892 |
+
# to a generic superset (backward compat).
|
| 893 |
+
declared = declared if isinstance(declared, list) else []
|
| 894 |
+
|
| 895 |
+
# Canonical mapping for common keys you're using in GeneratePrompt
|
| 896 |
+
mapping = {
|
| 897 |
+
"business_context": business_context,
|
| 898 |
+
"q_and_a": qa_text,
|
| 899 |
+
"country": country_name,
|
| 900 |
+
"currency": currency,
|
| 901 |
+
|
| 902 |
+
# Optional alternates you might have in some prompts
|
| 903 |
+
"section_title": section_title,
|
| 904 |
+
"current_date": now_iso,
|
| 905 |
+
"feedback": feedback or "",
|
| 906 |
+
"answers_json": json.dumps(answers or []),
|
| 907 |
+
"businessIdea": business_idea or "", # if some prompts still use old name
|
| 908 |
+
"countryCode": country_code or "", # if some prompts still use old name
|
| 909 |
+
}
|
| 910 |
+
|
| 911 |
+
if declared:
|
| 912 |
+
# Return exactly the keys the prompt asked for; raise obvious gaps to logs
|
| 913 |
+
out = {}
|
| 914 |
+
for key in declared:
|
| 915 |
+
out[key] = mapping.get(key, "")
|
| 916 |
+
return out
|
| 917 |
+
|
| 918 |
+
# Fallback for prompts that didn't declare inputVariables
|
| 919 |
+
return {
|
| 920 |
+
"business_context": business_context,
|
| 921 |
+
"q_and_a": qa_text,
|
| 922 |
+
"country": country_name,
|
| 923 |
+
"currency": currency,
|
| 924 |
+
"section_title": section_title,
|
| 925 |
+
"current_date": now_iso,
|
| 926 |
+
"feedback": feedback or "",
|
| 927 |
+
"answers_json": json.dumps(answers or []),
|
| 928 |
+
}
|
| 929 |
+
|
| 930 |
+
async def _ensure_business_plan(pool, *, bp_id: Optional[str], summary: str, full_plan: str,
|
| 931 |
+
model: str, answers: List[str], plan_type: Optional[str],
|
| 932 |
+
user_id: Optional[str], anon_id: Optional[str],
|
| 933 |
+
country_code: Optional[str]) -> str:
|
| 934 |
+
async with pool.acquire() as conn:
|
| 935 |
+
if bp_id:
|
| 936 |
+
exists = await conn.fetchval('SELECT 1 FROM "BusinessPlan" WHERE id = $1', bp_id)
|
| 937 |
+
if exists:
|
| 938 |
+
await conn.execute(
|
| 939 |
+
'UPDATE "BusinessPlan" SET '
|
| 940 |
+
'"summary"=$1, "model"=$2, "answers"=$3, '
|
| 941 |
+
'"planType"=COALESCE($4,\'free\'), "countryCode"=$5, '
|
| 942 |
+
'"updatedAt"=now(), "userId"=COALESCE($6,"userId") '
|
| 943 |
+
'WHERE id=$7',
|
| 944 |
+
summary, model, answers, plan_type, country_code, user_id, bp_id
|
| 945 |
+
)
|
| 946 |
+
return bp_id
|
| 947 |
+
|
| 948 |
+
new_id = new_cuid()
|
| 949 |
+
row = await conn.fetchrow(
|
| 950 |
+
'INSERT INTO "BusinessPlan" '
|
| 951 |
+
'("id","summary","fullPlan","model","answers","planType","userId","anonymousId","countryCode","updatedAt") '
|
| 952 |
+
'VALUES ($1,$2,$3,$4,$5,COALESCE($6,\'free\'),$7,$8,$9, now()) '
|
| 953 |
+
'RETURNING id',
|
| 954 |
+
new_id, summary, "", model, answers, plan_type, user_id, anon_id, country_code # fullPlan is intentionally ""
|
| 955 |
+
)
|
| 956 |
+
return row["id"]
|
| 957 |
+
|
| 958 |
+
# async def _upsert_section(pool, *, business_plan_id: str, section_key: str, content: str) -> None:
|
| 959 |
+
# if section_key not in SECTION_MAP:
|
| 960 |
+
# logging.warning(f"Unknown section_key '{section_key}', skipping section save.")
|
| 961 |
+
# return
|
| 962 |
+
|
| 963 |
+
# col_content, col_complete, col_generating = SECTION_MAP[section_key]
|
| 964 |
+
# set_clause = f'"{col_content}" = $2, "{col_complete}" = TRUE, "{col_generating}" = FALSE'
|
| 965 |
+
|
| 966 |
+
# async with pool.acquire() as conn:
|
| 967 |
+
# # ensure row exists with a generated id
|
| 968 |
+
# await conn.execute(
|
| 969 |
+
# 'INSERT INTO "BusinessPlanSection" ("id","businessPlanId") '
|
| 970 |
+
# 'VALUES ($1,$2) '
|
| 971 |
+
# 'ON CONFLICT ("businessPlanId") DO NOTHING',
|
| 972 |
+
# str(uuid.uuid4()), business_plan_id
|
| 973 |
+
# )
|
| 974 |
+
# # update content + flags
|
| 975 |
+
# await conn.execute(
|
| 976 |
+
# f'UPDATE "BusinessPlanSection" SET {set_clause} WHERE "businessPlanId" = $1',
|
| 977 |
+
# business_plan_id, content
|
| 978 |
+
# )
|
| 979 |
+
async def _upsert_section(pool, *, business_plan_id: str, section_key: str, content: str) -> None:
|
| 980 |
+
if section_key not in SECTION_MAP:
|
| 981 |
+
# logging.warning(f"Unknown section_key '{section_key}', skipping section save.")
|
| 982 |
+
return
|
| 983 |
+
|
| 984 |
+
col_content, col_complete, col_generating = SECTION_MAP[section_key]
|
| 985 |
+
# bump updatedAt on every write
|
| 986 |
+
set_clause = (
|
| 987 |
+
f'"{col_content}" = $2, '
|
| 988 |
+
f'"{col_complete}" = TRUE, '
|
| 989 |
+
f'"{col_generating}" = FALSE, '
|
| 990 |
+
f'"updatedAt" = now()'
|
| 991 |
+
)
|
| 992 |
+
|
| 993 |
+
async with pool.acquire() as conn:
|
| 994 |
+
# ensure row exists, and give updatedAt a value to satisfy NOT NULL
|
| 995 |
+
await conn.execute(
|
| 996 |
+
'INSERT INTO "BusinessPlanSection" ("id","businessPlanId","updatedAt") '
|
| 997 |
+
'VALUES ($1,$2, now()) '
|
| 998 |
+
'ON CONFLICT ("businessPlanId") DO NOTHING',
|
| 999 |
+
new_cuid(), business_plan_id
|
| 1000 |
+
)
|
| 1001 |
+
# update content + flags (+ updatedAt)
|
| 1002 |
+
await conn.execute(
|
| 1003 |
+
f'UPDATE "BusinessPlanSection" SET {set_clause} WHERE "businessPlanId" = $1',
|
| 1004 |
+
business_plan_id, content
|
| 1005 |
+
)
|
| 1006 |
+
|
| 1007 |
+
async def _recompute_full_plan(pool, *, business_plan_id: str) -> str:
|
| 1008 |
+
async with pool.acquire() as conn:
|
| 1009 |
+
row = await conn.fetchrow(
|
| 1010 |
+
'SELECT * FROM "BusinessPlanSection" WHERE "businessPlanId" = $1',
|
| 1011 |
+
business_plan_id
|
| 1012 |
+
)
|
| 1013 |
+
if not row:
|
| 1014 |
+
return ""
|
| 1015 |
+
parts = []
|
| 1016 |
+
for col in SECTION_ORDER:
|
| 1017 |
+
txt = row.get(col)
|
| 1018 |
+
if txt:
|
| 1019 |
+
parts.append(txt.strip())
|
| 1020 |
+
full_plan = "\n\n".join(parts)
|
| 1021 |
+
return full_plan # Do not persist fullPlan; caller may use it for summary only
|
| 1022 |
+
|
| 1023 |
+
def _all_sections_present(row: asyncpg.Record) -> bool:
|
| 1024 |
+
return all(bool(row.get(col)) for col in SECTION_ORDER)
|
| 1025 |
+
|
| 1026 |
+
# ──────────────────────────────────────────────────────────────────────────────
|
| 1027 |
+
# Endpoints
|
| 1028 |
+
# ──────────────────────────────────────────────────────────────────────────────
|
| 1029 |
+
@app.get("/suggestions", response_model=Dict[str, Any])
|
| 1030 |
+
async def get_suggestions(
|
| 1031 |
+
business_idea: str,
|
| 1032 |
+
model: str,
|
| 1033 |
+
question: str,
|
| 1034 |
+
prompt: str,
|
| 1035 |
+
provider: str,
|
| 1036 |
+
temperature: float,
|
| 1037 |
+
maxTokens: int,
|
| 1038 |
+
exclude: List[str] = Query([])
|
| 1039 |
+
) -> Dict[str, Any]:
|
| 1040 |
+
try:
|
| 1041 |
+
formatted_prompt = prompt.format(
|
| 1042 |
+
business_idea=business_idea,
|
| 1043 |
+
question=question,
|
| 1044 |
+
exclude=", ".join(exclude) if exclude else ""
|
| 1045 |
+
)
|
| 1046 |
+
llm = get_llm(model, provider)
|
| 1047 |
+
if hasattr(llm, 'temperature'):
|
| 1048 |
+
llm.temperature = temperature
|
| 1049 |
+
if hasattr(llm, 'max_tokens'):
|
| 1050 |
+
llm.max_tokens = maxTokens
|
| 1051 |
+
|
| 1052 |
+
suggestion_prompt = PromptTemplate(input_variables=[], template=formatted_prompt)
|
| 1053 |
+
suggestion_chain: RunnableSequence = suggestion_prompt | llm
|
| 1054 |
+
raw_result = await asyncio.to_thread(suggestion_chain.invoke, {})
|
| 1055 |
+
|
| 1056 |
+
if isinstance(raw_result, AIMessage):
|
| 1057 |
+
raw_text = raw_result.content
|
| 1058 |
+
elif isinstance(raw_result, (list, tuple)) and raw_result and isinstance(raw_result[0], AIMessage):
|
| 1059 |
+
raw_text = "\n".join(msg.content for msg in raw_result)
|
| 1060 |
+
else:
|
| 1061 |
+
raw_text = str(raw_result)
|
| 1062 |
+
|
| 1063 |
+
suggestion_array = [
|
| 1064 |
+
re.sub(r'^\s*[\-\d\.\)\s]+', '', line).strip()
|
| 1065 |
+
for line in raw_text.split("\n")
|
| 1066 |
+
if line.strip()
|
| 1067 |
+
]
|
| 1068 |
+
if not suggestion_array:
|
| 1069 |
+
suggestion_array = ["No suggestions available"]
|
| 1070 |
+
return {"suggestions": suggestion_array}
|
| 1071 |
+
except Exception as e:
|
| 1072 |
+
raise HTTPException(status_code=500, detail=f"Error generating suggestions: {str(e)}")
|
| 1073 |
+
|
| 1074 |
+
class CreatePlanRequest(BaseModel):
|
| 1075 |
+
answers: List[str] = []
|
| 1076 |
+
planType: Optional[str] = "free"
|
| 1077 |
+
countryCode: Optional[str] = None
|
| 1078 |
+
anonymousId: Optional[str] = None
|
| 1079 |
+
businessIdea: Optional[str] = None
|
| 1080 |
+
|
| 1081 |
+
@app.post("/plan")
|
| 1082 |
+
async def create_plan_shell(request: Request, data: CreatePlanRequest):
|
| 1083 |
+
pool = getattr(app.state, "db_pool", None)
|
| 1084 |
+
if not pool:
|
| 1085 |
+
raise HTTPException(status_code=500, detail="DB not initialized")
|
| 1086 |
+
|
| 1087 |
+
# Resolve owner (JWT beats anonymousId)
|
| 1088 |
+
user_id, anon_id = resolve_owner(request, data.anonymousId)
|
| 1089 |
+
|
| 1090 |
+
# Create a new plan row (server-minted id) + blank sections row
|
| 1091 |
+
bp_id = await _ensure_business_plan(
|
| 1092 |
+
pool,
|
| 1093 |
+
bp_id=None, # <- server generates the id; client never sends one
|
| 1094 |
+
summary="Generating…",
|
| 1095 |
+
full_plan="",
|
| 1096 |
+
model="unknown",
|
| 1097 |
+
answers=data.answers or [],
|
| 1098 |
+
plan_type=data.planType,
|
| 1099 |
+
user_id=user_id,
|
| 1100 |
+
anon_id=anon_id,
|
| 1101 |
+
country_code=data.countryCode,
|
| 1102 |
+
)
|
| 1103 |
+
await _ensure_sections_row(pool, bp_id)
|
| 1104 |
+
|
| 1105 |
+
# Tell subscribers a plan exists
|
| 1106 |
+
await _publish(bp_id, "plan.created", {"businessPlanId": bp_id})
|
| 1107 |
+
|
| 1108 |
+
return {"businessPlanId": bp_id}
|
| 1109 |
+
|
| 1110 |
+
@app.post("/generate/batch")
|
| 1111 |
+
async def generate_business_plan_batch(request: Request, data: BatchGenerateRequest):
|
| 1112 |
+
try:
|
| 1113 |
+
pool = getattr(app.state, "db_pool", None)
|
| 1114 |
+
if not pool:
|
| 1115 |
+
raise HTTPException(status_code=500, detail="DB not initialized")
|
| 1116 |
+
|
| 1117 |
+
# ① Resolve owner (JWT wins; else anonymous)
|
| 1118 |
+
user_id, anon_id = resolve_owner(request, data.anonymousId)
|
| 1119 |
+
# logging.info(f"[owner] user_id={user_id} anon_id={anon_id} planType={data.planType} planId={data.businessPlanId}")
|
| 1120 |
+
|
| 1121 |
+
# ② (Optional) Guard ownership of existing plan
|
| 1122 |
+
if data.businessPlanId:
|
| 1123 |
+
await assert_plan_ownership(pool, business_plan_id=data.businessPlanId, user_id=user_id, anonymous_id=anon_id)
|
| 1124 |
+
|
| 1125 |
+
# ③ Create/ensure plan shell
|
| 1126 |
+
bp_id = await _ensure_business_plan(
|
| 1127 |
+
pool,
|
| 1128 |
+
bp_id=data.businessPlanId,
|
| 1129 |
+
summary="Generating…",
|
| 1130 |
+
full_plan="",
|
| 1131 |
+
model="unknown",
|
| 1132 |
+
answers=data.answers,
|
| 1133 |
+
plan_type=data.planType,
|
| 1134 |
+
user_id=user_id,
|
| 1135 |
+
anon_id=anon_id,
|
| 1136 |
+
country_code=data.countryCode,
|
| 1137 |
+
)
|
| 1138 |
+
# logging.info(f"[plan] ensured id={bp_id} linked to user_id={user_id} anon_id={anon_id}")
|
| 1139 |
+
|
| 1140 |
+
# after ensure plan id
|
| 1141 |
+
await _publish(bp_id, "plan.created", {"businessPlanId": bp_id})
|
| 1142 |
+
|
| 1143 |
+
await _ensure_sections_row(pool, bp_id)
|
| 1144 |
+
|
| 1145 |
+
# 🔐 Charge exactly once per full plan (no charge for 'free' or anon)
|
| 1146 |
+
charged_now, remaining = await charge_full_plan_once(
|
| 1147 |
+
pool,
|
| 1148 |
+
business_plan_id=bp_id,
|
| 1149 |
+
user_id=user_id,
|
| 1150 |
+
plan_type=data.planType,
|
| 1151 |
+
)
|
| 1152 |
+
|
| 1153 |
+
# Mark all sections as generating
|
| 1154 |
+
for job in data.sections:
|
| 1155 |
+
await _set_section_status(
|
| 1156 |
+
pool, business_plan_id=bp_id, section_key=job.sectionKey, generating=True, complete=False
|
| 1157 |
+
)
|
| 1158 |
+
await _publish(bp_id, "section.started", {
|
| 1159 |
+
"businessPlanId": bp_id,
|
| 1160 |
+
"sectionKey": job.sectionKey,
|
| 1161 |
+
})
|
| 1162 |
+
|
| 1163 |
+
sem = asyncio.Semaphore(3)
|
| 1164 |
+
|
| 1165 |
+
async def run_job(job: SectionJob):
|
| 1166 |
+
async with sem:
|
| 1167 |
+
# 1) Resolve plan-specific key (base for full, *_Free for free except funding)
|
| 1168 |
+
effective_key = _plan_key_for_section(job.sectionKey, data.planType or "free")
|
| 1169 |
+
|
| 1170 |
+
# 2) Load GeneratePrompt row
|
| 1171 |
+
gp = await _get_generate_prompt(pool, effective_key)
|
| 1172 |
+
if not gp:
|
| 1173 |
+
raise HTTPException(status_code=400, detail=f"No active GeneratePrompt for key {effective_key}")
|
| 1174 |
+
|
| 1175 |
+
# 3) Resolve model/provider/temp/max (client overrides optional)
|
| 1176 |
+
resolved_model = job.model or gp["defaultModel"]
|
| 1177 |
+
resolved_provider = (job.provider or gp["defaultProvider"]).lower()
|
| 1178 |
+
resolved_temp = job.temperature if job.temperature is not None else float(gp["defaultTemp"])
|
| 1179 |
+
resolved_max = job.maxTokens if job.maxTokens is not None else int(gp["defaultMaxTokens"])
|
| 1180 |
+
|
| 1181 |
+
# 4) Compose variables server-side (respect declared inputVariables)
|
| 1182 |
+
vars_for_prompt = _compose_prompt_vars(
|
| 1183 |
+
gp_row=gp,
|
| 1184 |
+
answers=data.answers,
|
| 1185 |
+
business_idea=data.businessIdea or (data.answers[0] if data.answers else None),
|
| 1186 |
+
country_code=data.countryCode,
|
| 1187 |
+
section_title=gp["title"],
|
| 1188 |
+
feedback=data.feedback,
|
| 1189 |
+
)
|
| 1190 |
+
|
| 1191 |
+
# 5) Prompt + LLM + chain
|
| 1192 |
+
llm = get_llm(resolved_model, resolved_provider)
|
| 1193 |
+
if hasattr(llm, "temperature"): llm.temperature = resolved_temp
|
| 1194 |
+
if hasattr(llm, "max_tokens"): llm.max_tokens = resolved_max
|
| 1195 |
+
|
| 1196 |
+
template = job.prompt or gp["promptTemplate"]
|
| 1197 |
+
prompt_t = PromptTemplate(input_variables=list(vars_for_prompt.keys()), template=template)
|
| 1198 |
+
chain: RunnableSequence = prompt_t | llm
|
| 1199 |
+
raw = await asyncio.to_thread(chain.invoke, vars_for_prompt)
|
| 1200 |
+
text = raw.content if isinstance(raw, AIMessage) else str(raw)
|
| 1201 |
+
|
| 1202 |
+
# 6) Save section & clear flags
|
| 1203 |
+
await _upsert_section(pool, business_plan_id=bp_id, section_key=job.sectionKey, content=text)
|
| 1204 |
+
await _publish(bp_id, "section.complete", {
|
| 1205 |
+
"businessPlanId": bp_id,
|
| 1206 |
+
"sectionKey": job.sectionKey,
|
| 1207 |
+
})
|
| 1208 |
+
return resolved_model
|
| 1209 |
+
|
| 1210 |
+
resolved_models = await asyncio.gather(*(run_job(job) for job in data.sections))
|
| 1211 |
+
model_for_plan = next((m for m in resolved_models if m), "unknown")
|
| 1212 |
+
|
| 1213 |
+
# Recompose full plan & update summary
|
| 1214 |
+
full_plan = await _recompute_full_plan(pool, business_plan_id=bp_id)
|
| 1215 |
+
summary = extract_summary_from_markdown(full_plan)
|
| 1216 |
+
|
| 1217 |
+
await _ensure_business_plan(
|
| 1218 |
+
pool,
|
| 1219 |
+
bp_id=bp_id,
|
| 1220 |
+
summary=summary,
|
| 1221 |
+
full_plan=full_plan,
|
| 1222 |
+
model=model_for_plan,
|
| 1223 |
+
answers=data.answers,
|
| 1224 |
+
plan_type=data.planType,
|
| 1225 |
+
user_id=user_id,
|
| 1226 |
+
anon_id=anon_id,
|
| 1227 |
+
country_code=data.countryCode,
|
| 1228 |
+
)
|
| 1229 |
+
|
| 1230 |
+
await _publish(bp_id, "plan.updated", {
|
| 1231 |
+
"businessPlanId": bp_id,
|
| 1232 |
+
"summary": summary,
|
| 1233 |
+
"fullPlanLength": len(full_plan or ""),
|
| 1234 |
+
})
|
| 1235 |
+
await _publish(bp_id, "plan.complete", {
|
| 1236 |
+
"businessPlanId": bp_id,
|
| 1237 |
+
"completedKeys": [j.sectionKey for j in data.sections],
|
| 1238 |
+
})
|
| 1239 |
+
|
| 1240 |
+
return {
|
| 1241 |
+
"businessPlanId": bp_id,
|
| 1242 |
+
"status": "complete",
|
| 1243 |
+
"completedKeys": [j.sectionKey for j in data.sections],
|
| 1244 |
+
"fullPlanLength": len(full_plan or ""),
|
| 1245 |
+
}
|
| 1246 |
+
except Exception as e:
|
| 1247 |
+
raise HTTPException(status_code=500, detail=f"Batch generation failed: {e}")
|
| 1248 |
+
|
| 1249 |
+
@app.get("/plan/{businessPlanId}/status")
|
| 1250 |
+
async def get_plan_status(businessPlanId: str):
|
| 1251 |
+
pool = getattr(app.state, "db_pool", None)
|
| 1252 |
+
if not pool:
|
| 1253 |
+
raise HTTPException(status_code=500, detail="DB not initialized")
|
| 1254 |
+
async with pool.acquire() as conn:
|
| 1255 |
+
row = await conn.fetchrow('SELECT * FROM "BusinessPlanSection" WHERE "businessPlanId"=$1', businessPlanId)
|
| 1256 |
+
if not row:
|
| 1257 |
+
return {"exists": False}
|
| 1258 |
+
# Build per-section status
|
| 1259 |
+
statuses = {}
|
| 1260 |
+
for key, (col, complete_col, gen_col) in SECTION_MAP.items():
|
| 1261 |
+
statuses[key] = {"isGenerating": bool(row.get(gen_col)), "isComplete": bool(row.get(complete_col))}
|
| 1262 |
+
all_done = all(s["isComplete"] for s in statuses.values())
|
| 1263 |
+
return {"exists": True, "allComplete": all_done, "sections": statuses}
|
| 1264 |
+
|
| 1265 |
+
@app.get("/events/plan/{businessPlanId}")
|
| 1266 |
+
async def sse_plan(businessPlanId: str):
|
| 1267 |
+
async def event_gen() -> AsyncIterator[bytes]:
|
| 1268 |
+
q: asyncio.Queue[str] = asyncio.Queue(maxsize=200)
|
| 1269 |
+
SUBSCRIBERS[businessPlanId].add(q)
|
| 1270 |
+
try:
|
| 1271 |
+
yield b": connected\n\n"
|
| 1272 |
+
while True:
|
| 1273 |
+
try:
|
| 1274 |
+
msg = await asyncio.wait_for(q.get(), timeout=15)
|
| 1275 |
+
yield msg.encode("utf-8")
|
| 1276 |
+
except asyncio.TimeoutError:
|
| 1277 |
+
yield b": keep-alive\n\n"
|
| 1278 |
+
except asyncio.CancelledError:
|
| 1279 |
+
pass
|
| 1280 |
+
finally:
|
| 1281 |
+
SUBSCRIBERS[businessPlanId].discard(q)
|
| 1282 |
+
|
| 1283 |
+
return StreamingResponse(
|
| 1284 |
+
event_gen(),
|
| 1285 |
+
media_type="text/event-stream",
|
| 1286 |
+
headers={"Cache-Control": "no-cache", "Connection": "keep-alive"},
|
| 1287 |
+
)
|
| 1288 |
+
|
| 1289 |
+
|
| 1290 |
+
|
| 1291 |
+
@app.post("/generate")
|
| 1292 |
+
async def generate_business_plan(request: Request, data: GenerateRequest):
|
| 1293 |
+
"""
|
| 1294 |
+
Generate a business plan using the provided prompt template and variables,
|
| 1295 |
+
then persist to Supabase Postgres. Ownership is derived from the verified JWT.
|
| 1296 |
+
"""
|
| 1297 |
+
try:
|
| 1298 |
+
# logging.info(f"Initializing model: {data.model} with provider: {data.provider}")
|
| 1299 |
+
llm_selected = get_llm(data.model, data.provider) # provider is used in get_llm for HF models
|
| 1300 |
+
|
| 1301 |
+
if hasattr(llm_selected, 'temperature'):
|
| 1302 |
+
llm_selected.temperature = data.temperature
|
| 1303 |
+
if hasattr(llm_selected, 'max_tokens'):
|
| 1304 |
+
llm_selected.max_tokens = data.maxTokens
|
| 1305 |
+
|
| 1306 |
+
plan_prompt = PromptTemplate(
|
| 1307 |
+
input_variables=list(data.promptVariables.keys()),
|
| 1308 |
+
template=data.prompt
|
| 1309 |
+
)
|
| 1310 |
+
plan_chain: RunnableSequence = plan_prompt | llm_selected
|
| 1311 |
+
# logging.info(f"Generating business plan with model: {data.model}")
|
| 1312 |
+
|
| 1313 |
+
raw_plan = await asyncio.to_thread(plan_chain.invoke, data.promptVariables)
|
| 1314 |
+
|
| 1315 |
+
if isinstance(raw_plan, AIMessage):
|
| 1316 |
+
plan_text = raw_plan.content
|
| 1317 |
+
elif isinstance(raw_plan, (list, tuple)) and raw_plan and isinstance(raw_plan[0], AIMessage):
|
| 1318 |
+
plan_text = "\n".join(msg.content for msg in raw_plan)
|
| 1319 |
+
else:
|
| 1320 |
+
plan_text = str(raw_plan)
|
| 1321 |
+
|
| 1322 |
+
# ① Resolve owner consistently (JWT wins)
|
| 1323 |
+
user_id, anon_id = resolve_owner(request, data.anonymousId)
|
| 1324 |
+
# logging.info(f"[owner] user_id={user_id} anon_id={anon_id} planType={data.planType} planId={data.businessPlanId}")
|
| 1325 |
+
|
| 1326 |
+
# ② (Optional) Guard ownership if an existing plan is referenced
|
| 1327 |
+
if data.businessPlanId:
|
| 1328 |
+
pool = getattr(app.state, "db_pool", None)
|
| 1329 |
+
if not pool:
|
| 1330 |
+
raise HTTPException(status_code=500, detail="DB not initialized")
|
| 1331 |
+
await assert_plan_ownership(pool, business_plan_id=data.businessPlanId, user_id=user_id, anonymous_id=anon_id)
|
| 1332 |
+
|
| 1333 |
+
# If generating a specific section, publish section.started before LLM generation
|
| 1334 |
+
if data.sectionKey and data.businessPlanId:
|
| 1335 |
+
pool = getattr(app.state, "db_pool", None)
|
| 1336 |
+
if pool:
|
| 1337 |
+
await _publish(data.businessPlanId, "section.started", {
|
| 1338 |
+
"businessPlanId": data.businessPlanId,
|
| 1339 |
+
"sectionKey": data.sectionKey,
|
| 1340 |
+
})
|
| 1341 |
+
|
| 1342 |
+
# ── Persist
|
| 1343 |
+
pool = getattr(app.state, "db_pool", None)
|
| 1344 |
+
summary = extract_summary_from_markdown(plan_text)
|
| 1345 |
+
|
| 1346 |
+
if not pool:
|
| 1347 |
+
# logging.warning("DB pool not available; skipping persistence.")
|
| 1348 |
+
return {
|
| 1349 |
+
"summary": summary or "Generated Business Plan",
|
| 1350 |
+
"plan": plan_text,
|
| 1351 |
+
}
|
| 1352 |
+
|
| 1353 |
+
# If generating a specific section
|
| 1354 |
+
if data.sectionKey:
|
| 1355 |
+
business_plan_id = await _ensure_business_plan(
|
| 1356 |
+
pool,
|
| 1357 |
+
bp_id=data.businessPlanId,
|
| 1358 |
+
summary=summary,
|
| 1359 |
+
full_plan="", # recomputed after section save
|
| 1360 |
+
model=data.model,
|
| 1361 |
+
answers=data.answers,
|
| 1362 |
+
plan_type=data.planType,
|
| 1363 |
+
user_id=user_id,
|
| 1364 |
+
anon_id=anon_id,
|
| 1365 |
+
country_code=data.countryCode,
|
| 1366 |
+
)
|
| 1367 |
+
# logging.info(f"[plan] ensured section id={business_plan_id} linked to user_id={user_id} anon_id={anon_id}")
|
| 1368 |
+
|
| 1369 |
+
# mark generating True in the DB for this section...
|
| 1370 |
+
await _publish(business_plan_id, "section.started", {
|
| 1371 |
+
"businessPlanId": business_plan_id,
|
| 1372 |
+
"sectionKey": data.sectionKey,
|
| 1373 |
+
})
|
| 1374 |
+
|
| 1375 |
+
await _upsert_section(
|
| 1376 |
+
pool,
|
| 1377 |
+
business_plan_id=business_plan_id,
|
| 1378 |
+
section_key=data.sectionKey,
|
| 1379 |
+
content=plan_text
|
| 1380 |
+
)
|
| 1381 |
+
|
| 1382 |
+
# ...generate text, save to this section, set generating False, set section complete...
|
| 1383 |
+
await _publish(business_plan_id, "section.complete", {
|
| 1384 |
+
"businessPlanId": business_plan_id,
|
| 1385 |
+
"sectionKey": data.sectionKey,
|
| 1386 |
+
})
|
| 1387 |
+
|
| 1388 |
+
# Recompute full plan after section write
|
| 1389 |
+
full_plan = await _recompute_full_plan(pool, business_plan_id=business_plan_id)
|
| 1390 |
+
|
| 1391 |
+
# if you recompute summary here, also:
|
| 1392 |
+
await _publish(business_plan_id, "plan.updated", {
|
| 1393 |
+
"businessPlanId": business_plan_id,
|
| 1394 |
+
"summary": summary,
|
| 1395 |
+
"fullPlanLength": len(full_plan or ""),
|
| 1396 |
+
})
|
| 1397 |
+
|
| 1398 |
+
# Optional: mark complete if all sections present
|
| 1399 |
+
is_complete = False
|
| 1400 |
+
async with pool.acquire() as conn:
|
| 1401 |
+
row = await conn.fetchrow(
|
| 1402 |
+
'SELECT * FROM "BusinessPlanSection" WHERE "businessPlanId"=$1',
|
| 1403 |
+
business_plan_id
|
| 1404 |
+
)
|
| 1405 |
+
if row:
|
| 1406 |
+
is_complete = _all_sections_present(row)
|
| 1407 |
+
|
| 1408 |
+
resp = {
|
| 1409 |
+
"summary": summary,
|
| 1410 |
+
"plan": plan_text,
|
| 1411 |
+
"sections": [{"key": data.sectionKey, "content": plan_text}],
|
| 1412 |
+
"isComplete": True, # this means "this section completed"
|
| 1413 |
+
"currentSection": data.sectionKey,
|
| 1414 |
+
"businessPlanId": business_plan_id,
|
| 1415 |
+
}
|
| 1416 |
+
return resp
|
| 1417 |
+
|
| 1418 |
+
# Non-section — treat as a whole plan
|
| 1419 |
+
business_plan_id = await _ensure_business_plan(
|
| 1420 |
+
pool,
|
| 1421 |
+
bp_id=data.businessPlanId,
|
| 1422 |
+
summary=summary,
|
| 1423 |
+
full_plan="", # do not persist the full combined text
|
| 1424 |
+
model=data.model,
|
| 1425 |
+
answers=data.answers,
|
| 1426 |
+
plan_type=data.planType,
|
| 1427 |
+
user_id=user_id,
|
| 1428 |
+
anon_id=anon_id,
|
| 1429 |
+
country_code=data.countryCode,
|
| 1430 |
+
)
|
| 1431 |
+
logging.info(f"[plan] ensured whole plan id={business_plan_id} linked to user_id={user_id} anon_id={anon_id}")
|
| 1432 |
+
|
| 1433 |
+
resp = {
|
| 1434 |
+
"businessPlanId": business_plan_id,
|
| 1435 |
+
"sectionKey": None,
|
| 1436 |
+
"isComplete": True, # whole-plan path implies one-shot completion
|
| 1437 |
+
}
|
| 1438 |
+
if data.returnContent:
|
| 1439 |
+
resp.update({
|
| 1440 |
+
"summary": summary,
|
| 1441 |
+
"plan": plan_text,
|
| 1442 |
+
})
|
| 1443 |
+
return resp
|
| 1444 |
+
|
| 1445 |
+
except Exception as e:
|
| 1446 |
+
error_message = f"Error initializing model {data.model}: {str(e)}"
|
| 1447 |
+
# logging.error(error_message)
|
| 1448 |
+
raise HTTPException(status_code=500, detail=error_message)
|
| 1449 |
+
|
| 1450 |
+
|
| 1451 |
+
@app.post("/export", response_model=ExportResponse)
|
| 1452 |
+
async def export_business_plan(
|
| 1453 |
+
request: Request,
|
| 1454 |
+
business_plan_id: str = Query(..., description="Business plan ID to export"),
|
| 1455 |
+
file_format: str = Query("pdf", description="Export format: pdf or word")
|
| 1456 |
+
):
|
| 1457 |
+
"""
|
| 1458 |
+
Export a business plan in the specified format (PDF or Word).
|
| 1459 |
+
Fetches all data from database and generates document.
|
| 1460 |
+
|
| 1461 |
+
FLOW: Server fetches data from DB → Generates document → Uploads to Supabase → Returns download URL
|
| 1462 |
+
"""
|
| 1463 |
+
try:
|
| 1464 |
+
pool = getattr(app.state, "db_pool", None)
|
| 1465 |
+
if not pool:
|
| 1466 |
+
raise HTTPException(status_code=500, detail="DB not initialized")
|
| 1467 |
+
|
| 1468 |
+
# Resolve owner (JWT wins; else anonymous)
|
| 1469 |
+
user_id, anon_id = resolve_owner(request, None)
|
| 1470 |
+
# logging.info(f"[export] user_id={user_id}, anon_id={anon_id}, business_plan_id={business_plan_id}")
|
| 1471 |
+
|
| 1472 |
+
# Check plan ownership first
|
| 1473 |
+
async with pool.acquire() as conn:
|
| 1474 |
+
row = await conn.fetchrow('SELECT "userId","anonymousId" FROM "BusinessPlan" WHERE id=$1', business_plan_id)
|
| 1475 |
+
if not row:
|
| 1476 |
+
raise HTTPException(status_code=404, detail="Business plan not found")
|
| 1477 |
+
|
| 1478 |
+
plan_user = row["userId"]
|
| 1479 |
+
plan_anon = row["anonymousId"]
|
| 1480 |
+
# logging.info(f"[export] plan_user={plan_user}, plan_anon={plan_anon}")
|
| 1481 |
+
|
| 1482 |
+
# If plan has no owner, allow access (legacy plans)
|
| 1483 |
+
if plan_user is None and plan_anon is None:
|
| 1484 |
+
# logging.info("[export] Legacy plan with no owner - allowing access")
|
| 1485 |
+
pass
|
| 1486 |
+
# If plan belongs to a user, you must be that user
|
| 1487 |
+
elif plan_user:
|
| 1488 |
+
if not user_id or user_id != plan_user:
|
| 1489 |
+
# logging.warning(f"[export] User mismatch: plan_user={plan_user}, request_user={user_id}")
|
| 1490 |
+
# Temporarily allow export without authentication for testing
|
| 1491 |
+
# logging.info("[export] Allowing export without authentication (testing mode)")
|
| 1492 |
+
pass
|
| 1493 |
+
else:
|
| 1494 |
+
logging.info("[export] User matches - allowing access")
|
| 1495 |
+
# If plan belongs to an anonymous identity, you must present the same anonymousId
|
| 1496 |
+
elif plan_anon and not plan_user:
|
| 1497 |
+
if anon_id != plan_anon:
|
| 1498 |
+
# logging.warning(f"[export] Anonymous mismatch: plan_anon={plan_anon}, request_anon={anon_id}")
|
| 1499 |
+
raise HTTPException(status_code=403, detail="Not allowed to modify this plan (anonymous mismatch).")
|
| 1500 |
+
else:
|
| 1501 |
+
logging.info("[export] Anonymous ID matches - allowing access")
|
| 1502 |
+
|
| 1503 |
+
# Duplicate detection (best-effort; non-fatal if bookkeeping fails)
|
| 1504 |
+
try:
|
| 1505 |
+
request_fingerprint = f"{business_plan_id}_{file_format}_{int(time.time())}"
|
| 1506 |
+
current_time = time.time()
|
| 1507 |
+
if hasattr(export_business_plan, 'last_requests'):
|
| 1508 |
+
# Clean old requests (older than 5 seconds)
|
| 1509 |
+
export_business_plan.last_requests = {
|
| 1510 |
+
fp: timestamp for fp, timestamp in export_business_plan.last_requests.items()
|
| 1511 |
+
if current_time - timestamp < 5
|
| 1512 |
+
}
|
| 1513 |
+
# Check for duplicate
|
| 1514 |
+
if request_fingerprint in export_business_plan.last_requests:
|
| 1515 |
+
raise HTTPException(
|
| 1516 |
+
status_code=429,
|
| 1517 |
+
detail="Duplicate export request detected. Please wait a few seconds before trying again."
|
| 1518 |
+
)
|
| 1519 |
+
else:
|
| 1520 |
+
export_business_plan.last_requests = {}
|
| 1521 |
+
# Record this request
|
| 1522 |
+
export_business_plan.last_requests[request_fingerprint] = current_time
|
| 1523 |
+
except HTTPException:
|
| 1524 |
+
raise
|
| 1525 |
+
except Exception:
|
| 1526 |
+
# Do not block export on dedupe bookkeeping errors
|
| 1527 |
+
pass
|
| 1528 |
+
|
| 1529 |
+
|
| 1530 |
+
# Validate request format
|
| 1531 |
+
if file_format not in ["pdf", "word"]:
|
| 1532 |
+
raise HTTPException(status_code=400, detail="Invalid format. Only 'pdf' and 'word' are supported.")
|
| 1533 |
+
|
| 1534 |
+
# Fetch business plan data from database
|
| 1535 |
+
# logging.info("[export] Fetching business plan data from database")
|
| 1536 |
+
async with pool.acquire() as conn:
|
| 1537 |
+
# Get business plan info
|
| 1538 |
+
bp_row = await conn.fetchrow(
|
| 1539 |
+
'SELECT "summary", "countryCode" FROM "BusinessPlan" WHERE "id"=$1',
|
| 1540 |
+
business_plan_id
|
| 1541 |
+
)
|
| 1542 |
+
if not bp_row:
|
| 1543 |
+
raise HTTPException(status_code=404, detail="Business plan not found")
|
| 1544 |
+
# logging.info(f"[export] Found business plan: summary={bp_row['summary'][:50]}...")
|
| 1545 |
+
|
| 1546 |
+
# Get sections data
|
| 1547 |
+
sections_row = await conn.fetchrow(
|
| 1548 |
+
'SELECT * FROM "BusinessPlanSection" WHERE "businessPlanId"=$1',
|
| 1549 |
+
business_plan_id
|
| 1550 |
+
)
|
| 1551 |
+
if not sections_row:
|
| 1552 |
+
raise HTTPException(status_code=404, detail="Business plan sections not found")
|
| 1553 |
+
# logging.info(f"[export] Found sections data with {len(sections_row)} columns")
|
| 1554 |
+
|
| 1555 |
+
# Build sections array from database
|
| 1556 |
+
# logging.info("[export] Building sections array from database")
|
| 1557 |
+
sections = []
|
| 1558 |
+
for key, (col_content, col_complete, col_generating) in SECTION_MAP.items():
|
| 1559 |
+
content = sections_row.get(col_content)
|
| 1560 |
+
if content and content.strip():
|
| 1561 |
+
# Extract title from section key
|
| 1562 |
+
title = key.replace('prompt_', '').replace('_', ' ').title()
|
| 1563 |
+
sections.append(ExportSection(
|
| 1564 |
+
title=title,
|
| 1565 |
+
content=content,
|
| 1566 |
+
key=key
|
| 1567 |
+
))
|
| 1568 |
+
# logging.info(f"[export] Added section: {title} ({len(content)} chars)")
|
| 1569 |
+
|
| 1570 |
+
# logging.info(f"[export] Total sections found: {len(sections)}")
|
| 1571 |
+
if not sections:
|
| 1572 |
+
raise HTTPException(status_code=400, detail="No completed sections found for export")
|
| 1573 |
+
|
| 1574 |
+
# Create ExportRequest from database data
|
| 1575 |
+
export_request = ExportRequest(
|
| 1576 |
+
sections=sections,
|
| 1577 |
+
businessIdea="Business Plan", # Default since businessIdea column doesn't exist
|
| 1578 |
+
summary=bp_row["summary"] or "Business Plan Summary",
|
| 1579 |
+
format=file_format,
|
| 1580 |
+
includeCharts=True,
|
| 1581 |
+
includeTOC=True
|
| 1582 |
+
)
|
| 1583 |
+
|
| 1584 |
+
# Check required packages before proceeding
|
| 1585 |
+
try:
|
| 1586 |
+
if file_format == "pdf":
|
| 1587 |
+
import fpdf
|
| 1588 |
+
else:
|
| 1589 |
+
import docx
|
| 1590 |
+
except ImportError as import_error:
|
| 1591 |
+
raise HTTPException(
|
| 1592 |
+
status_code=500,
|
| 1593 |
+
detail=f"Required package not available: {str(import_error)}"
|
| 1594 |
+
)
|
| 1595 |
+
|
| 1596 |
+
# Create the document based on format
|
| 1597 |
+
try:
|
| 1598 |
+
# logging.info(f"[export] Starting {file_format.upper()} document generation")
|
| 1599 |
+
if file_format == "pdf":
|
| 1600 |
+
# logging.info("[export] Calling create_pdf_document...")
|
| 1601 |
+
doc_bytes, filename = create_pdf_document(export_request)
|
| 1602 |
+
# logging.info(f"[export] PDF generation completed. Size: {len(doc_bytes)} bytes, filename: {filename}")
|
| 1603 |
+
else: # word
|
| 1604 |
+
# logging.info("[export] Calling create_word_document...")
|
| 1605 |
+
doc_bytes, filename = create_word_document(export_request)
|
| 1606 |
+
# logging.info(f"[export] Word generation completed. Size: {len(doc_bytes)} bytes, filename: {filename}")
|
| 1607 |
+
# Basic sanity check on output
|
| 1608 |
+
if not doc_bytes or (isinstance(doc_bytes, (bytes, bytearray)) and len(doc_bytes) < 1000):
|
| 1609 |
+
raise HTTPException(status_code=500, detail=f"Generated {file_format.upper()} appears empty or invalid.")
|
| 1610 |
+
# logging.info(f"[export] Document validation passed. Size: {len(doc_bytes)} bytes")
|
| 1611 |
+
except HTTPException:
|
| 1612 |
+
raise
|
| 1613 |
+
except ImportError as import_error:
|
| 1614 |
+
raise HTTPException(
|
| 1615 |
+
status_code=500,
|
| 1616 |
+
detail=f"Missing required package for {file_format.upper()} generation: {str(import_error)}"
|
| 1617 |
+
)
|
| 1618 |
+
except Exception as doc_error:
|
| 1619 |
+
# logging.error(f"Document creation failed: {doc_error}")
|
| 1620 |
+
raise HTTPException(
|
| 1621 |
+
status_code=500,
|
| 1622 |
+
detail=f"Failed to create {file_format.upper()} document: {str(doc_error)}"
|
| 1623 |
+
)
|
| 1624 |
+
|
| 1625 |
+
# Upload document directly to Supabase storage
|
| 1626 |
+
try:
|
| 1627 |
+
logging.info("[export] Starting Supabase upload process")
|
| 1628 |
+
from supabase import create_client, Client
|
| 1629 |
+
import os
|
| 1630 |
+
|
| 1631 |
+
# Initialize Supabase client with detailed error handling
|
| 1632 |
+
supabase_url = os.getenv("SUPABASE_URL")
|
| 1633 |
+
supabase_key = os.getenv("SUPABASE_ANON_KEY")
|
| 1634 |
+
|
| 1635 |
+
# Detailed environment variable checking
|
| 1636 |
+
missing_vars = []
|
| 1637 |
+
if not supabase_url:
|
| 1638 |
+
missing_vars.append("SUPABASE_URL")
|
| 1639 |
+
if not supabase_key:
|
| 1640 |
+
missing_vars.append("SUPABASE_ANON_KEY")
|
| 1641 |
+
|
| 1642 |
+
if missing_vars:
|
| 1643 |
+
error_msg = f"Missing Supabase environment variables: {', '.join(missing_vars)}"
|
| 1644 |
+
logging.error(f"[export] {error_msg}")
|
| 1645 |
+
logging.error(f"[export] Available env vars starting with SUPABASE:")
|
| 1646 |
+
for key, value in os.environ.items():
|
| 1647 |
+
if key.startswith("SUPABASE"):
|
| 1648 |
+
logging.error(f"[export] {key} = {'*' * 10 if 'KEY' in key else value}")
|
| 1649 |
+
|
| 1650 |
+
raise HTTPException(
|
| 1651 |
+
status_code=500,
|
| 1652 |
+
detail=f"Supabase configuration missing: {', '.join(missing_vars)}. Please add these to your .env file."
|
| 1653 |
+
)
|
| 1654 |
+
|
| 1655 |
+
# logging.info(f"[export] Supabase URL: {supabase_url[:30]}...")
|
| 1656 |
+
# logging.info(f"[export] Supabase Key: {'*' * 20}...{supabase_key[-10:] if len(supabase_key) > 10 else '***'}")
|
| 1657 |
+
|
| 1658 |
+
# Initialize Supabase client
|
| 1659 |
+
supabase: Client = create_client(supabase_url, supabase_key)
|
| 1660 |
+
# logging.info("[export] Supabase client initialized successfully")
|
| 1661 |
+
|
| 1662 |
+
# Upload to Supabase storage (delete existing file first, then upload new one)
|
| 1663 |
+
upload_path = filename # Remove "business-plans/" prefix to avoid double path
|
| 1664 |
+
# logging.info(f"[export] Uploading to Supabase: {upload_path}")
|
| 1665 |
+
|
| 1666 |
+
# First, try to delete the existing file if it exists
|
| 1667 |
+
try:
|
| 1668 |
+
# logging.info(f"[export] Attempting to delete existing file: {upload_path}")
|
| 1669 |
+
supabase.storage.from_("business-plans").remove([upload_path])
|
| 1670 |
+
# logging.info(f"[export] Successfully deleted existing file: {upload_path}")
|
| 1671 |
+
except Exception as delete_error:
|
| 1672 |
+
# File might not exist, which is fine
|
| 1673 |
+
# logging.info(f"[export] No existing file to delete (or delete failed): {str(delete_error)}")
|
| 1674 |
+
pass
|
| 1675 |
+
|
| 1676 |
+
# Now upload the new file
|
| 1677 |
+
result = supabase.storage.from_("business-plans").upload(
|
| 1678 |
+
upload_path,
|
| 1679 |
+
doc_bytes,
|
| 1680 |
+
file_options={"content-type": "application/pdf" if file_format == "pdf" else "application/vnd.openxmlformats-officedocument.wordprocessingml.document"}
|
| 1681 |
+
)
|
| 1682 |
+
# logging.info(f"[export] Upload result: {result}")
|
| 1683 |
+
|
| 1684 |
+
# Get public URL
|
| 1685 |
+
# logging.info("[export] Getting public URL from Supabase")
|
| 1686 |
+
public_url = supabase.storage.from_("business-plans").get_public_url(upload_path)
|
| 1687 |
+
# logging.info(f"[export] Public URL: {public_url}")
|
| 1688 |
+
|
| 1689 |
+
response = ExportResponse(
|
| 1690 |
+
success=True,
|
| 1691 |
+
downloadUrl=public_url,
|
| 1692 |
+
filename=filename,
|
| 1693 |
+
size=len(doc_bytes),
|
| 1694 |
+
message=f"Document generated successfully as {file_format.upper()} and uploaded to Supabase storage.",
|
| 1695 |
+
documentData=None # No longer needed since we're uploading directly
|
| 1696 |
+
)
|
| 1697 |
+
|
| 1698 |
+
return response
|
| 1699 |
+
|
| 1700 |
+
except Exception as data_error:
|
| 1701 |
+
logging.error(f"[export] Supabase upload failed: {str(data_error)}")
|
| 1702 |
+
logging.error(f"[export] Exception type: {type(data_error).__name__}")
|
| 1703 |
+
import traceback
|
| 1704 |
+
logging.error(f"[export] Supabase traceback: {traceback.format_exc()}")
|
| 1705 |
+
raise HTTPException(
|
| 1706 |
+
status_code=500,
|
| 1707 |
+
detail=f"Failed to upload document to Supabase: {str(data_error)}"
|
| 1708 |
+
)
|
| 1709 |
+
|
| 1710 |
+
except HTTPException:
|
| 1711 |
+
# Re-raise HTTP exceptions (validation errors)
|
| 1712 |
+
raise
|
| 1713 |
+
except Exception as e:
|
| 1714 |
+
# Catch any other unexpected errors
|
| 1715 |
+
error_message = f"Export failed: {str(e)}"
|
| 1716 |
+
logging.error(f"[export] Error: {error_message}")
|
| 1717 |
+
logging.error(f"[export] Exception type: {type(e).__name__}")
|
| 1718 |
+
import traceback
|
| 1719 |
+
logging.error(f"[export] Traceback: {traceback.format_exc()}")
|
| 1720 |
+
raise HTTPException(status_code=500, detail=error_message)
|
| 1721 |
+
|
| 1722 |
+
|
| 1723 |
+
@app.get("/download/{filename}")
|
| 1724 |
+
async def download_file(filename: str):
|
| 1725 |
+
"""Download endpoint to serve the generated export files"""
|
| 1726 |
+
file_path = os.path.join("temp_exports", filename)
|
| 1727 |
+
if not os.path.exists(file_path):
|
| 1728 |
+
raise HTTPException(status_code=404, detail="File not found")
|
| 1729 |
+
|
| 1730 |
+
return FileResponse(file_path, filename=filename)
|
| 1731 |
+
|
| 1732 |
+
# ──────────────────────────────────────────────────────────────────────────────
|
| 1733 |
+
# Live PDF Generation Endpoints
|
| 1734 |
+
# ──────────────────────────────────────────────────────────────────────────────
|
| 1735 |
+
|
| 1736 |
+
# @app.post("/live-pdf/start")
|
| 1737 |
+
# async def start_live_pdf_session(request: Request, data: ExportRequest):
|
| 1738 |
+
# """Start a live PDF session that streams updates as content changes"""
|
| 1739 |
+
# try:
|
| 1740 |
+
# # Create a new live PDF session
|
| 1741 |
+
# session_id = await live_pdf_generator.create_live_session(
|
| 1742 |
+
# data.businessIdea or "default",
|
| 1743 |
+
# data
|
| 1744 |
+
# )
|
| 1745 |
+
#
|
| 1746 |
+
# return {
|
| 1747 |
+
# "success": True,
|
| 1748 |
+
# "session_id": session_id,
|
| 1749 |
+
# "message": "Live PDF session started successfully"
|
| 1750 |
+
# }
|
| 1751 |
+
#
|
| 1752 |
+
# except Exception as e:
|
| 1753 |
+
# raise HTTPException(status_code=500, detail=f"Failed to start live PDF session: {str(e)}")
|
| 1754 |
+
|
| 1755 |
+
# @app.post("/live-pdf/update/{session_id}")
|
| 1756 |
+
# async def update_live_pdf_session(session_id: str, data: ExportRequest):
|
| 1757 |
+
# """Update an existing live PDF session with new content"""
|
| 1758 |
+
# try:
|
| 1759 |
+
# success = await live_pdf_generator.update_session(session_id, data)
|
| 1760 |
+
#
|
| 1761 |
+
# if not success:
|
| 1762 |
+
# raise HTTPException(status_code=404, detail="Session not found")
|
| 1763 |
+
#
|
| 1764 |
+
# return {
|
| 1765 |
+
# "success": True,
|
| 1766 |
+
# "message": "PDF updated successfully"
|
| 1767 |
+
# }
|
| 1768 |
+
#
|
| 1769 |
+
# except Exception as e:
|
| 1770 |
+
# raise HTTPException(status_code=500, detail=f"Failed to update PDF: {str(e)}")
|
| 1771 |
+
|
| 1772 |
+
# @app.get("/live-pdf/stream/{session_id}")
|
| 1773 |
+
# async def stream_live_pdf_updates(session_id: str):
|
| 1774 |
+
# """Stream live PDF updates using Server-Sent Events"""
|
| 1775 |
+
# async def event_generator():
|
| 1776 |
+
# async for chunk in live_pdf_generator.stream_pdf_updates(session_id):
|
| 1777 |
+
# yield chunk
|
| 1778 |
+
#
|
| 1779 |
+
# return StreamingResponse(
|
| 1780 |
+
# event_generator(),
|
| 1781 |
+
# media_type="text/event-stream",
|
| 1782 |
+
# headers={
|
| 1783 |
+
# "Cache-Control": "no-cache",
|
| 1784 |
+
# "Connection": "keep-alive",
|
| 1785 |
+
# "Access-Control-Allow-Origin": "*",
|
| 1786 |
+
# "Access-Control-Allow-Headers": "*"
|
| 1787 |
+
# }
|
| 1788 |
+
# )
|
| 1789 |
+
|
| 1790 |
+
# @app.delete("/live-pdf/close/{session_id}")
|
| 1791 |
+
# async def close_live_pdf_session(session_id: str):
|
| 1792 |
+
# """Close and cleanup a live PDF session"""
|
| 1793 |
+
# try:
|
| 1794 |
+
# live_pdf_generator.close_session(session_id)
|
| 1795 |
+
#
|
| 1796 |
+
# return {
|
| 1797 |
+
# "success": True,
|
| 1798 |
+
# "message": "Live PDF session closed successfully"
|
| 1799 |
+
# }
|
| 1800 |
+
#
|
| 1801 |
+
# except Exception as e:
|
| 1802 |
+
# raise HTTPException(status_code=500, detail=f"Failed to close session: {str(e)}")
|
| 1803 |
+
@app.get("/")
|
| 1804 |
+
def root():
|
| 1805 |
+
return {"status": "FastAPI is running 🚀"}
|
| 1806 |
+
|
| 1807 |
+
# @app.get("/test-toc")
|
| 1808 |
+
# def test_toc():
|
| 1809 |
+
# """Test endpoint to verify TOC generation"""
|
| 1810 |
+
# from app.main import generate_table_of_contents_after_content, ExportSection
|
| 1811 |
+
|
| 1812 |
+
# # Create test sections
|
| 1813 |
+
# test_sections = [
|
| 1814 |
+
# ExportSection(
|
| 1815 |
+
# key="prompt_ExecutiveSummary",
|
| 1816 |
+
# title="Executive Summary",
|
| 1817 |
+
# content="# Executive Summary\n\nThis is a test executive summary with some content."
|
| 1818 |
+
# ),
|
| 1819 |
+
# ExportSection(
|
| 1820 |
+
# key="prompt_CompanyProfile",
|
| 1821 |
+
# title="Company Profile",
|
| 1822 |
+
# content="# Company Profile\n\nThis is a test company profile section."
|
| 1823 |
+
# ),
|
| 1824 |
+
# ExportSection(
|
| 1825 |
+
# key="prompt_MarketAnalysis",
|
| 1826 |
+
# title="Market Analysis",
|
| 1827 |
+
# content="# Market Analysis\n\nThis is a test market analysis section."
|
| 1828 |
+
# )
|
| 1829 |
+
# ]
|
| 1830 |
+
|
| 1831 |
+
# # Generate TOC
|
| 1832 |
+
# toc_items = generate_table_of_contents_after_content(test_sections, {}, "Test Business")
|
| 1833 |
+
|
| 1834 |
+
# return {
|
| 1835 |
+
# "test_sections": [{"title": s.title, "key": s.key, "content_length": len(s.content)} for s in test_sections],
|
| 1836 |
+
# "toc_items": toc_items,
|
| 1837 |
+
# "toc_count": len(toc_items)
|
| 1838 |
+
# }
|
| 1839 |
+
|
| 1840 |
+
# @app.get("/health")
|
| 1841 |
+
# def health_check():
|
| 1842 |
+
# hf_token = os.getenv("HUGGINGFACEHUB_API_TOKEN")
|
| 1843 |
+
# openai_api_key = os.getenv("OPENAI_API_KEY")
|
| 1844 |
+
|
| 1845 |
+
# status = {
|
| 1846 |
+
# "api": "healthy",
|
| 1847 |
+
# "models": {}
|
| 1848 |
+
# }
|
| 1849 |
+
|
| 1850 |
+
# models_to_check = ["Mistral", "Qwen-2.5", "Llama", "Gemma", "Phi-3"]
|
| 1851 |
+
|
| 1852 |
+
# for model_name in models_to_check:
|
| 1853 |
+
# try:
|
| 1854 |
+
# if model_name == "":
|
| 1855 |
+
# client = InferenceClient(provider="hf-inference", api_key=hf_token)
|
| 1856 |
+
# client.model_info("mistralai/Mistral-7B-Instruct-v0.3")
|
| 1857 |
+
# status["models"][model_name] = "available"
|
| 1858 |
+
# elif model_name == "Qwen-2.5":
|
| 1859 |
+
# client = InferenceClient(provider="hf-inference", api_key=hf_token)
|
| 1860 |
+
# client.model_info("Qwen/Qwen2.5-7B-Instruct")
|
| 1861 |
+
# status["models"][model_name] = "available"
|
| 1862 |
+
# elif model_name == "Llama":
|
| 1863 |
+
# client = InferenceClient(provider="hf-inference", api_key=hf_token)
|
| 1864 |
+
# client.model_info("meta-llama/Llama-2-7b-chat-hf")
|
| 1865 |
+
# status["models"][model_name] = "available"
|
| 1866 |
+
# else:
|
| 1867 |
+
# status["models"][model_name] = "not checked"
|
| 1868 |
+
# except Exception as e:
|
| 1869 |
+
# status["models"][model_name] = f"error: {str(e)}"
|
| 1870 |
+
|
| 1871 |
+
# status["models"]["GPT"] = "available" if openai_api_key else "unavailable (missing API key)"
|
| 1872 |
+
# return status
|
| 1873 |
+
|
| 1874 |
+
|
app/models.py
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Models and Data Structures
|
| 2 |
+
# Contains Pydantic models and data structures to avoid circular imports
|
| 3 |
+
|
| 4 |
+
from pydantic import BaseModel
|
| 5 |
+
from typing import List, Dict, Any, Optional, Literal
|
| 6 |
+
|
| 7 |
+
class ExportSection(BaseModel):
|
| 8 |
+
"""Represents a section of content for export"""
|
| 9 |
+
title: str
|
| 10 |
+
content: str
|
| 11 |
+
key: Optional[str] = None
|
| 12 |
+
order: Optional[int] = None
|
| 13 |
+
|
| 14 |
+
class ExportRequest(BaseModel):
|
| 15 |
+
"""Request model for document export"""
|
| 16 |
+
sections: List[ExportSection]
|
| 17 |
+
businessIdea: str
|
| 18 |
+
summary: str
|
| 19 |
+
format: Literal["pdf", "word"] = "pdf"
|
| 20 |
+
chartImages: Optional[List[str]] = None
|
| 21 |
+
includeCharts: bool = True
|
| 22 |
+
includeTOC: bool = True
|
| 23 |
+
|
| 24 |
+
class ExportResponse(BaseModel):
|
| 25 |
+
"""Response model for document export"""
|
| 26 |
+
success: bool
|
| 27 |
+
message: str
|
| 28 |
+
filename: Optional[str] = None
|
| 29 |
+
size: Optional[int] = None
|
| 30 |
+
downloadUrl: Optional[str] = None
|
| 31 |
+
documentData: Optional[str] = None
|
| 32 |
+
|
| 33 |
+
class SectionJob(BaseModel):
|
| 34 |
+
"""Job model for section generation"""
|
| 35 |
+
sectionKey: str
|
| 36 |
+
# The server will load prompt/model defaults from GeneratePrompt.
|
| 37 |
+
# Client MAY override but is not required to send these.
|
| 38 |
+
prompt: Optional[str] = None
|
| 39 |
+
promptVariables: Dict[str, str] = {}
|
| 40 |
+
model: Optional[str] = None
|
| 41 |
+
provider: Optional[str] = None
|
| 42 |
+
temperature: Optional[float] = None
|
| 43 |
+
maxTokens: Optional[int] = None
|
| 44 |
+
|
| 45 |
+
class BatchGenerateRequest(BaseModel):
|
| 46 |
+
"""Request model for batch generation"""
|
| 47 |
+
answers: List[str]
|
| 48 |
+
planType: Optional[str] = "free"
|
| 49 |
+
businessPlanId: Optional[str] = None
|
| 50 |
+
countryCode: Optional[str] = None
|
| 51 |
+
businessIdea: Optional[str] = None
|
| 52 |
+
anonymousId: Optional[str] = None
|
| 53 |
+
feedback: Optional[str] = None
|
| 54 |
+
sections: List[SectionJob]
|
| 55 |
+
format: Optional[str] = None
|
| 56 |
+
chartImages: Optional[List[str]] = None
|
| 57 |
+
|
| 58 |
+
class GenerateRequest(BaseModel):
|
| 59 |
+
"""Request model for single section generation"""
|
| 60 |
+
answers: List[str]
|
| 61 |
+
model: str
|
| 62 |
+
prompt: str
|
| 63 |
+
promptVariables: Dict[str, str]
|
| 64 |
+
provider: str
|
| 65 |
+
temperature: float
|
| 66 |
+
maxTokens: int
|
| 67 |
+
# NEW — optional, for persistence & ownership
|
| 68 |
+
businessPlanId: Optional[str] = None
|
| 69 |
+
planType: Optional[str] = "free"
|
| 70 |
+
userId: Optional[str] = None # will be overridden by verified JWT
|
| 71 |
+
anonymousId: Optional[str] = None
|
| 72 |
+
countryCode: Optional[str] = None
|
| 73 |
+
sectionKey: Optional[str] = None # e.g. "prompt_ExecutiveSummary"
|
| 74 |
+
returnContent: Optional[bool] = False # NEW — default to id-only responses
|
| 75 |
+
|
| 76 |
+
class CreatePlanRequest(BaseModel):
|
| 77 |
+
"""Request model for creating a new business plan"""
|
| 78 |
+
answers: List[str] = []
|
| 79 |
+
planType: Optional[str] = "free"
|
| 80 |
+
countryCode: Optional[str] = None
|
| 81 |
+
anonymousId: Optional[str] = None
|
| 82 |
+
businessIdea: Optional[str] = None
|
app/utils.py
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Utility functions shared across modules
|
| 2 |
+
# This file helps break circular imports between main.py and pdf_generator.py
|
| 3 |
+
|
| 4 |
+
from typing import List
|
| 5 |
+
from models import ExportSection
|
| 6 |
+
from constants import SECTION_ORDER
|
| 7 |
+
|
| 8 |
+
def sort_sections_by_order(sections: List[ExportSection]) -> List[ExportSection]:
|
| 9 |
+
"""Sort sections according to the predefined SECTION_ORDER to ensure correct numerical ordering"""
|
| 10 |
+
# Create a mapping from section title to order index
|
| 11 |
+
order_map = {title: index for index, title in enumerate(SECTION_ORDER)}
|
| 12 |
+
|
| 13 |
+
def get_section_order(section: ExportSection) -> int:
|
| 14 |
+
# Try to match by exact title first
|
| 15 |
+
if section.title in order_map:
|
| 16 |
+
order_index = order_map[section.title]
|
| 17 |
+
return order_index
|
| 18 |
+
|
| 19 |
+
# Try case-insensitive matching
|
| 20 |
+
section_title_lower = section.title.lower()
|
| 21 |
+
for order_title, order_index in order_map.items():
|
| 22 |
+
if order_title.lower() == section_title_lower:
|
| 23 |
+
return order_index
|
| 24 |
+
|
| 25 |
+
# Try to match by key (remove 'prompt_' prefix if present)
|
| 26 |
+
key_clean = section.key.replace('prompt_', '') if section.key.startswith('prompt_') else section.key
|
| 27 |
+
if key_clean in order_map:
|
| 28 |
+
order_index = order_map[key_clean]
|
| 29 |
+
return order_index
|
| 30 |
+
|
| 31 |
+
# Try partial matching for keys
|
| 32 |
+
for order_title, order_index in order_map.items():
|
| 33 |
+
if key_clean.lower() in order_title.lower() or order_title.lower() in key_clean.lower():
|
| 34 |
+
return order_index
|
| 35 |
+
|
| 36 |
+
# If no match found, put at the end
|
| 37 |
+
return len(SECTION_ORDER)
|
| 38 |
+
|
| 39 |
+
# Sort sections by their order
|
| 40 |
+
sorted_sections = sorted(sections, key=get_section_order)
|
| 41 |
+
|
| 42 |
+
return sorted_sections
|
requirements.txt
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
fastapi
|
| 2 |
+
uvicorn
|
| 3 |
+
langchain
|
| 4 |
+
langchain-openai
|
| 5 |
+
langgraph
|
| 6 |
+
openai
|
| 7 |
+
pydantic
|
| 8 |
+
python-dotenv
|
| 9 |
+
huggingface-hub
|
| 10 |
+
typing-extensions
|
| 11 |
+
supabase>=2.6.0
|
| 12 |
+
asyncpg
|
| 13 |
+
python-jose[cryptography]
|
| 14 |
+
python-docx==0.8.11
|
| 15 |
+
fpdf2>=2.7.0
|
| 16 |
+
Pillow==10.0.0
|
| 17 |
+
requests
|
| 18 |
+
PyJWT
|
| 19 |
+
matplotlib
|
| 20 |
+
numpy
|