devZenaight commited on
Commit
8126b08
·
1 Parent(s): b8737d9

Test Planify V1

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
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"]
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,304 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
+ else:
231
+ # Content before any subheading - create a default group
232
+ if not groups or groups[-1].get('subheading') != '## Introduction':
233
+ cleaned_line = clean_markdown_from_line(line)
234
+ current_group = {
235
+ 'subheading': '## Introduction',
236
+ 'content_lines': [cleaned_line],
237
+ 'content': '',
238
+ 'subheading_clean': 'Introduction',
239
+ 'subheading_type': 'h2'
240
+ }
241
+
242
+ # Don't forget the last group
243
+ if current_group:
244
+ current_group['content'] = '\n'.join(current_group['content_lines'])
245
+ groups.append(current_group)
246
+ print(f"DEBUG: Saved final group '{current_group.get('subheading_clean', 'Unknown')[:30]}...' with {len(current_group['content_lines'])} content lines")
247
+
248
+ # Add metadata to each group
249
+ for i, group in enumerate(groups):
250
+ group['group_id'] = f"group_{i}"
251
+ group['content_length'] = len(group['content'])
252
+ group['word_count'] = len(group['content'].split()) if group['content'] else 0
253
+ group['has_charts'] = 'CHART' in group['content'].upper() if group['content'] else False
254
+ group['line_count'] = len([line for line in group['content'].split('\n') if line.strip()])
255
+
256
+ print(f"DEBUG: Created {len(groups)} groups total")
257
+ return groups
258
+
259
+ def extract_key_phrases(content: str) -> List[tuple]:
260
+ """Extract key phrases from content for grouping metadata"""
261
+ if not content:
262
+ return []
263
+
264
+ # Simple key phrase extraction
265
+ words = re.findall(r'\b\w+\b', content.lower())
266
+ word_freq = {}
267
+
268
+ for word in words:
269
+ if len(word) > 3: # Only words longer than 3 characters
270
+ word_freq[word] = word_freq.get(word, 0) + 1
271
+
272
+ # Return top 5 most frequent words
273
+ return sorted(word_freq.items(), key=lambda x: x[1], reverse=True)[:5]
274
+
275
+ def get_content_groups_info(content: str) -> Dict[str, Any]:
276
+ """Get detailed information about content groups for analysis and debugging"""
277
+ if not content:
278
+ return {"groups": [], "summary": {"total_groups": 0, "total_content_length": 0}}
279
+
280
+ groups = parse_content_into_groups(content)
281
+
282
+ # Add key phrases to each group
283
+ for group in groups:
284
+ group['key_phrases'] = extract_key_phrases(group['content'])
285
+
286
+ # Calculate summary statistics
287
+ total_content_length = sum(group['content_length'] for group in groups)
288
+ total_word_count = sum(group['word_count'] for group in groups)
289
+ groups_with_charts = sum(1 for group in groups if group['has_charts'])
290
+
291
+ summary = {
292
+ "total_groups": len(groups),
293
+ "total_content_length": total_content_length,
294
+ "total_word_count": total_word_count,
295
+ "groups_with_charts": groups_with_charts,
296
+ "average_group_size": total_content_length / len(groups) if groups else 0,
297
+ "largest_group": max(groups, key=lambda g: g['content_length'])['subheading_clean'] if groups else None,
298
+ "smallest_group": min(groups, key=lambda g: g['content_length'])['subheading_clean'] if groups else None
299
+ }
300
+
301
+ return {
302
+ "groups": groups,
303
+ "summary": summary
304
+ }
app/DocumentGeneration/live_pdf_generator.py ADDED
@@ -0,0 +1,126 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
+ from fpdf import FPDF
10
+ import base64
11
+
12
+ import sys
13
+ import os
14
+ sys.path.append(os.path.dirname(__file__))
15
+ from pdf_generator import create_pdf_document
16
+ from models import ExportRequest
17
+
18
+ class LivePDFGenerator:
19
+ """Generates and streams PDF updates in real-time"""
20
+
21
+ def __init__(self):
22
+ self.active_sessions: Dict[str, 'PDFSession'] = {}
23
+
24
+ async def create_live_session(self, business_plan_id: str, initial_request: ExportRequest) -> str:
25
+ """Create a new live PDF session"""
26
+ session_id = f"pdf_session_{business_plan_id}_{int(asyncio.get_event_loop().time())}"
27
+
28
+ session = PDFSession(session_id, business_plan_id, initial_request)
29
+ self.active_sessions[session_id] = session
30
+
31
+ # Generate initial PDF
32
+ await session.update_pdf(initial_request)
33
+
34
+ return session_id
35
+
36
+ async def update_session(self, session_id: str, updated_request: ExportRequest) -> bool:
37
+ """Update an existing PDF session with new content"""
38
+ if session_id not in self.active_sessions:
39
+ return False
40
+
41
+ session = self.active_sessions[session_id]
42
+ await session.update_pdf(updated_request)
43
+ return True
44
+
45
+ async def stream_pdf_updates(self, session_id: str) -> AsyncGenerator[bytes, None]:
46
+ """Stream PDF updates as they occur"""
47
+ if session_id not in self.active_sessions:
48
+ return
49
+
50
+ session = self.active_sessions[session_id]
51
+ async for pdf_chunk in session.stream_updates():
52
+ yield pdf_chunk
53
+
54
+ def close_session(self, session_id: str):
55
+ """Close and cleanup a PDF session"""
56
+ if session_id in self.active_sessions:
57
+ del self.active_sessions[session_id]
58
+
59
+ class PDFSession:
60
+ """Individual PDF session that manages real-time updates"""
61
+
62
+ def __init__(self, session_id: str, business_plan_id: str, initial_request: ExportRequest):
63
+ self.session_id = session_id
64
+ self.business_plan_id = business_plan_id
65
+ self.current_request = initial_request
66
+ self.update_queue = asyncio.Queue()
67
+ self.is_active = True
68
+ self.last_pdf_bytes: Optional[bytes] = None
69
+
70
+ async def update_pdf(self, new_request: ExportRequest):
71
+ """Update the PDF with new content"""
72
+ self.current_request = new_request
73
+
74
+ try:
75
+ # Generate new PDF
76
+ pdf_bytes, filename = create_pdf_document(new_request)
77
+ self.last_pdf_bytes = pdf_bytes
78
+
79
+ # Notify subscribers of update
80
+ await self.update_queue.put({
81
+ 'type': 'pdf_update',
82
+ 'data': base64.b64encode(pdf_bytes).decode('utf-8'),
83
+ 'filename': filename,
84
+ 'timestamp': asyncio.get_event_loop().time()
85
+ })
86
+
87
+ except Exception as e:
88
+ # Notify subscribers of error
89
+ await self.update_queue.put({
90
+ 'type': 'pdf_error',
91
+ 'error': str(e),
92
+ 'timestamp': asyncio.get_event_loop().time()
93
+ })
94
+
95
+ async def stream_updates(self) -> AsyncGenerator[bytes, None]:
96
+ """Stream PDF updates as they occur"""
97
+ while self.is_active:
98
+ try:
99
+ # Wait for updates with timeout
100
+ update = await asyncio.wait_for(self.update_queue.get(), timeout=30.0)
101
+
102
+ if update['type'] == 'pdf_update':
103
+ # Send SSE event for PDF update
104
+ event_data = f"event: pdf_update\ndata: {{\"data\": \"{update['data']}\", \"filename\": \"{update['filename']}\"}}\n\n"
105
+ yield event_data.encode('utf-8')
106
+
107
+ elif update['type'] == 'pdf_error':
108
+ # Send SSE event for error
109
+ error_data = f"event: pdf_error\ndata: {{\"error\": \"{update['error']}\"}}\n\n"
110
+ yield error_data.encode('utf-8')
111
+
112
+ except asyncio.TimeoutError:
113
+ # Send keep-alive
114
+ yield b": keep-alive\n\n"
115
+ except Exception as e:
116
+ # Send error event
117
+ error_data = f"event: stream_error\ndata: {str(e)}\n\n"
118
+ yield error_data.encode('utf-8')
119
+ break
120
+
121
+ def close(self):
122
+ """Close this session"""
123
+ self.is_active = False
124
+
125
+ # Global instance
126
+ 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/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/__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/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,1741 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
+ from fastapi import FastAPI, HTTPException, Request
68
+ from fastapi.middleware.cors import CORSMiddleware
69
+ from fastapi import Query
70
+ from fastapi.responses import FileResponse
71
+ from pydantic import BaseModel
72
+ from starlette.responses import StreamingResponse
73
+ from typing import AsyncIterator
74
+ from collections import defaultdict
75
+ from fastapi import Depends, HTTPException, status
76
+ from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
77
+
78
+ import asyncpg
79
+ from jose import jwt, JWTError
80
+
81
+ # LangChain / LLMs
82
+ from langchain_openai import ChatOpenAI
83
+ from langchain.prompts import PromptTemplate
84
+ from langchain.schema.runnable import RunnableSequence
85
+ from langchain.schema import AIMessage
86
+ from langchain.llms.base import LLM
87
+
88
+ from huggingface_hub import InferenceClient
89
+
90
+ # ──────────────────────────────────────────────────────────────────────────────
91
+ # Document Generation Imports
92
+ # ──────────────────────────────────────────────────────────────────────────────
93
+ import sys
94
+ import os
95
+
96
+ # Add the Document Generation and Currency Mapping paths to sys.path
97
+ sys.path.append(os.path.join(os.path.dirname(__file__), 'DocumentGeneration'))
98
+ sys.path.append(os.path.join(os.path.dirname(__file__), 'CurrencyMapping'))
99
+
100
+ # Import models
101
+ from models import ExportRequest, ExportSection, ExportResponse, SectionJob, BatchGenerateRequest, GenerateRequest, CreatePlanRequest
102
+
103
+ # Import constants
104
+ from constants import SECTION_ORDER, SECTION_MAP, SECTION_COLUMNS
105
+
106
+ # Import utilities
107
+ from utils import sort_sections_by_order
108
+
109
+ # Now import the modules
110
+ import sys
111
+ import os
112
+
113
+ # Add the Document Generation and Currency Mapping paths to sys.path
114
+ sys.path.append(os.path.join(os.path.dirname(__file__), 'DocumentGeneration'))
115
+ sys.path.append(os.path.join(os.path.dirname(__file__), 'CurrencyMapping'))
116
+
117
+ from DocumentGeneration.pdf_generator import create_pdf_document
118
+ from DocumentGeneration.word_generator import create_word_document
119
+ from DocumentGeneration.live_pdf_generator import live_pdf_generator
120
+ from DocumentGeneration.chart_generator import parse_embedded_chart_data, create_chart_from_data
121
+ from DocumentGeneration.table_of_content import generate_table_of_contents_after_content, add_table_of_contents_page
122
+ from DocumentGeneration.document_helpers import (
123
+ extract_business_name_from_content,
124
+ filter_empty_sections,
125
+ extract_subheadings,
126
+ remove_duplicate_headings
127
+ )
128
+ from DocumentGeneration.grouped import parse_content_into_groups, extract_key_phrases, get_content_groups_info
129
+ from DocumentGeneration.markdown_renderer import (
130
+ clean_text_for_pdf,
131
+ render_markdown_to_pdf,
132
+ render_markdown_to_pdf_grouped,
133
+ render_markdown_to_docx,
134
+ render_markdown_to_docx_grouped,
135
+ clean_text_for_docx
136
+ )
137
+ from CurrencyMapping.currency_mapping import convert_currency_symbols_to_iso
138
+
139
+ # ──────────────────────────────────────────────────────────────────────────────
140
+ # Logging
141
+ # ──────────────────────────────────────────────────────────────────────────────
142
+ logging.basicConfig(level=logging.INFO)
143
+
144
+ # ──────────────────────────────────────────────────────────────────────────────
145
+ # SSE broker
146
+ # ──────────────────────────────────────────────────────────────────────────────
147
+ # --- In-memory SSE broker keyed by businessPlanId ---
148
+ SUBSCRIBERS: dict[str, set[asyncio.Queue[str]]] = defaultdict(set)
149
+
150
+ def _sse_format(event: str, payload: dict) -> str:
151
+ return f"event: {event}\n" f"data: {json.dumps(payload, ensure_ascii=False)}\n\n"
152
+
153
+ async def _publish(plan_id: str, event: str, payload: dict) -> None:
154
+ msg = _sse_format(event, payload)
155
+ for q in list(SUBSCRIBERS.get(plan_id, set())):
156
+ try:
157
+ q.put_nowait(msg)
158
+ except Exception:
159
+ SUBSCRIBERS[plan_id].discard(q)
160
+
161
+ # ──────────────────────────────────────────────────────────────────────────────
162
+ # Environment checks
163
+ # ──────────────────────────────────────────────────────────────────────────────
164
+ def check_environment_variables():
165
+ hf_token = os.getenv("HUGGINGFACEHUB_API_TOKEN")
166
+ if not hf_token:
167
+ logging.warning("⚠️ HUGGINGFACEHUB_API_TOKEN not set. Hugging Face models will not work properly.")
168
+ else:
169
+ logging.info("✅ HUGGINGFACEHUB_API_TOKEN is set")
170
+
171
+ openai_api_key = os.getenv("OPENAI_API_KEY")
172
+ if not openai_api_key:
173
+ logging.warning("⚠️ OPENAI_API_KEY not set. OpenAI GPT models will not work properly.")
174
+ else:
175
+ logging.info("✅ OPENAI_API_KEY is set")
176
+
177
+ check_environment_variables()
178
+
179
+ logging.info("=" * 80)
180
+ logging.info("MODEL SIZE LIMITATIONS:")
181
+ logging.info("The free tier of Hugging Face Inference API limits models to 10GB.")
182
+ logging.info("Large models like Qwen-2.5-7B (15GB) and Llama-2-7B (13GB) exceed this limit.")
183
+ logging.info("We've configured smaller alternative models as replacements.")
184
+ logging.info("For full-sized models, upgrade to Hugging Face Pro subscription.")
185
+ logging.info("=" * 80)
186
+
187
+ # ──────────────────────────────────────────────────────────────────────────────
188
+ # FastAPI app & CORS
189
+ # ──────────────────────────────────────────────────────────────────────────────
190
+ app = FastAPI()
191
+
192
+ @app.middleware("http")
193
+ async def log_requests(request: Request, call_next):
194
+ start_time = time.time()
195
+ response = await call_next(request)
196
+ process_time = time.time() - start_time
197
+ logging.info(f"Request: {request.method} {request.url.path} - Status: {response.status_code} - Time: {process_time:.2f}s")
198
+ return response
199
+
200
+ app.add_middleware(
201
+ CORSMiddleware,
202
+ allow_origins=["*"],
203
+ allow_credentials=True,
204
+ allow_methods=["*"],
205
+ allow_headers=["*"],
206
+ expose_headers=["*"]
207
+ )
208
+
209
+ # ──────────────────────────────────────────────────────────────────────────────
210
+ # Allow all origins (adjust for production usage)
211
+ # app.add_middleware(
212
+ # CORSMiddleware,
213
+ # allow_origins=["*"],
214
+ # allow_credentials=True,
215
+ # allow_methods=["*"],
216
+ # allow_headers=["*"],
217
+ # )
218
+ # Fixed list of business questions (order matters)
219
+ # ───────────────────────────────────��──────────────────────────────────────────
220
+ QUESTIONS = [
221
+ "What is your business name?",
222
+ "What product or service do you offer?",
223
+ "Who is your target customer?",
224
+ "What problem does your business solve?",
225
+ "Who are your competitors?",
226
+ "What is your unique value proposition?",
227
+ "What is your pricing strategy?",
228
+ "What are your short-term goals?",
229
+ "What are your long-term goals?",
230
+ "How will you acquire customers?",
231
+ ]
232
+
233
+ # ──────────────────────────────────────────────────────────────────────────────
234
+ # SectionKey type
235
+ # ──────────────────────────────────────────────────────────────────────────────
236
+ SectionKey = Literal[
237
+ "prompt_ExecutiveSummary","prompt_CompanyProfile","prompt_MarketAnalysis",
238
+ "prompt_ProductOrService","prompt_BusinessModel","prompt_MarketingGrowth",
239
+ "prompt_OperationsPlan","prompt_ManagementTeam","prompt_FinancialPlanFunding"
240
+ ]
241
+
242
+ class SectionJob(BaseModel):
243
+ sectionKey: SectionKey
244
+ # The server will load prompt/model defaults from GeneratePrompt.
245
+ # Client MAY override but is not required to send these.
246
+ prompt: Optional[str] = None
247
+ promptVariables: Dict[str, str] = {}
248
+ model: Optional[str] = None
249
+ provider: Optional[str] = None
250
+ temperature: Optional[float] = None
251
+ maxTokens: Optional[int] = None
252
+
253
+ # Database helper constants
254
+
255
+ # Database helper functions
256
+
257
+
258
+
259
+ def extract_summary_from_markdown(markdown_text: str, max_length: int = 200) -> str:
260
+ """Extract a summary from markdown text, removing headers and formatting"""
261
+ if not markdown_text:
262
+ return ""
263
+
264
+ # Try to find the first header as a title
265
+ lines = markdown_text.split('\n')
266
+ for line in lines:
267
+ if line.strip().startswith('#'):
268
+ header_text = re.sub(r'^#+\s*', '', line.strip())
269
+ if header_text:
270
+ return header_text[:max_length]
271
+
272
+ # Fallback to first non-empty line
273
+ for line in lines:
274
+ clean_line = re.sub(r'[*_`~#]', '', line.strip())
275
+ if clean_line:
276
+ return clean_line[:max_length]
277
+
278
+ return "Generated Business Plan"
279
+
280
+ # ──────────────────────────────────────────────────────────────────────────────
281
+ # Helpers for status flags
282
+ # ──────────────────────────────────────────────────────────────────────────────
283
+ async def _ensure_sections_row(pool, business_plan_id: str) -> None:
284
+ async with pool.acquire() as conn:
285
+ await conn.execute(
286
+ 'INSERT INTO "BusinessPlanSection" ("id","businessPlanId","updatedAt") '
287
+ 'VALUES ($1,$2, now()) ON CONFLICT ("businessPlanId") DO NOTHING',
288
+ new_cuid(), business_plan_id
289
+ )
290
+
291
+ async def charge_full_plan_once(
292
+ pool,
293
+ *,
294
+ business_plan_id: str,
295
+ user_id: Optional[str],
296
+ plan_type: Optional[str],
297
+ ) -> tuple[bool, int]:
298
+ """
299
+ Charges ONE credit for a full-plan generation, once per business_plan_id.
300
+ Returns (charged_now, current_credits_after).
301
+ - Only runs for plan_type == 'full' and authenticated users.
302
+ - Idempotent via CreditCharge(businessPlanId) unique constraint.
303
+ - Updates User.currentCredits -= 1 and User.totalUsedCredits += 1 atomically.
304
+ """
305
+ if plan_type != "full":
306
+ return (False, -1)
307
+ if not user_id:
308
+ # Full plans require a signed-in user (credits live on users)
309
+ raise HTTPException(status_code=401, detail="Sign in required to generate a full plan.")
310
+
311
+
312
+ async with pool.acquire() as conn:
313
+ # If we've already charged this plan, just return current credits tally.
314
+ already = await conn.fetchval(
315
+ 'SELECT 1 FROM "CreditCharge" WHERE "businessPlanId"=$1',
316
+ business_plan_id,
317
+ )
318
+ if already:
319
+ # return current balance for UI if you want to display it
320
+ bal = await conn.fetchval(
321
+ 'SELECT "currentCredits" FROM "User" WHERE "id"=$1',
322
+ user_id,
323
+ )
324
+ return (False, int(bal) if bal is not None else -1)
325
+
326
+ # Charge once in a transaction; guards concurrency
327
+ async with conn.transaction():
328
+ # Lock the user row
329
+ row = await conn.fetchrow(
330
+ 'SELECT "currentCredits","totalUsedCredits" FROM "User" WHERE "id"=$1 FOR UPDATE',
331
+ user_id,
332
+ )
333
+ if not row:
334
+ raise HTTPException(status_code=404, detail="User not found.")
335
+ current = int(row["currentCredits"] or 0)
336
+ if current <= 0:
337
+ raise HTTPException(status_code=402, detail="Insufficient credits.")
338
+
339
+ # Deduct and increment usage
340
+ await conn.execute(
341
+ 'UPDATE "User" SET "currentCredits" = "currentCredits" - 1, "totalUsedCredits" = "totalUsedCredits" + 1 WHERE "id"=$1',
342
+ user_id,
343
+ )
344
+ # Mark this plan as charged
345
+ await conn.execute(
346
+ 'INSERT INTO "CreditCharge" ("id","businessPlanId","userId") VALUES ($1,$2,$3)',
347
+ new_cuid(), business_plan_id, user_id,
348
+ )
349
+
350
+ # Return new balance
351
+ new_bal = await conn.fetchval(
352
+ 'SELECT "currentCredits" FROM "User" WHERE "id"=$1',
353
+ user_id,
354
+ )
355
+ return (True, int(new_bal) if new_bal is not None else -1)
356
+
357
+ async def _set_section_status(pool, *, business_plan_id: str, section_key: str, generating: bool, complete: bool) -> None:
358
+ if section_key not in SECTION_MAP:
359
+ return
360
+ _, col_complete, col_generating = SECTION_MAP[section_key]
361
+ async with pool.acquire() as conn:
362
+ await conn.execute(
363
+ f'''
364
+ UPDATE "BusinessPlanSection"
365
+ SET "{col_generating}"=$2,
366
+ "{col_complete}"=$3,
367
+ "updatedAt"=now()
368
+ WHERE "businessPlanId"=$1
369
+ ''',
370
+ business_plan_id, generating, complete
371
+ )
372
+
373
+ # ──────────────────────────────────────────────────────────────────────────────
374
+ # Request model
375
+ # ──────────────────────────────────────────────────────────────────────────────
376
+ class GenerateRequest(BaseModel):
377
+ answers: List[str]
378
+ model: str
379
+ prompt: str
380
+ promptVariables: Dict[str, str]
381
+ provider: str
382
+ temperature: float
383
+ maxTokens: int
384
+ # NEW — optional, for persistence & ownership
385
+ businessPlanId: Optional[str] = None
386
+ planType: Optional[str] = "free"
387
+ userId: Optional[str] = None # will be overridden by verified JWT
388
+ anonymousId: Optional[str] = None
389
+ countryCode: Optional[str] = None
390
+ sectionKey: Optional[str] = None # e.g. "prompt_ExecutiveSummary"
391
+ returnContent: Optional[bool] = False # NEW — default to id-only responses
392
+
393
+ # ──────────────────────────────────────────────────────────────────────────────
394
+ # LLM plumbing
395
+ # ──────────────────────────────────────────────────────────────────────────────
396
+ def clean_markdown_text(text: str) -> str:
397
+ """Clean markdown formatting for PDF generation - PRESERVE HEADINGS"""
398
+ if not text:
399
+ return ""
400
+
401
+ # Remove markdown formatting BUT PRESERVE HEADING STRUCTURE
402
+ cleaned = text
403
+
404
+ # PRESERVE HEADINGS - don't strip the # markers, keep them for rendering
405
+ # We'll handle styling at render time instead of stripping them
406
+
407
+ # Keep bold, italic formatting - don't strip ** or * symbols
408
+ # cleaned = re.sub(r'\*\*(.*?)\*\*', r'\1', cleaned) # Remove bold - COMMENTED OUT
409
+ # cleaned = re.sub(r'\*(.*?)\*', r'\1', cleaned) # Remove italic - COMMENTED OUT
410
+ cleaned = re.sub(r'`(.*?)`', r'\1', cleaned) # Remove code
411
+ cleaned = re.sub(r'~~(.*?)~~', r'\1', cleaned) # Remove strikethrough
412
+
413
+ # Remove links (keep text)
414
+ cleaned = re.sub(r'\[([^\]]+)\]\([^)]+\)', r'\1', cleaned) # Remove links, keep text
415
+ cleaned = re.sub(r'!\[([^\]]*)\]\([^)]+\)', '', cleaned) # Remove images
416
+
417
+ # Remove list markers (more aggressive)
418
+ cleaned = re.sub(r'^\s*[-*+]\s*', '', cleaned, flags=re.MULTILINE) # Remove list markers
419
+ cleaned = re.sub(r'^\s*\d+\.\s*', '', cleaned, flags=re.MULTILINE) # Remove numbered list markers
420
+ cleaned = re.sub(r'^\s*[-*+]\s*', '', cleaned, flags=re.MULTILINE) # Remove any remaining list markers
421
+
422
+ # Remove blockquotes
423
+ cleaned = re.sub(r'^\s*>\s*', '', cleaned, flags=re.MULTILINE) # Remove blockquotes
424
+
425
+ # Remove horizontal rules
426
+ cleaned = re.sub(r'^\s*[-*_]{3,}\s*$', '', cleaned, flags=re.MULTILINE) # Remove horizontal rules
427
+
428
+ # Remove code blocks
429
+ cleaned = re.sub(r'```[\s\S]*?```', '', cleaned) # Remove code blocks
430
+ cleaned = re.sub(r'`.*?`', '', cleaned) # Remove any remaining inline code
431
+
432
+ # Keep emphasis markers - don't strip ** or * symbols
433
+ # cleaned = re.sub(r'_{1,2}(.*?)_{1,2}', r'\1', cleaned) # Remove underscores - COMMENTED OUT
434
+ # cleaned = re.sub(r'\*{1,2}(.*?)\*{1,2}', r'\1', cleaned) # Remove asterisks - COMMENTED OUT
435
+
436
+ # REMOVE RAW CHART DATA MARKERS (CRITICAL FIX)
437
+ cleaned = re.sub(r'<!--CHARTDATASTART-->.*?<!--CHARTDATAEND-->', '', cleaned, flags=re.DOTALL)
438
+ cleaned = re.sub(r'<!--CHARTDATASTART-->.*$', '', cleaned, flags=re.DOTALL)
439
+ cleaned = re.sub(r'<!--CHARTDATAEND-->', '', cleaned)
440
+
441
+ # Remove any remaining HTML-like tags
442
+ cleaned = re.sub(r'<[^>]+>', '', cleaned)
443
+
444
+ # Clean up extra whitespace and formatting
445
+ cleaned = re.sub(r'\n\s*\n\s*\n+', '\n\n', cleaned) # Remove excessive line breaks
446
+ cleaned = re.sub(r'^\s+', '', cleaned, flags=re.MULTILINE) # Remove leading whitespace
447
+ cleaned = re.sub(r'\s+$', '', cleaned, flags=re.MULTILINE) # Remove trailing whitespace
448
+ cleaned = re.sub(r' +', ' ', cleaned) # Replace multiple spaces with single space
449
+
450
+ # Final cleanup
451
+ cleaned = cleaned.strip()
452
+
453
+ return cleaned
454
+
455
+
456
+ def generate_model_output(model: str, provider: str, api_key: str, prompt: str, max_tokens: int = 4000) -> str:
457
+ try:
458
+ logging.info(f"Initializing InferenceClient with provider: {provider}")
459
+ client = InferenceClient(provider=provider, api_key=api_key)
460
+ logging.info(f"Sending request to model: {model} with prompt length: {len(prompt)} and max_tokens: {max_tokens}")
461
+ completion = client.chat.completions.create(
462
+ model=model,
463
+ messages=[{"role": "user", "content": prompt}],
464
+ max_tokens=max_tokens,
465
+ )
466
+ logging.info(f"Successfully received response from model: {model} with content length: {len(completion.choices[0].message.content)}")
467
+ return completion.choices[0].message.content
468
+ except Exception as e:
469
+ error_message = f"Error generating output with model {model}: {str(e)}"
470
+ logging.error(error_message)
471
+ raise Exception(error_message) from e
472
+
473
+ class HFInferenceLLM(LLM):
474
+ model: str = None
475
+ provider: str = "hf-inference"
476
+ api_key: str = ""
477
+ max_tokens: int = 4000
478
+
479
+ def __init__(self, model: str, provider: str = "hf-inference", api_key: str = "", max_tokens: int = 4000):
480
+ super().__init__()
481
+ self.model = model
482
+ self.provider = provider
483
+ self.api_key = api_key
484
+ self.max_tokens = max_tokens
485
+
486
+ if not api_key:
487
+ logging.error(f"No API key provided for model {model}")
488
+ raise ValueError(f"API key is required for Hugging Face Inference API access to {model}")
489
+ try:
490
+ _ = InferenceClient(provider=provider, api_key=api_key)
491
+ logging.info(f"Successfully initialized client for model: {model}")
492
+ except Exception as e:
493
+ logging.error(f"Failed to initialize client for model {model}: {str(e)}")
494
+ raise ValueError(f"Could not initialize Hugging Face client for {model}: {str(e)}")
495
+
496
+ @property
497
+ def _llm_type(self) -> str:
498
+ return "hf_inference"
499
+
500
+ def _call(self, prompt: str, stop: Optional[List[str]] = None) -> str:
501
+ return generate_model_output(
502
+ model=self.model,
503
+ provider=self.provider,
504
+ api_key=self.api_key,
505
+ prompt=prompt,
506
+ max_tokens=self.max_tokens
507
+ )
508
+
509
+ def get_num_tokens(self, prompt: str) -> int:
510
+ return len(prompt.split())
511
+
512
+ # def get_llm(model_name: str, provider: str = "hf-inference"):
513
+ # hf_token = os.getenv("HUGGINGFACEHUB_API_TOKEN")
514
+ # if not hf_token:
515
+ # logging.error("HUGGINGFACEHUB_API_TOKEN environment variable not set")
516
+ # raise ValueError("HUGGINGFACEHUB_API_TOKEN environment variable is required for Hugging Face models")
517
+
518
+ # if model_name == "BPGenerateAI":
519
+ # openai_api_key = os.getenv("OPENAI_API_KEY")
520
+ # if not openai_api_key:
521
+ # logging.error("OPENAI_API_KEY environment variable not set")
522
+ # raise ValueError("OPENAI_API_KEY environment variable is required for GPT models")
523
+ # return ChatOpenAI(
524
+ # model_name="gpt-4.1-mini",
525
+ # temperature=0.7,
526
+ # max_tokens=6000,
527
+ # openai_api_key=openai_api_key
528
+ # )
529
+ # elif model_name == "BPSuggestionsAI":
530
+ # openai_api_key = os.getenv("OPENAI_API_KEY")
531
+ # if not openai_api_key:
532
+ # logging.error("OPENAI_API_KEY environment variable not set")
533
+ # raise ValueError("OPENAI_API_KEY environment variable is required for GPT models")
534
+ # return ChatOpenAI(
535
+ # model_name="gpt-4.1-nano",
536
+ # temperature=0.7,
537
+ # max_tokens=4000,
538
+ # openai_api_key=openai_api_key
539
+ # )
540
+ # elif model_name.lower() == "gpt-4.1-nano":
541
+ # openai_api_key = os.getenv("OPENAI_API_KEY")
542
+ # if not openai_api_key:
543
+ # raise ValueError("OPENAI_API_KEY is required for GPT models")
544
+ # return ChatOpenAI(
545
+ # model_name="gpt-4.1-nano",
546
+ # temperature=0.7,
547
+ # max_tokens=4000,
548
+ # openai_api_key=openai_api_key
549
+ # )
550
+ # else:
551
+ # return HFInferenceLLM(
552
+ # model=model_name,
553
+ # provider=provider,
554
+ # api_key=hf_token,
555
+ # max_tokens=4000
556
+ # )
557
+ def get_llm(model_name: str, provider: str):
558
+ """
559
+ Strict provider routing.
560
+ - provider='openai' -> use OpenAI (requires OPENAI_API_KEY)
561
+ - provider in {'hf-inference','together'} -> use Hugging Face InferenceClient (requires HUGGINGFACEHUB_API_TOKEN)
562
+ - No automatic fallbacks.
563
+ """
564
+ if not provider:
565
+ raise ValueError("No provider specified. Set provider to 'openai', 'hf-inference', or 'together'.")
566
+ provider = provider.lower()
567
+
568
+ supported = {"openai", "hf-inference", "together"}
569
+ if provider not in supported:
570
+ raise ValueError(f"Unsupported provider '{provider}'. Must be one of {sorted(supported)}.")
571
+
572
+ # ─────────────────────────────────────────────────────────────
573
+ # OPENAI
574
+ # ─────────────────────────────────────────────────────────────
575
+ if provider == "openai":
576
+ openai_api_key = os.getenv("OPENAI_API_KEY")
577
+ if not openai_api_key:
578
+ raise ValueError("OPENAI_API_KEY is required when provider='openai'.")
579
+
580
+ # Map your aliases to concrete OpenAI models; allow raw names too.
581
+ if model_name == "BPGenerateAI":
582
+ model = "gpt-4.1-mini"
583
+ max_tokens = 6000
584
+ elif model_name in ("BPSuggestionsAI", "gpt-4.1-nano"):
585
+ model = "gpt-4.1-nano"
586
+ max_tokens = 4000
587
+ else:
588
+ model = model_name
589
+ max_tokens = 4000
590
+
591
+ return ChatOpenAI(
592
+ model_name=model,
593
+ temperature=0.7,
594
+ max_tokens=max_tokens,
595
+ openai_api_key=openai_api_key,
596
+ )
597
+
598
+ # ─────────────────────────────────────────────────────────────
599
+ # HF Inference (and compatible providers like 'together')
600
+ # ─────────────────────────────────────────────────────────────
601
+ hf_token = os.getenv("HUGGINGFACEHUB_API_TOKEN")
602
+ if not hf_token:
603
+ raise ValueError(f"HUGGINGFACEHUB_API_TOKEN is required when provider='{provider}'.")
604
+
605
+ # Map your aliases to HF models; allow raw names too.
606
+ if model_name == "BPGenerateAI":
607
+ model = "mistralai/Mistral-7B-Instruct-v0.3"
608
+ max_tokens = 4000
609
+ elif model_name == "BPSuggestionsAI":
610
+ model = "Qwen/Qwen2.5-3B-Instruct" # smaller/faster for suggestions
611
+ max_tokens = 4000
612
+ else:
613
+ model = model_name
614
+ max_tokens = 4000
615
+
616
+ return HFInferenceLLM(
617
+ model=model,
618
+ provider=provider, # 'hf-inference' or 'together'
619
+ api_key=hf_token,
620
+ max_tokens=max_tokens
621
+ )
622
+
623
+ # ──────────────────────────────────────────────────────────────────────────────
624
+ # Supabase JWT verification
625
+ # ──────────────────────────────────────────────────────────────────────────────
626
+ SUPABASE_JWT_SECRET = os.getenv("SUPABASE_JWT_SECRET")
627
+ SUPABASE_JWT_AUD = os.getenv("SUPABASE_JWT_AUD") # optional; e.g. "authenticated"
628
+
629
+ def _get_bearer_token(request: Request) -> Optional[str]:
630
+ auth = request.headers.get("Authorization", "")
631
+ if auth.startswith("Bearer "):
632
+ return auth.split(" ", 1)[1].strip()
633
+ return None
634
+
635
+ def get_user_id_from_request(request: Request) -> Optional[str]:
636
+ token = _get_bearer_token(request)
637
+ if not token or not SUPABASE_JWT_SECRET:
638
+ return None
639
+ # basic shape check: header.payload.signature
640
+ if token.count(".") != 2:
641
+ logging.info("Authorization header present but not a JWT; skipping verification.")
642
+ return None
643
+ try:
644
+ if SUPABASE_JWT_AUD:
645
+ payload = jwt.decode(token, SUPABASE_JWT_SECRET, algorithms=["HS256"], audience=SUPABASE_JWT_AUD)
646
+ else:
647
+ payload = jwt.decode(token, SUPABASE_JWT_SECRET, algorithms=["HS256"])
648
+ return payload.get("sub")
649
+ except JWTError as e:
650
+ logging.warning(f"JWT verification failed: {e}")
651
+ return None
652
+
653
+ def verify_supabase_jwt(token: str) -> Optional[str]:
654
+ """
655
+ Verify a Supabase JWT token and return the user ID (sub claim).
656
+
657
+ Args:
658
+ token: The JWT token string
659
+
660
+ Returns:
661
+ The user ID from the 'sub' claim, or None if verification fails
662
+ """
663
+ if not token or not SUPABASE_JWT_SECRET:
664
+ return None
665
+
666
+ # basic shape check: header.payload.signature
667
+ if token.count(".") != 2:
668
+ logging.info("Token is not a valid JWT format; skipping verification.")
669
+ return None
670
+
671
+ try:
672
+ if SUPABASE_JWT_AUD:
673
+ payload = jwt.decode(token, SUPABASE_JWT_SECRET, algorithms=["HS256"], audience=SUPABASE_JWT_AUD)
674
+ else:
675
+ payload = jwt.decode(token, SUPABASE_JWT_SECRET, algorithms=["HS256"])
676
+ return payload.get("sub")
677
+ except JWTError as e:
678
+ logging.warning(f"JWT verification failed: {e}")
679
+ return None
680
+
681
+ # --- Ownership resolver: prefer verified user; else anonymous ---
682
+ def resolve_owner(request: Request, provided_anonymous_id: Optional[str]) -> tuple[Optional[str], Optional[str]]:
683
+ """
684
+ Returns (user_id, anonymous_id):
685
+ - If Authorization JWT is present and valid -> (user_id, None)
686
+ - Else -> (None, provided_anonymous_id or None)
687
+ """
688
+ user_id = get_user_id_from_request(request)
689
+ if user_id:
690
+ return user_id, None
691
+ return None, (provided_anonymous_id or None)
692
+
693
+ # --- Optional: guard plan ownership to prevent hijacking ---
694
+ async def assert_plan_ownership(pool, *, business_plan_id: str, user_id: Optional[str], anonymous_id: Optional[str]) -> None:
695
+ """
696
+ - If plan has a userId: only that user can modify it.
697
+ - If plan has an anonymousId: only the same anonymousId can modify it.
698
+ - If plan has neither: allow (legacy).
699
+ Raises HTTPException(403) if not allowed.
700
+ """
701
+ if not business_plan_id:
702
+ return
703
+ async with pool.acquire() as conn:
704
+ row = await conn.fetchrow('SELECT "userId","anonymousId" FROM "BusinessPlan" WHERE id=$1', business_plan_id)
705
+ if not row:
706
+ return
707
+ plan_user = row["userId"]
708
+ plan_anon = row["anonymousId"]
709
+
710
+ # If plan belongs to a user, you must be that user
711
+ if plan_user:
712
+ if not user_id or user_id != plan_user:
713
+ raise HTTPException(status_code=403, detail="Not allowed to modify this plan (user mismatch).")
714
+
715
+ # If plan belongs to an anonymous identity, you must present the same anonymousId
716
+ if plan_anon and not plan_user:
717
+ if anonymous_id != plan_anon:
718
+ raise HTTPException(status_code=403, detail="Not allowed to modify this plan (anonymous mismatch).")
719
+ # ──────────────────────────────────────────────────────────────────────────────
720
+ # Database (asyncpg pooled)
721
+ # ──────────────────────────────────────────────────────────────────────────────
722
+ DATABASE_URL = os.getenv("DATABASE_URL") # pooled endpoint recommended (6543)
723
+ DB_CA_CERT_PEM = os.getenv("DB_CA_CERT_PEM")
724
+
725
+ @app.on_event("startup")
726
+ async def startup_event():
727
+ if not DATABASE_URL:
728
+ logging.error("DATABASE_URL not set; DB writes disabled.")
729
+ return
730
+
731
+ if not DB_CA_CERT_PEM:
732
+ raise RuntimeError("DB_CA_CERT_PEM is not set. Provide the PEM certificate content in the environment.")
733
+
734
+ # Convert '\n' sequences to real newlines if needed
735
+ pem = DB_CA_CERT_PEM.replace("\\n", "\n")
736
+
737
+ ssl_ctx = ssl.create_default_context()
738
+ # Load the PEM content directly from env
739
+ ssl_ctx.load_verify_locations(cadata=pem)
740
+ ssl_ctx.check_hostname = True
741
+ ssl_ctx.verify_mode = ssl.CERT_REQUIRED
742
+
743
+ app.state.db_pool = await asyncpg.create_pool(
744
+ dsn=DATABASE_URL,
745
+ ssl=ssl_ctx,
746
+ min_size=0,
747
+ max_size=8,
748
+ command_timeout=15.0,
749
+ statement_cache_size=0,
750
+ )
751
+ logging.info("✅ DB pool initialized")
752
+
753
+ # Create CreditCharge table for tracking one-time charges per plan
754
+ async with app.state.db_pool.acquire() as conn:
755
+ await conn.execute(
756
+ '''
757
+ CREATE TABLE IF NOT EXISTS "CreditCharge" (
758
+ "id" text PRIMARY KEY,
759
+ "businessPlanId" text UNIQUE NOT NULL,
760
+ "userId" text NOT NULL,
761
+ "createdAt" timestamptz NOT NULL DEFAULT now()
762
+ );
763
+ '''
764
+ )
765
+
766
+ @app.on_event("shutdown")
767
+ async def shutdown_event():
768
+ pool = getattr(app.state, "db_pool", None)
769
+ if pool:
770
+ await pool.close()
771
+ logging.info("✅ DB pool closed")
772
+ # ───────────────────────────────────────────────────────────────────��──────────
773
+ # Section mapping & helpers
774
+ # ──────────────────────────────────────────────────────────────────────────────
775
+ SECTION_MAP = {
776
+ "prompt_ExecutiveSummary": ("executiveSummary", "isExecutiveSummaryComplete", "isExecutiveSummaryGenerating"),
777
+ "prompt_CompanyProfile": ("companyProfile", "isCompanyProfileComplete", "isCompanyProfileGenerating"),
778
+ "prompt_MarketAnalysis": ("marketAnalysis", "isMarketAnalysisComplete", "isMarketAnalysisGenerating"),
779
+ "prompt_ProductOrService": ("productOrService", "isProductOrServiceComplete", "isProductOrServiceGenerating"),
780
+ "prompt_BusinessModel": ("businessModel", "isBusinessModelComplete", "isBusinessModelGenerating"),
781
+ "prompt_MarketingGrowth": ("marketingGrowth", "isMarketingGrowthComplete", "isMarketingGrowthGenerating"),
782
+ "prompt_OperationsPlan": ("operationsPlan", "isOperationsPlanComplete", "isOperationsPlanGenerating"),
783
+ "prompt_ManagementTeam": ("managementTeam", "isManagementTeamComplete", "isManagementTeamGenerating"),
784
+ "prompt_FinancialPlanFunding": ("financialPlanFunding", "isFinancialPlanFundingComplete", "isFinancialPlanFundingGenerating"),
785
+ }
786
+
787
+ # Database column names for sections (using the consolidated definition above)
788
+
789
+ def extract_summary_from_markdown(md: str) -> str:
790
+ m = re.search(r"^\s*#{1,2}\s+(.+)", md, flags=re.MULTILINE)
791
+ if m:
792
+ return m.group(1).strip()[:250]
793
+ return (md.strip().split("\n", 1)[0] if md.strip() else "Generated Business Plan")[:250]
794
+
795
+ async def _get_generate_prompt(pool, key: str):
796
+ async with pool.acquire() as conn:
797
+ row = await conn.fetchrow(
798
+ 'SELECT "key","title","promptTemplate","inputVariables","defaultModel","defaultProvider",'
799
+ '"defaultTemp","defaultMaxTokens","defaultCountry","isActive" '
800
+ 'FROM "GeneratePrompt" WHERE "key"=$1 AND "isActive"=TRUE',
801
+ key,
802
+ )
803
+ return row
804
+
805
+ def _plan_key_for_section(base_key: str, plan_type: str) -> str:
806
+ # free plan uses *_Free prompts except FinancialPlanFunding
807
+ if plan_type == "free" and base_key != "prompt_FinancialPlanFunding":
808
+ return f"{base_key}_Free"
809
+ return base_key
810
+
811
+ # --- helpers: country/currency mapping ---
812
+
813
+ def _country_name_from_code(code: Optional[str], default_name: Optional[str]) -> str:
814
+ if not code:
815
+ return default_name or "South Africa"
816
+ m = {
817
+ "ZA": "South Africa","US": "United States","GB": "United Kingdom","DE": "Germany","FR": "France",
818
+ "NG": "Nigeria","KE": "Kenya","IN": "India","AU": "Australia","CA": "Canada","BR": "Brazil",
819
+ "NL": "Netherlands","ES": "Spain","IT": "Italy","IE": "Ireland","SG": "Singapore","AE": "United Arab Emirates"
820
+ }
821
+ return m.get(code.upper(), default_name or code)
822
+
823
+ def _currency_from_country_code(code: Optional[str], fallback_country_name: Optional[str]) -> str:
824
+ # prefer code, else derive from country name, else USD
825
+ if code:
826
+ m = {
827
+ "ZA": "ZAR","US": "USD","GB": "GBP","DE": "EUR","FR": "EUR","NL": "EUR","ES": "EUR","IT": "EUR","IE":"EUR",
828
+ "NG": "NGN","KE": "KES","IN": "INR","AU": "AUD","CA": "CAD","BR": "BRL","SG":"SGD","AE":"AED"
829
+ }
830
+ if code.upper() in m:
831
+ return m[code.upper()]
832
+ if fallback_country_name:
833
+ name = fallback_country_name.lower()
834
+ if "south africa" in name: return "ZAR"
835
+ if any(x in name for x in ["united states","usa","america"]): return "USD"
836
+ if "united kingdom" in name or "uk" in name: return "GBP"
837
+ if any(x in name for x in ["germany","france","netherlands","spain","italy","ireland"]): return "EUR"
838
+ if "nigeria" in name: return "NGN"
839
+ if "kenya" in name: return "KES"
840
+ if "india" in name: return "INR"
841
+ if "australia" in name: return "AUD"
842
+ if "canada" in name: return "CAD"
843
+ if "brazil" in name: return "BRL"
844
+ if "singapore" in name: return "SGD"
845
+ if "emirates" in name or "uae" in name: return "AED"
846
+ return "USD"
847
+
848
+ def _compose_prompt_vars(
849
+ *,
850
+ gp_row,
851
+ answers: List[str],
852
+ business_idea: Optional[str],
853
+ country_code: Optional[str],
854
+ section_title: str,
855
+ feedback: Optional[str],
856
+ ) -> Dict[str, str]:
857
+ """
858
+ Build exactly the variables that the GeneratePrompt row declares in inputVariables.
859
+ Examples we've seen: ["business_context","country","currency","q_and_a"].
860
+ """
861
+
862
+ # Parse declared input variables from DB (stringified JSON)
863
+ try:
864
+ declared = json.loads(gp_row["inputVariables"] or "[]")
865
+ except Exception:
866
+ declared = []
867
+
868
+ # Precompute building blocks we might map into the declared names
869
+ now_iso = datetime.utcnow().isoformat()
870
+ country_name = _country_name_from_code(country_code, gp_row.get("defaultCountry"))
871
+ currency = _currency_from_country_code(country_code, country_name)
872
+
873
+ # Build a Q&A string (or JSON) from your QUESTIONS & answers array
874
+ # (safe against length mismatch; unanswered = "N/A")
875
+ qa_lines = []
876
+ for i, q in enumerate(QUESTIONS):
877
+ a = answers[i] if i < len(answers) and answers[i] else "N/A"
878
+ qa_lines.append(f"{q} -> {a}")
879
+ qa_text = "\n".join(qa_lines)
880
+
881
+ # Business context: prefer explicit business_idea, else stitch from first answers
882
+ business_context = (business_idea or "").strip()
883
+ if not business_context:
884
+ # use first two answers as a minimal context
885
+ name = answers[0] if len(answers) > 0 else ""
886
+ offering = answers[1] if len(answers) > 1 else ""
887
+ business_context = (name + " — " + offering).strip(" —")
888
+
889
+ # We'll fill only what the prompt expects. If nothing declared, we fall back
890
+ # to a generic superset (backward compat).
891
+ declared = declared if isinstance(declared, list) else []
892
+
893
+ # Canonical mapping for common keys you're using in GeneratePrompt
894
+ mapping = {
895
+ "business_context": business_context,
896
+ "q_and_a": qa_text,
897
+ "country": country_name,
898
+ "currency": currency,
899
+
900
+ # Optional alternates you might have in some prompts
901
+ "section_title": section_title,
902
+ "current_date": now_iso,
903
+ "feedback": feedback or "",
904
+ "answers_json": json.dumps(answers or []),
905
+ "businessIdea": business_idea or "", # if some prompts still use old name
906
+ "countryCode": country_code or "", # if some prompts still use old name
907
+ }
908
+
909
+ if declared:
910
+ # Return exactly the keys the prompt asked for; raise obvious gaps to logs
911
+ out = {}
912
+ for key in declared:
913
+ out[key] = mapping.get(key, "")
914
+ return out
915
+
916
+ # Fallback for prompts that didn't declare inputVariables
917
+ return {
918
+ "business_context": business_context,
919
+ "q_and_a": qa_text,
920
+ "country": country_name,
921
+ "currency": currency,
922
+ "section_title": section_title,
923
+ "current_date": now_iso,
924
+ "feedback": feedback or "",
925
+ "answers_json": json.dumps(answers or []),
926
+ }
927
+
928
+ async def _ensure_business_plan(pool, *, bp_id: Optional[str], summary: str, full_plan: str,
929
+ model: str, answers: List[str], plan_type: Optional[str],
930
+ user_id: Optional[str], anon_id: Optional[str],
931
+ country_code: Optional[str]) -> str:
932
+ async with pool.acquire() as conn:
933
+ if bp_id:
934
+ exists = await conn.fetchval('SELECT 1 FROM "BusinessPlan" WHERE id = $1', bp_id)
935
+ if exists:
936
+ await conn.execute(
937
+ 'UPDATE "BusinessPlan" SET '
938
+ '"summary"=$1, "model"=$2, "answers"=$3, '
939
+ '"planType"=COALESCE($4,\'free\'), "countryCode"=$5, '
940
+ '"updatedAt"=now(), "userId"=COALESCE($6,"userId") '
941
+ 'WHERE id=$7',
942
+ summary, model, answers, plan_type, country_code, user_id, bp_id
943
+ )
944
+ return bp_id
945
+
946
+ new_id = new_cuid()
947
+ row = await conn.fetchrow(
948
+ 'INSERT INTO "BusinessPlan" '
949
+ '("id","summary","fullPlan","model","answers","planType","userId","anonymousId","countryCode","updatedAt") '
950
+ 'VALUES ($1,$2,$3,$4,$5,COALESCE($6,\'free\'),$7,$8,$9, now()) '
951
+ 'RETURNING id',
952
+ new_id, summary, "", model, answers, plan_type, user_id, anon_id, country_code # fullPlan is intentionally ""
953
+ )
954
+ return row["id"]
955
+
956
+ # async def _upsert_section(pool, *, business_plan_id: str, section_key: str, content: str) -> None:
957
+ # if section_key not in SECTION_MAP:
958
+ # logging.warning(f"Unknown section_key '{section_key}', skipping section save.")
959
+ # return
960
+
961
+ # col_content, col_complete, col_generating = SECTION_MAP[section_key]
962
+ # set_clause = f'"{col_content}" = $2, "{col_complete}" = TRUE, "{col_generating}" = FALSE'
963
+
964
+ # async with pool.acquire() as conn:
965
+ # # ensure row exists with a generated id
966
+ # await conn.execute(
967
+ # 'INSERT INTO "BusinessPlanSection" ("id","businessPlanId") '
968
+ # 'VALUES ($1,$2) '
969
+ # 'ON CONFLICT ("businessPlanId") DO NOTHING',
970
+ # str(uuid.uuid4()), business_plan_id
971
+ # )
972
+ # # update content + flags
973
+ # await conn.execute(
974
+ # f'UPDATE "BusinessPlanSection" SET {set_clause} WHERE "businessPlanId" = $1',
975
+ # business_plan_id, content
976
+ # )
977
+ async def _upsert_section(pool, *, business_plan_id: str, section_key: str, content: str) -> None:
978
+ if section_key not in SECTION_MAP:
979
+ logging.warning(f"Unknown section_key '{section_key}', skipping section save.")
980
+ return
981
+
982
+ col_content, col_complete, col_generating = SECTION_MAP[section_key]
983
+ # bump updatedAt on every write
984
+ set_clause = (
985
+ f'"{col_content}" = $2, '
986
+ f'"{col_complete}" = TRUE, '
987
+ f'"{col_generating}" = FALSE, '
988
+ f'"updatedAt" = now()'
989
+ )
990
+
991
+ async with pool.acquire() as conn:
992
+ # ensure row exists, and give updatedAt a value to satisfy NOT NULL
993
+ await conn.execute(
994
+ 'INSERT INTO "BusinessPlanSection" ("id","businessPlanId","updatedAt") '
995
+ 'VALUES ($1,$2, now()) '
996
+ 'ON CONFLICT ("businessPlanId") DO NOTHING',
997
+ new_cuid(), business_plan_id
998
+ )
999
+ # update content + flags (+ updatedAt)
1000
+ await conn.execute(
1001
+ f'UPDATE "BusinessPlanSection" SET {set_clause} WHERE "businessPlanId" = $1',
1002
+ business_plan_id, content
1003
+ )
1004
+
1005
+ async def _recompute_full_plan(pool, *, business_plan_id: str) -> str:
1006
+ async with pool.acquire() as conn:
1007
+ row = await conn.fetchrow(
1008
+ 'SELECT * FROM "BusinessPlanSection" WHERE "businessPlanId" = $1',
1009
+ business_plan_id
1010
+ )
1011
+ if not row:
1012
+ return ""
1013
+ parts = []
1014
+ for col in SECTION_ORDER:
1015
+ txt = row.get(col)
1016
+ if txt:
1017
+ parts.append(txt.strip())
1018
+ full_plan = "\n\n".join(parts)
1019
+ return full_plan # Do not persist fullPlan; caller may use it for summary only
1020
+
1021
+ def _all_sections_present(row: asyncpg.Record) -> bool:
1022
+ return all(bool(row.get(col)) for col in SECTION_ORDER)
1023
+
1024
+ # ──────────────────────────────────────────────────────────────────────────────
1025
+ # Endpoints
1026
+ # ──────────────────────────────────────────────────────────────────────────────
1027
+ @app.get("/suggestions", response_model=Dict[str, Any])
1028
+ async def get_suggestions(
1029
+ business_idea: str,
1030
+ model: str,
1031
+ question: str,
1032
+ prompt: str,
1033
+ provider: str,
1034
+ temperature: float,
1035
+ maxTokens: int,
1036
+ exclude: List[str] = Query([])
1037
+ ) -> Dict[str, Any]:
1038
+ try:
1039
+ formatted_prompt = prompt.format(
1040
+ business_idea=business_idea,
1041
+ question=question,
1042
+ exclude=", ".join(exclude) if exclude else ""
1043
+ )
1044
+ llm = get_llm(model, provider)
1045
+ if hasattr(llm, 'temperature'):
1046
+ llm.temperature = temperature
1047
+ if hasattr(llm, 'max_tokens'):
1048
+ llm.max_tokens = maxTokens
1049
+
1050
+ suggestion_prompt = PromptTemplate(input_variables=[], template=formatted_prompt)
1051
+ suggestion_chain: RunnableSequence = suggestion_prompt | llm
1052
+ raw_result = await asyncio.to_thread(suggestion_chain.invoke, {})
1053
+
1054
+ if isinstance(raw_result, AIMessage):
1055
+ raw_text = raw_result.content
1056
+ elif isinstance(raw_result, (list, tuple)) and raw_result and isinstance(raw_result[0], AIMessage):
1057
+ raw_text = "\n".join(msg.content for msg in raw_result)
1058
+ else:
1059
+ raw_text = str(raw_result)
1060
+
1061
+ suggestion_array = [
1062
+ re.sub(r'^\s*[\-\d\.\)\s]+', '', line).strip()
1063
+ for line in raw_text.split("\n")
1064
+ if line.strip()
1065
+ ]
1066
+ if not suggestion_array:
1067
+ suggestion_array = ["No suggestions available"]
1068
+ return {"suggestions": suggestion_array}
1069
+ except Exception as e:
1070
+ raise HTTPException(status_code=500, detail=f"Error generating suggestions: {str(e)}")
1071
+
1072
+ class CreatePlanRequest(BaseModel):
1073
+ answers: List[str] = []
1074
+ planType: Optional[str] = "free"
1075
+ countryCode: Optional[str] = None
1076
+ anonymousId: Optional[str] = None
1077
+ businessIdea: Optional[str] = None
1078
+
1079
+ @app.post("/plan")
1080
+ async def create_plan_shell(request: Request, data: CreatePlanRequest):
1081
+ pool = getattr(app.state, "db_pool", None)
1082
+ if not pool:
1083
+ raise HTTPException(status_code=500, detail="DB not initialized")
1084
+
1085
+ # Resolve owner (JWT beats anonymousId)
1086
+ user_id, anon_id = resolve_owner(request, data.anonymousId)
1087
+
1088
+ # Create a new plan row (server-minted id) + blank sections row
1089
+ bp_id = await _ensure_business_plan(
1090
+ pool,
1091
+ bp_id=None, # <- server generates the id; client never sends one
1092
+ summary="Generating…",
1093
+ full_plan="",
1094
+ model="unknown",
1095
+ answers=data.answers or [],
1096
+ plan_type=data.planType,
1097
+ user_id=user_id,
1098
+ anon_id=anon_id,
1099
+ country_code=data.countryCode,
1100
+ )
1101
+ await _ensure_sections_row(pool, bp_id)
1102
+
1103
+ # Tell subscribers a plan exists
1104
+ await _publish(bp_id, "plan.created", {"businessPlanId": bp_id})
1105
+
1106
+ return {"businessPlanId": bp_id}
1107
+
1108
+ @app.post("/generate/batch")
1109
+ async def generate_business_plan_batch(request: Request, data: BatchGenerateRequest):
1110
+ try:
1111
+ pool = getattr(app.state, "db_pool", None)
1112
+ if not pool:
1113
+ raise HTTPException(status_code=500, detail="DB not initialized")
1114
+
1115
+ # ① Resolve owner (JWT wins; else anonymous)
1116
+ user_id, anon_id = resolve_owner(request, data.anonymousId)
1117
+ logging.info(f"[owner] user_id={user_id} anon_id={anon_id} planType={data.planType} planId={data.businessPlanId}")
1118
+
1119
+ # ② (Optional) Guard ownership of existing plan
1120
+ if data.businessPlanId:
1121
+ await assert_plan_ownership(pool, business_plan_id=data.businessPlanId, user_id=user_id, anonymous_id=anon_id)
1122
+
1123
+ # ③ Create/ensure plan shell
1124
+ bp_id = await _ensure_business_plan(
1125
+ pool,
1126
+ bp_id=data.businessPlanId,
1127
+ summary="Generating…",
1128
+ full_plan="",
1129
+ model="unknown",
1130
+ answers=data.answers,
1131
+ plan_type=data.planType,
1132
+ user_id=user_id,
1133
+ anon_id=anon_id,
1134
+ country_code=data.countryCode,
1135
+ )
1136
+ logging.info(f"[plan] ensured id={bp_id} linked to user_id={user_id} anon_id={anon_id}")
1137
+
1138
+ # after ensure plan id
1139
+ await _publish(bp_id, "plan.created", {"businessPlanId": bp_id})
1140
+
1141
+ await _ensure_sections_row(pool, bp_id)
1142
+
1143
+ # 🔐 Charge exactly once per full plan (no charge for 'free' or anon)
1144
+ charged_now, remaining = await charge_full_plan_once(
1145
+ pool,
1146
+ business_plan_id=bp_id,
1147
+ user_id=user_id,
1148
+ plan_type=data.planType,
1149
+ )
1150
+
1151
+ # Mark all sections as generating
1152
+ for job in data.sections:
1153
+ await _set_section_status(
1154
+ pool, business_plan_id=bp_id, section_key=job.sectionKey, generating=True, complete=False
1155
+ )
1156
+ await _publish(bp_id, "section.started", {
1157
+ "businessPlanId": bp_id,
1158
+ "sectionKey": job.sectionKey,
1159
+ })
1160
+
1161
+ sem = asyncio.Semaphore(3)
1162
+
1163
+ async def run_job(job: SectionJob):
1164
+ async with sem:
1165
+ # 1) Resolve plan-specific key (base for full, *_Free for free except funding)
1166
+ effective_key = _plan_key_for_section(job.sectionKey, data.planType or "free")
1167
+
1168
+ # 2) Load GeneratePrompt row
1169
+ gp = await _get_generate_prompt(pool, effective_key)
1170
+ if not gp:
1171
+ raise HTTPException(status_code=400, detail=f"No active GeneratePrompt for key {effective_key}")
1172
+
1173
+ # 3) Resolve model/provider/temp/max (client overrides optional)
1174
+ resolved_model = job.model or gp["defaultModel"]
1175
+ resolved_provider = (job.provider or gp["defaultProvider"]).lower()
1176
+ resolved_temp = job.temperature if job.temperature is not None else float(gp["defaultTemp"])
1177
+ resolved_max = job.maxTokens if job.maxTokens is not None else int(gp["defaultMaxTokens"])
1178
+
1179
+ # 4) Compose variables server-side (respect declared inputVariables)
1180
+ vars_for_prompt = _compose_prompt_vars(
1181
+ gp_row=gp,
1182
+ answers=data.answers,
1183
+ business_idea=data.businessIdea or (data.answers[0] if data.answers else None),
1184
+ country_code=data.countryCode,
1185
+ section_title=gp["title"],
1186
+ feedback=data.feedback,
1187
+ )
1188
+
1189
+ # 5) Prompt + LLM + chain
1190
+ llm = get_llm(resolved_model, resolved_provider)
1191
+ if hasattr(llm, "temperature"): llm.temperature = resolved_temp
1192
+ if hasattr(llm, "max_tokens"): llm.max_tokens = resolved_max
1193
+
1194
+ template = job.prompt or gp["promptTemplate"]
1195
+ prompt_t = PromptTemplate(input_variables=list(vars_for_prompt.keys()), template=template)
1196
+ chain: RunnableSequence = prompt_t | llm
1197
+ raw = await asyncio.to_thread(chain.invoke, vars_for_prompt)
1198
+ text = raw.content if isinstance(raw, AIMessage) else str(raw)
1199
+
1200
+ # 6) Save section & clear flags
1201
+ await _upsert_section(pool, business_plan_id=bp_id, section_key=job.sectionKey, content=text)
1202
+ await _publish(bp_id, "section.complete", {
1203
+ "businessPlanId": bp_id,
1204
+ "sectionKey": job.sectionKey,
1205
+ })
1206
+ return resolved_model
1207
+
1208
+ resolved_models = await asyncio.gather(*(run_job(job) for job in data.sections))
1209
+ model_for_plan = next((m for m in resolved_models if m), "unknown")
1210
+
1211
+ # Recompose full plan & update summary
1212
+ full_plan = await _recompute_full_plan(pool, business_plan_id=bp_id)
1213
+ summary = extract_summary_from_markdown(full_plan)
1214
+
1215
+ await _ensure_business_plan(
1216
+ pool,
1217
+ bp_id=bp_id,
1218
+ summary=summary,
1219
+ full_plan=full_plan,
1220
+ model=model_for_plan,
1221
+ answers=data.answers,
1222
+ plan_type=data.planType,
1223
+ user_id=user_id,
1224
+ anon_id=anon_id,
1225
+ country_code=data.countryCode,
1226
+ )
1227
+
1228
+ await _publish(bp_id, "plan.updated", {
1229
+ "businessPlanId": bp_id,
1230
+ "summary": summary,
1231
+ "fullPlanLength": len(full_plan or ""),
1232
+ })
1233
+ await _publish(bp_id, "plan.complete", {
1234
+ "businessPlanId": bp_id,
1235
+ "completedKeys": [j.sectionKey for j in data.sections],
1236
+ })
1237
+
1238
+ return {
1239
+ "businessPlanId": bp_id,
1240
+ "status": "complete",
1241
+ "completedKeys": [j.sectionKey for j in data.sections],
1242
+ "fullPlanLength": len(full_plan or ""),
1243
+ }
1244
+ except Exception as e:
1245
+ raise HTTPException(status_code=500, detail=f"Batch generation failed: {e}")
1246
+
1247
+ @app.get("/plan/{businessPlanId}/status")
1248
+ async def get_plan_status(businessPlanId: str):
1249
+ pool = getattr(app.state, "db_pool", None)
1250
+ if not pool:
1251
+ raise HTTPException(status_code=500, detail="DB not initialized")
1252
+ async with pool.acquire() as conn:
1253
+ row = await conn.fetchrow('SELECT * FROM "BusinessPlanSection" WHERE "businessPlanId"=$1', businessPlanId)
1254
+ if not row:
1255
+ return {"exists": False}
1256
+ # Build per-section status
1257
+ statuses = {}
1258
+ for key, (col, complete_col, gen_col) in SECTION_MAP.items():
1259
+ statuses[key] = {"isGenerating": bool(row.get(gen_col)), "isComplete": bool(row.get(complete_col))}
1260
+ all_done = all(s["isComplete"] for s in statuses.values())
1261
+ return {"exists": True, "allComplete": all_done, "sections": statuses}
1262
+
1263
+ @app.get("/events/plan/{businessPlanId}")
1264
+ async def sse_plan(businessPlanId: str):
1265
+ async def event_gen() -> AsyncIterator[bytes]:
1266
+ q: asyncio.Queue[str] = asyncio.Queue(maxsize=200)
1267
+ SUBSCRIBERS[businessPlanId].add(q)
1268
+ try:
1269
+ yield b": connected\n\n"
1270
+ while True:
1271
+ try:
1272
+ msg = await asyncio.wait_for(q.get(), timeout=15)
1273
+ yield msg.encode("utf-8")
1274
+ except asyncio.TimeoutError:
1275
+ yield b": keep-alive\n\n"
1276
+ except asyncio.CancelledError:
1277
+ pass
1278
+ finally:
1279
+ SUBSCRIBERS[businessPlanId].discard(q)
1280
+
1281
+ return StreamingResponse(
1282
+ event_gen(),
1283
+ media_type="text/event-stream",
1284
+ headers={"Cache-Control": "no-cache", "Connection": "keep-alive"},
1285
+ )
1286
+
1287
+
1288
+
1289
+ @app.post("/generate")
1290
+ async def generate_business_plan(request: Request, data: GenerateRequest):
1291
+ """
1292
+ Generate a business plan using the provided prompt template and variables,
1293
+ then persist to Supabase Postgres. Ownership is derived from the verified JWT.
1294
+ """
1295
+ try:
1296
+ logging.info(f"Initializing model: {data.model} with provider: {data.provider}")
1297
+ llm_selected = get_llm(data.model, data.provider) # provider is used in get_llm for HF models
1298
+
1299
+ if hasattr(llm_selected, 'temperature'):
1300
+ llm_selected.temperature = data.temperature
1301
+ if hasattr(llm_selected, 'max_tokens'):
1302
+ llm_selected.max_tokens = data.maxTokens
1303
+
1304
+ plan_prompt = PromptTemplate(
1305
+ input_variables=list(data.promptVariables.keys()),
1306
+ template=data.prompt
1307
+ )
1308
+ plan_chain: RunnableSequence = plan_prompt | llm_selected
1309
+ logging.info(f"Generating business plan with model: {data.model}")
1310
+
1311
+ raw_plan = await asyncio.to_thread(plan_chain.invoke, data.promptVariables)
1312
+
1313
+ if isinstance(raw_plan, AIMessage):
1314
+ plan_text = raw_plan.content
1315
+ elif isinstance(raw_plan, (list, tuple)) and raw_plan and isinstance(raw_plan[0], AIMessage):
1316
+ plan_text = "\n".join(msg.content for msg in raw_plan)
1317
+ else:
1318
+ plan_text = str(raw_plan)
1319
+
1320
+ # ① Resolve owner consistently (JWT wins)
1321
+ user_id, anon_id = resolve_owner(request, data.anonymousId)
1322
+ logging.info(f"[owner] user_id={user_id} anon_id={anon_id} planType={data.planType} planId={data.businessPlanId}")
1323
+
1324
+ # ② (Optional) Guard ownership if an existing plan is referenced
1325
+ if data.businessPlanId:
1326
+ pool = getattr(app.state, "db_pool", None)
1327
+ if not pool:
1328
+ raise HTTPException(status_code=500, detail="DB not initialized")
1329
+ await assert_plan_ownership(pool, business_plan_id=data.businessPlanId, user_id=user_id, anonymous_id=anon_id)
1330
+
1331
+ # If generating a specific section, publish section.started before LLM generation
1332
+ if data.sectionKey and data.businessPlanId:
1333
+ pool = getattr(app.state, "db_pool", None)
1334
+ if pool:
1335
+ await _publish(data.businessPlanId, "section.started", {
1336
+ "businessPlanId": data.businessPlanId,
1337
+ "sectionKey": data.sectionKey,
1338
+ })
1339
+
1340
+ # ── Persist
1341
+ pool = getattr(app.state, "db_pool", None)
1342
+ summary = extract_summary_from_markdown(plan_text)
1343
+
1344
+ if not pool:
1345
+ logging.warning("DB pool not available; skipping persistence.")
1346
+ return {
1347
+ "summary": summary or "Generated Business Plan",
1348
+ "plan": plan_text,
1349
+ }
1350
+
1351
+ # If generating a specific section
1352
+ if data.sectionKey:
1353
+ business_plan_id = await _ensure_business_plan(
1354
+ pool,
1355
+ bp_id=data.businessPlanId,
1356
+ summary=summary,
1357
+ full_plan="", # recomputed after section save
1358
+ model=data.model,
1359
+ answers=data.answers,
1360
+ plan_type=data.planType,
1361
+ user_id=user_id,
1362
+ anon_id=anon_id,
1363
+ country_code=data.countryCode,
1364
+ )
1365
+ logging.info(f"[plan] ensured section id={business_plan_id} linked to user_id={user_id} anon_id={anon_id}")
1366
+
1367
+ # mark generating True in the DB for this section...
1368
+ await _publish(business_plan_id, "section.started", {
1369
+ "businessPlanId": business_plan_id,
1370
+ "sectionKey": data.sectionKey,
1371
+ })
1372
+
1373
+ await _upsert_section(
1374
+ pool,
1375
+ business_plan_id=business_plan_id,
1376
+ section_key=data.sectionKey,
1377
+ content=plan_text
1378
+ )
1379
+
1380
+ # ...generate text, save to this section, set generating False, set section complete...
1381
+ await _publish(business_plan_id, "section.complete", {
1382
+ "businessPlanId": business_plan_id,
1383
+ "sectionKey": data.sectionKey,
1384
+ })
1385
+
1386
+ # Recompute full plan after section write
1387
+ full_plan = await _recompute_full_plan(pool, business_plan_id=business_plan_id)
1388
+
1389
+ # if you recompute summary here, also:
1390
+ await _publish(business_plan_id, "plan.updated", {
1391
+ "businessPlanId": business_plan_id,
1392
+ "summary": summary,
1393
+ "fullPlanLength": len(full_plan or ""),
1394
+ })
1395
+
1396
+ # Optional: mark complete if all sections present
1397
+ is_complete = False
1398
+ async with pool.acquire() as conn:
1399
+ row = await conn.fetchrow(
1400
+ 'SELECT * FROM "BusinessPlanSection" WHERE "businessPlanId"=$1',
1401
+ business_plan_id
1402
+ )
1403
+ if row:
1404
+ is_complete = _all_sections_present(row)
1405
+
1406
+ resp = {
1407
+ "summary": summary,
1408
+ "plan": plan_text,
1409
+ "sections": [{"key": data.sectionKey, "content": plan_text}],
1410
+ "isComplete": True, # this means "this section completed"
1411
+ "currentSection": data.sectionKey,
1412
+ "businessPlanId": business_plan_id,
1413
+ }
1414
+ return resp
1415
+
1416
+ # Non-section — treat as a whole plan
1417
+ business_plan_id = await _ensure_business_plan(
1418
+ pool,
1419
+ bp_id=data.businessPlanId,
1420
+ summary=summary,
1421
+ full_plan="", # do not persist the full combined text
1422
+ model=data.model,
1423
+ answers=data.answers,
1424
+ plan_type=data.planType,
1425
+ user_id=user_id,
1426
+ anon_id=anon_id,
1427
+ country_code=data.countryCode,
1428
+ )
1429
+ logging.info(f"[plan] ensured whole plan id={business_plan_id} linked to user_id={user_id} anon_id={anon_id}")
1430
+
1431
+ resp = {
1432
+ "businessPlanId": business_plan_id,
1433
+ "sectionKey": None,
1434
+ "isComplete": True, # whole-plan path implies one-shot completion
1435
+ }
1436
+ if data.returnContent:
1437
+ resp.update({
1438
+ "summary": summary,
1439
+ "plan": plan_text,
1440
+ })
1441
+ return resp
1442
+
1443
+ except Exception as e:
1444
+ error_message = f"Error initializing model {data.model}: {str(e)}"
1445
+ logging.error(error_message)
1446
+ raise HTTPException(status_code=500, detail=error_message)
1447
+
1448
+
1449
+ @app.post("/export", response_model=ExportResponse)
1450
+ async def export_business_plan(request: ExportRequest):
1451
+ """
1452
+ Export a business plan in the specified format (PDF or Word).
1453
+ This endpoint creates documents and returns them as base64 data for frontend upload to UploadThing.
1454
+
1455
+ FLOW: Server generates document → Returns base64 data → Frontend uploads to UploadThing → User gets download link
1456
+ """
1457
+ try:
1458
+ # Duplicate detection (best-effort; non-fatal if bookkeeping fails)
1459
+ try:
1460
+ request_id = str(id(request))
1461
+ request_fingerprint = f"{request.businessIdea}_{request.format}_{len(request.sections)}_{hash(str(request.sections))}"
1462
+ current_time = time.time()
1463
+ if hasattr(export_business_plan, 'last_requests'):
1464
+ # Clean old requests (older than 5 seconds)
1465
+ export_business_plan.last_requests = {
1466
+ fp: timestamp for fp, timestamp in export_business_plan.last_requests.items()
1467
+ if current_time - timestamp < 5
1468
+ }
1469
+ # Check for duplicate
1470
+ if request_fingerprint in export_business_plan.last_requests:
1471
+ raise HTTPException(
1472
+ status_code=429,
1473
+ detail="Duplicate export request detected. Please wait a few seconds before trying again."
1474
+ )
1475
+ else:
1476
+ export_business_plan.last_requests = {}
1477
+ # Record this request
1478
+ export_business_plan.last_requests[request_fingerprint] = current_time
1479
+ except HTTPException:
1480
+ raise
1481
+ except Exception:
1482
+ # Do not block export on dedupe bookkeeping errors
1483
+ pass
1484
+
1485
+
1486
+ # Validate request format
1487
+ if request.format not in ["pdf", "word"]:
1488
+ raise HTTPException(status_code=400, detail="Invalid format. Only 'pdf' and 'word' are supported.")
1489
+
1490
+ # Validate required fields
1491
+ if not request.summary:
1492
+ raise HTTPException(status_code=400, detail="Summary is required for export.")
1493
+
1494
+ if not request.sections:
1495
+ raise HTTPException(status_code=400, detail="Sections are required for export.")
1496
+
1497
+ if len(request.sections) == 0:
1498
+ raise HTTPException(status_code=400, detail="At least one section is required for export.")
1499
+
1500
+ # Check section content and shape
1501
+ for i, section in enumerate(request.sections):
1502
+ title = getattr(section, 'title', None)
1503
+ if not title or not str(title).strip():
1504
+ raise HTTPException(status_code=400, detail=f"Section at index {i} is missing a title.")
1505
+ if not section.content or not section.content.strip():
1506
+ raise HTTPException(status_code=400, detail=f"Section '{title}' has no content.")
1507
+
1508
+ # logging.info(f"Exporting business plan for: {request.businessIdea} in {request.format.upper()} format")
1509
+ # logging.info(f"Request has {len(request.sections)} sections and summary length: {len(request.summary)}")
1510
+
1511
+ # Debug section information
1512
+ for i, section in enumerate(request.sections):
1513
+ # logging.info(f"Section {i+1}: '{section.title}' (key: {section.key}) - Content length: {len(section.content) if section.content else 0}")
1514
+ if section.content:
1515
+ # logging.info(f" First 100 chars: {section.content[:100]}...")
1516
+ pass
1517
+ else:
1518
+ # logging.info(f" No content")
1519
+ pass
1520
+
1521
+ # Check required packages before proceeding
1522
+ try:
1523
+ if request.format == "pdf":
1524
+ import fpdf
1525
+ else:
1526
+ import docx
1527
+ except ImportError as import_error:
1528
+ raise HTTPException(
1529
+ status_code=500,
1530
+ detail=f"Required package not available: {str(import_error)}"
1531
+ )
1532
+
1533
+ # Create the document based on format
1534
+ try:
1535
+ if request.format == "pdf":
1536
+ doc_bytes, filename = create_pdf_document(request)
1537
+ else: # word
1538
+ doc_bytes, filename = create_word_document(request)
1539
+ # Basic sanity check on output
1540
+ if not doc_bytes or (isinstance(doc_bytes, (bytes, bytearray)) and len(doc_bytes) < 1000):
1541
+ raise HTTPException(status_code=500, detail=f"Generated {request.format.upper()} appears empty or invalid.")
1542
+ except HTTPException:
1543
+ raise
1544
+ except ImportError as import_error:
1545
+ raise HTTPException(
1546
+ status_code=500,
1547
+ detail=f"Missing required package for {request.format.upper()} generation: {str(import_error)}"
1548
+ )
1549
+ except Exception as doc_error:
1550
+ # logging.error(f"Document creation failed: {doc_error}")
1551
+ raise HTTPException(
1552
+ status_code=500,
1553
+ detail=f"Failed to create {request.format.upper()} document: {str(doc_error)}"
1554
+ )
1555
+
1556
+ # Return document data to frontend for UploadThing upload
1557
+ try:
1558
+ # Encode document bytes as base64 for frontend transmission
1559
+ import base64
1560
+ document_data = base64.b64encode(doc_bytes).decode('utf-8')
1561
+
1562
+ response = ExportResponse(
1563
+ success=True,
1564
+ downloadUrl="", # Will be set by frontend after UploadThing upload
1565
+ filename=filename,
1566
+ size=len(doc_bytes),
1567
+ message=f"Document generated successfully as {request.format.upper()}. Ready for frontend upload to UploadThing.",
1568
+ documentData=document_data # Base64 encoded document data
1569
+ )
1570
+
1571
+ return response
1572
+
1573
+ except Exception as data_error:
1574
+ # logging.error(f"Failed to prepare document data: {data_error}")
1575
+ raise HTTPException(
1576
+ status_code=500,
1577
+ detail=f"Failed to prepare document data: {str(data_error)}"
1578
+ )
1579
+
1580
+ except HTTPException:
1581
+ # Re-raise HTTP exceptions (validation errors)
1582
+ raise
1583
+ except Exception as e:
1584
+ # Catch any other unexpected errors
1585
+ error_message = f"Export failed: {str(e)}"
1586
+ # logging.error(error_message)
1587
+ raise HTTPException(status_code=500, detail=error_message)
1588
+
1589
+
1590
+ @app.get("/download/{filename}")
1591
+ async def download_file(filename: str):
1592
+ """Download endpoint to serve the generated export files"""
1593
+ file_path = os.path.join("temp_exports", filename)
1594
+ if not os.path.exists(file_path):
1595
+ raise HTTPException(status_code=404, detail="File not found")
1596
+
1597
+ return FileResponse(file_path, filename=filename)
1598
+
1599
+ # ──────────────────────────────────────────────────────────────────────────────
1600
+ # Live PDF Generation Endpoints
1601
+ # ──────────────────────────────────────────────────────────────────────────────
1602
+
1603
+ @app.post("/live-pdf/start")
1604
+ async def start_live_pdf_session(request: Request, data: ExportRequest):
1605
+ """Start a live PDF session that streams updates as content changes"""
1606
+ try:
1607
+ # Create a new live PDF session
1608
+ session_id = await live_pdf_generator.create_live_session(
1609
+ data.businessIdea or "default",
1610
+ data
1611
+ )
1612
+
1613
+ return {
1614
+ "success": True,
1615
+ "session_id": session_id,
1616
+ "message": "Live PDF session started successfully"
1617
+ }
1618
+
1619
+ except Exception as e:
1620
+ raise HTTPException(status_code=500, detail=f"Failed to start live PDF session: {str(e)}")
1621
+
1622
+ @app.post("/live-pdf/update/{session_id}")
1623
+ async def update_live_pdf_session(session_id: str, data: ExportRequest):
1624
+ """Update an existing live PDF session with new content"""
1625
+ try:
1626
+ success = await live_pdf_generator.update_session(session_id, data)
1627
+
1628
+ if not success:
1629
+ raise HTTPException(status_code=404, detail="Session not found")
1630
+
1631
+ return {
1632
+ "success": True,
1633
+ "message": "PDF updated successfully"
1634
+ }
1635
+
1636
+ except Exception as e:
1637
+ raise HTTPException(status_code=500, detail=f"Failed to update PDF: {str(e)}")
1638
+
1639
+ @app.get("/live-pdf/stream/{session_id}")
1640
+ async def stream_live_pdf_updates(session_id: str):
1641
+ """Stream live PDF updates using Server-Sent Events"""
1642
+ async def event_generator():
1643
+ async for chunk in live_pdf_generator.stream_pdf_updates(session_id):
1644
+ yield chunk
1645
+
1646
+ return StreamingResponse(
1647
+ event_generator(),
1648
+ media_type="text/event-stream",
1649
+ headers={
1650
+ "Cache-Control": "no-cache",
1651
+ "Connection": "keep-alive",
1652
+ "Access-Control-Allow-Origin": "*",
1653
+ "Access-Control-Allow-Headers": "*"
1654
+ }
1655
+ )
1656
+
1657
+ @app.delete("/live-pdf/close/{session_id}")
1658
+ async def close_live_pdf_session(session_id: str):
1659
+ """Close and cleanup a live PDF session"""
1660
+ try:
1661
+ live_pdf_generator.close_session(session_id)
1662
+
1663
+ return {
1664
+ "success": True,
1665
+ "message": "Live PDF session closed successfully"
1666
+ }
1667
+
1668
+ except Exception as e:
1669
+ raise HTTPException(status_code=500, detail=f"Failed to close session: {str(e)}")
1670
+ @app.get("/")
1671
+ def root():
1672
+ return {"status": "FastAPI is running 🚀"}
1673
+
1674
+ # @app.get("/test-toc")
1675
+ # def test_toc():
1676
+ # """Test endpoint to verify TOC generation"""
1677
+ # from app.main import generate_table_of_contents_after_content, ExportSection
1678
+
1679
+ # # Create test sections
1680
+ # test_sections = [
1681
+ # ExportSection(
1682
+ # key="prompt_ExecutiveSummary",
1683
+ # title="Executive Summary",
1684
+ # content="# Executive Summary\n\nThis is a test executive summary with some content."
1685
+ # ),
1686
+ # ExportSection(
1687
+ # key="prompt_CompanyProfile",
1688
+ # title="Company Profile",
1689
+ # content="# Company Profile\n\nThis is a test company profile section."
1690
+ # ),
1691
+ # ExportSection(
1692
+ # key="prompt_MarketAnalysis",
1693
+ # title="Market Analysis",
1694
+ # content="# Market Analysis\n\nThis is a test market analysis section."
1695
+ # )
1696
+ # ]
1697
+
1698
+ # # Generate TOC
1699
+ # toc_items = generate_table_of_contents_after_content(test_sections, {}, "Test Business")
1700
+
1701
+ # return {
1702
+ # "test_sections": [{"title": s.title, "key": s.key, "content_length": len(s.content)} for s in test_sections],
1703
+ # "toc_items": toc_items,
1704
+ # "toc_count": len(toc_items)
1705
+ # }
1706
+
1707
+ # @app.get("/health")
1708
+ # def health_check():
1709
+ # hf_token = os.getenv("HUGGINGFACEHUB_API_TOKEN")
1710
+ # openai_api_key = os.getenv("OPENAI_API_KEY")
1711
+
1712
+ # status = {
1713
+ # "api": "healthy",
1714
+ # "models": {}
1715
+ # }
1716
+
1717
+ # models_to_check = ["Mistral", "Qwen-2.5", "Llama", "Gemma", "Phi-3"]
1718
+
1719
+ # for model_name in models_to_check:
1720
+ # try:
1721
+ # if model_name == "":
1722
+ # client = InferenceClient(provider="hf-inference", api_key=hf_token)
1723
+ # client.model_info("mistralai/Mistral-7B-Instruct-v0.3")
1724
+ # status["models"][model_name] = "available"
1725
+ # elif model_name == "Qwen-2.5":
1726
+ # client = InferenceClient(provider="hf-inference", api_key=hf_token)
1727
+ # client.model_info("Qwen/Qwen2.5-7B-Instruct")
1728
+ # status["models"][model_name] = "available"
1729
+ # elif model_name == "Llama":
1730
+ # client = InferenceClient(provider="hf-inference", api_key=hf_token)
1731
+ # client.model_info("meta-llama/Llama-2-7b-chat-hf")
1732
+ # status["models"][model_name] = "available"
1733
+ # else:
1734
+ # status["models"][model_name] = "not checked"
1735
+ # except Exception as e:
1736
+ # status["models"][model_name] = f"error: {str(e)}"
1737
+
1738
+ # status["models"]["GPT"] = "available" if openai_api_key else "unavailable (missing API key)"
1739
+ # return status
1740
+
1741
+
app/models.py ADDED
@@ -0,0 +1,61 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
+ businessPlanId: str
36
+ sectionKey: str
37
+ businessIdea: str
38
+ countryCode: Optional[str] = None
39
+ defaultCountry: Optional[str] = None
40
+
41
+ class BatchGenerateRequest(BaseModel):
42
+ """Request model for batch generation"""
43
+ businessPlanId: str
44
+ businessIdea: str
45
+ sections: List[str]
46
+ countryCode: Optional[str] = None
47
+ defaultCountry: Optional[str] = None
48
+
49
+ class GenerateRequest(BaseModel):
50
+ """Request model for single section generation"""
51
+ businessPlanId: str
52
+ sectionKey: str
53
+ businessIdea: str
54
+ countryCode: Optional[str] = None
55
+ defaultCountry: Optional[str] = None
56
+
57
+ class CreatePlanRequest(BaseModel):
58
+ """Request model for creating a new business plan"""
59
+ businessIdea: str
60
+ countryCode: Optional[str] = None
61
+ defaultCountry: 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
package-lock.json ADDED
@@ -0,0 +1,6 @@
 
 
 
 
 
 
 
1
+ {
2
+ "name": "planify",
3
+ "lockfileVersion": 3,
4
+ "requires": true,
5
+ "packages": {}
6
+ }
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
16
+ Pillow==10.0.0
17
+ requests
18
+ PyJWT
19
+ matplotlib
20
+ numpy