Zenaight commited on
Commit
2d2c483
·
verified ·
1 Parent(s): f62c294

Upload 24 files

Browse files
.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