import os import asyncio import logging from typing import List, Dict, Any, Optional import re import time import base64 import io import tempfile # CRITICAL FIX: Configure matplotlib backend BEFORE importing matplotlib import matplotlib matplotlib.use('Agg') # Use non-interactive backend for server environments import matplotlib.pyplot as plt import numpy as np from dotenv import load_dotenv load_dotenv() # Load environment variables from .env into os.environ from fastapi import FastAPI, HTTPException, Request from fastapi.middleware.cors import CORSMiddleware from fastapi import Query from fastapi.responses import FileResponse from pydantic import BaseModel from fastapi import Depends, HTTPException, status from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials # Import LangChain components from langchain_openai import ChatOpenAI from langchain.prompts import PromptTemplate from langchain.schema.runnable import RunnableSequence from langchain.schema import AIMessage # Import the base LLM class to build our custom wrapper from langchain.llms.base import LLM from huggingface_hub import InferenceClient import jwt as PyJWT import asyncpg import uuid from contextlib import asynccontextmanager # Set up logging logging.basicConfig(level=logging.INFO) # Database connection pool DB_POOL = None def get_db_pool(): """Get the database connection pool""" global DB_POOL return DB_POOL def set_db_pool(pool): """Set the database connection pool""" global DB_POOL DB_POOL = pool return DB_POOL # JWT verification function def get_user_id_from_request(request: Request) -> Optional[str]: """Extract and verify user ID from JWT token in request headers""" try: auth_header = request.headers.get("Authorization") if not auth_header or not auth_header.startswith("Bearer "): return None token = auth_header.split(" ")[1] # Supabase JWT verification SUPABASE_JWT_SECRET = os.getenv("SUPABASE_JWT_SECRET") SUPABASE_JWT_AUD = os.getenv("SUPABASE_JWT_AUD") # optional; e.g. "authenticated" if not token or not SUPABASE_JWT_SECRET: return None try: if SUPABASE_JWT_AUD: payload = PyJWT.decode(token, SUPABASE_JWT_SECRET, algorithms=["HS256"], audience=SUPABASE_JWT_AUD) else: payload = PyJWT.decode(token, SUPABASE_JWT_SECRET, algorithms=["HS256"]) return payload.get("sub") # user ID from JWT except PyJWT.InvalidTokenError: return None except Exception as e: logging.warning(f"JWT verification failed: {e}") return None # Check for required environment variables def check_environment_variables(): hf_token = os.getenv("HUGGINGFACEHUB_API_TOKEN") if not hf_token: logging.warning("⚠️ HUGGINGFACEHUB_API_TOKEN not set. Hugging Face models will not work properly.") else: logging.info("✅ HUGGINGFACEHUB_API_TOKEN is set") openai_api_key = os.getenv("OPENAI_API_KEY") if not openai_api_key: logging.warning("⚠️ OPENAI_API_KEY not set. OpenAI GPT models will not work properly.") else: logging.info("✅ OPENAI_API_KEY is set") # Check Supabase configuration supabase_url = os.getenv("DATABASE_URL") supabase_key = os.getenv("SUPABASE_JWT_SECRET") if not supabase_url or not supabase_key: logging.warning("⚠️ DATABASE_URL or SUPABASE_KEY not set. Database persistence will not work.") else: logging.info("✅ Supabase configuration is set") # Check JWT configuration jwt_secret = os.getenv("SUPABASE_JWT_SECRET") if not jwt_secret: logging.warning("⚠️ SUPABASE_JWT_SECRET not set. JWT verification will not work.") else: logging.info("✅ JWT configuration is set") # Note: UploadThing is now handled by frontend, not server-side logging.info("ℹ️ UploadThing uploads are handled by frontend using document data from server") # Run the environment variable check check_environment_variables() # Display information about model size limitations logging.info("=" * 80) logging.info("MODEL SIZE LIMITATIONS:") logging.info("The free tier of Hugging Face Inference API limits models to 10GB.") logging.info("Large models like Qwen-2.5-7B (15GB) and Llama-2-7B (13GB) exceed this limit.") logging.info("We've configured smaller alternative models as replacements.") logging.info("For full-sized models, upgrade to Hugging Face Pro subscription.") logging.info("=" * 80) # Supabase connection setup SUPABASE_URL = os.getenv("DATABASE_URL") SUPABASE_KEY = os.getenv("SUPABASE_JWT_SECRET") # JWT security security = HTTPBearer() # Create the FastAPI app app = FastAPI() # Add logging middleware @app.middleware("http") async def log_requests(request: Request, call_next): start_time = time.time() response = await call_next(request) process_time = time.time() - start_time logging.info(f"Request: {request.method} {request.url.path} - Status: {response.status_code} - Time: {process_time:.2f}s") return response # Configure CORS for Hugging Face Spaces app.add_middleware( CORSMiddleware, allow_origins=["*"], allow_credentials=True, allow_methods=["*"], allow_headers=["*"], expose_headers=["*"] ) # Allow all origins (adjust for production usage) # app.add_middleware( # CORSMiddleware, # allow_origins=["*"], # allow_credentials=True, # allow_methods=["*"], # allow_headers=["*"], # ) # Fixed list of business questions (order matters) QUESTIONS = [ "What is your business name?", "What product or service do you offer?", "Who is your target customer?", "What problem does your business solve?", "Who are your competitors?", "What is your unique value proposition?", "What is your pricing strategy?", "What are your short-term goals?", "What are your long-term goals?", "How will you acquire customers?", ] # Data model for incoming business plan generation requests class GenerateRequest(BaseModel): answers: List[str] model: str prompt: str # The prompt template from the database promptVariables: Dict[str, str] # Variables to fill in the template provider: str # The provider (e.g., "hf-inference", "together") temperature: float # Temperature setting maxTokens: int # Max tokens setting # NEW — optional, for persistence & ownership businessPlanId: Optional[str] = None planType: Optional[str] = "free" userId: Optional[str] = None # will be overridden by verified JWT anonymousId: Optional[str] = None countryCode: Optional[str] = None sectionKey: Optional[str] = None # e.g. "prompt_ExecutiveSummary" # Data models for HF Export functionality class ExportSection(BaseModel): key: str title: str content: str class ExportRequest(BaseModel): summary: str sections: List[ExportSection] businessIdea: Optional[str] = None isFullPlan: Optional[bool] = False format: str # "pdf" or "word" chartImages: Optional[List[str]] = None # Base64 encoded images class ExportResponse(BaseModel): success: bool downloadUrl: str filename: str size: int message: str documentData: Optional[str] = None # Base64 encoded document data for frontend upload # Database helper constants SECTION_MAP = { "prompt_ExecutiveSummary": ("executiveSummary", "executiveSummaryComplete", "executiveSummaryGenerating"), "prompt_CompanyProfile": ("companyProfile", "companyProfileComplete", "companyProfileGenerating"), "prompt_MarketAnalysis": ("marketAnalysis", "marketAnalysisComplete", "marketAnalysisGenerating"), "prompt_ProductOrService": ("productOrService", "productOrServiceComplete", "productOrServiceGenerating"), "prompt_BusinessModel": ("businessModel", "businessModelComplete", "businessModelGenerating"), "prompt_MarketingGrowth": ("marketingGrowth", "marketingGrowthComplete", "marketingGrowthGenerating"), "prompt_OperationsPlan": ("operationsPlan", "operationsPlanComplete", "operationsPlanGenerating"), "prompt_ManagementTeam": ("managementTeam", "managementTeamComplete", "managementTeamGenerating"), "prompt_FinancialPlanFunding": ("financialPlanFunding", "financialPlanFundingComplete", "financialPlanFundingGenerating"), } SECTION_ORDER = [ "Executive Summary", "Company Profile", "Market Analysis", "Product or Service", "Business Model", "Marketing & Growth", "Operations Plan", "Management Team", "Financial Plan & Funding" ] def sort_sections_by_order(sections: List[ExportSection]) -> List[ExportSection]: """Sort sections according to the predefined SECTION_ORDER to ensure correct numerical ordering""" # Create a mapping from section title to order index order_map = {title: index for index, title in enumerate(SECTION_ORDER)} def get_section_order(section: ExportSection) -> int: # Try to match by exact title first if section.title in order_map: order_index = order_map[section.title] return order_index # Try case-insensitive matching section_title_lower = section.title.lower() for order_title, order_index in order_map.items(): if order_title.lower() == section_title_lower: return order_index # Try to match by key (remove 'prompt_' prefix if present) key_clean = section.key.replace('prompt_', '') if section.key.startswith('prompt_') else section.key if key_clean in order_map: order_index = order_map[key_clean] return order_index # Try partial matching for keys for order_title, order_index in order_map.items(): if key_clean.lower() in order_title.lower() or order_title.lower() in key_clean.lower(): return order_index # If no match found, put at the end return len(SECTION_ORDER) # Sort sections by their order sorted_sections = sorted(sections, key=get_section_order) return sorted_sections def extract_summary_from_markdown(markdown_text: str, max_length: int = 200) -> str: """Extract a summary from markdown text, removing headers and formatting""" if not markdown_text: return "" # Try to find the first header as a title lines = markdown_text.split('\n') for line in lines: if line.strip().startswith('#'): header_text = re.sub(r'^#+\s*', '', line.strip()) if header_text: return header_text[:max_length] # Fallback to first non-empty line for line in lines: clean_line = re.sub(r'[*_`~#]', '', line.strip()) if clean_line: return clean_line[:max_length] return "Generated Business Plan" async def _ensure_business_plan(pool, *, bp_id: Optional[str], summary: str, full_plan: str, model: str, answers: List[str], plan_type: Optional[str], user_id: Optional[str], anon_id: Optional[str], country_code: Optional[str]) -> str: """Ensure a business plan exists and return its ID""" async with pool.acquire() as conn: if bp_id: exists = await conn.fetchval('SELECT 1 FROM "BusinessPlan" WHERE id = $1', bp_id) if exists: await conn.execute( 'UPDATE "BusinessPlan" SET ' '"summary"=$1, "fullPlan"=$2, "model"=$3, "answers"=$4, ' '"planType"=COALESCE($5,\'free\'), "countryCode"=$6, ' '"updatedAt"=now(), "userId"=COALESCE($7,"userId") ' 'WHERE id=$8', summary, full_plan, model, answers, plan_type, country_code, user_id, bp_id ) return bp_id new_id = str(uuid.uuid4()) row = await conn.fetchrow( 'INSERT INTO "BusinessPlan" ' '("id","summary","fullPlan","model","answers","planType","userId","anonymousId","countryCode","updatedAt") ' 'VALUES ($1,$2,$3,$4,$5,COALESCE($6,\'free\'),$7,$8,$9, now()) ' 'RETURNING id', new_id, summary, full_plan, model, answers, plan_type, user_id, anon_id, country_code ) return row["id"] async def _upsert_section(pool, *, business_plan_id: str, section_key: str, content: str) -> None: """Update or insert a business plan section""" if section_key not in SECTION_MAP: logging.warning(f"Unknown section_key '{section_key}', skipping section save.") return col_content, col_complete, col_generating = SECTION_MAP[section_key] set_clause = f'"{col_content}" = $2, "{col_complete}" = TRUE, "{col_generating}" = FALSE' async with pool.acquire() as conn: # ensure row exists with a generated id await conn.execute( 'INSERT INTO "BusinessPlanSection" ("id","businessPlanId") ' 'VALUES ($1,$2) ' 'ON CONFLICT ("businessPlanId") DO NOTHING', str(uuid.uuid4()), business_plan_id ) # update content + flags await conn.execute( f'UPDATE "BusinessPlanSection" SET {set_clause} WHERE "businessPlanId" = $1', business_plan_id, content ) async def _recompute_full_plan(pool, *, business_plan_id: str) -> str: """Recompute the full plan from all sections""" async with pool.acquire() as conn: row = await conn.fetchrow( 'SELECT * FROM "BusinessPlanSection" WHERE "businessPlanId" = $1', business_plan_id ) if not row: return "" parts = [] for col in SECTION_ORDER: txt = row.get(col) if txt: parts.append(txt.strip()) full_plan = "\n\n".join(parts) await conn.execute( 'UPDATE "BusinessPlan" SET "fullPlan"=$1, "updatedAt"=now() WHERE id=$2', full_plan, business_plan_id ) return full_plan def _all_sections_present(row: asyncpg.Record) -> bool: """Check if all required sections are present""" return all(bool(row.get(col)) for col in SECTION_ORDER) def clean_markdown_text(text: str) -> str: """Clean markdown formatting for PDF generation - PRESERVE HEADINGS""" if not text: return "" # Remove markdown formatting BUT PRESERVE HEADING STRUCTURE cleaned = text # PRESERVE HEADINGS - don't strip the # markers, keep them for rendering # We'll handle styling at render time instead of stripping them # Remove bold, italic, code, strikethrough cleaned = re.sub(r'\*\*(.*?)\*\*', r'\1', cleaned) # Remove bold cleaned = re.sub(r'\*(.*?)\*', r'\1', cleaned) # Remove italic cleaned = re.sub(r'`(.*?)`', r'\1', cleaned) # Remove code cleaned = re.sub(r'~~(.*?)~~', r'\1', cleaned) # Remove strikethrough # Remove links (keep text) cleaned = re.sub(r'\[([^\]]+)\]\([^)]+\)', r'\1', cleaned) # Remove links, keep text cleaned = re.sub(r'!\[([^\]]*)\]\([^)]+\)', '', cleaned) # Remove images # Remove list markers (more aggressive) cleaned = re.sub(r'^\s*[-*+]\s*', '', cleaned, flags=re.MULTILINE) # Remove list markers cleaned = re.sub(r'^\s*\d+\.\s*', '', cleaned, flags=re.MULTILINE) # Remove numbered list markers cleaned = re.sub(r'^\s*[-*+]\s*', '', cleaned, flags=re.MULTILINE) # Remove any remaining list markers # Remove blockquotes cleaned = re.sub(r'^\s*>\s*', '', cleaned, flags=re.MULTILINE) # Remove blockquotes # Remove horizontal rules cleaned = re.sub(r'^\s*[-*_]{3,}\s*$', '', cleaned, flags=re.MULTILINE) # Remove horizontal rules # Remove code blocks cleaned = re.sub(r'```[\s\S]*?```', '', cleaned) # Remove code blocks cleaned = re.sub(r'`.*?`', '', cleaned) # Remove any remaining inline code # Remove emphasis markers cleaned = re.sub(r'_{1,2}(.*?)_{1,2}', r'\1', cleaned) # Remove underscores cleaned = re.sub(r'\*{1,2}(.*?)\*{1,2}', r'\1', cleaned) # Remove asterisks # REMOVE RAW CHART DATA MARKERS (CRITICAL FIX) cleaned = re.sub(r'.*?', '', cleaned, flags=re.DOTALL) cleaned = re.sub(r'.*$', '', cleaned, flags=re.DOTALL) cleaned = re.sub(r'', '', cleaned) # Remove any remaining HTML-like tags cleaned = re.sub(r'<[^>]+>', '', cleaned) # Clean up extra whitespace and formatting cleaned = re.sub(r'\n\s*\n\s*\n+', '\n\n', cleaned) # Remove excessive line breaks cleaned = re.sub(r'^\s+', '', cleaned, flags=re.MULTILINE) # Remove leading whitespace cleaned = re.sub(r'\s+$', '', cleaned, flags=re.MULTILINE) # Remove trailing whitespace cleaned = re.sub(r' +', ' ', cleaned) # Replace multiple spaces with single space # Final cleanup cleaned = cleaned.strip() return cleaned def clean_text_for_pdf(text: str) -> str: """Clean text specifically for PDF generation, handling Unicode issues""" if not text: return "" # First clean markdown cleaned = clean_markdown_text(text) # Replace problematic Unicode characters with ASCII equivalents unicode_replacements = { '\u2019': "'", # Right single quotation mark '\u2018': "'", # Left single quotation mark '\u201C': '"', # Left double quotation mark '\u201D': '"', # Right double quotation mark '\u2013': '-', # En dash '\u2014': '--', # Em dash '\u2022': '•', # Bullet '\u2026': '...', # Horizontal ellipsis '\u00A0': ' ', # Non-breaking space '\u00B0': '°', # Degree sign '\u00AE': '(R)', # Registered trademark '\u2122': '(TM)', # Trademark '\u00A9': '(C)', # Copyright } for unicode_char, replacement in unicode_replacements.items(): cleaned = cleaned.replace(unicode_char, replacement) return cleaned def generate_model_output(model: str, provider: str, api_key: str, prompt: str, max_tokens: int = 4000) -> str: """ A helper function that wraps the Hugging Face Inference API call. """ try: logging.info(f"Initializing InferenceClient with provider: {provider}") client = InferenceClient(provider=provider, api_key=api_key) logging.info(f"Sending request to model: {model} with prompt length: {len(prompt)} and max_tokens: {max_tokens}") completion = client.chat.completions.create( model=model, messages=[{"role": "user", "content": prompt}], max_tokens=max_tokens, ) logging.info(f"Successfully received response from model: {model} with content length: {len(completion.choices[0].message.content)}") return completion.choices[0].message.content except Exception as e: error_message = f"Error generating output with model {model}: {str(e)}" logging.error(error_message) # Re-raise the exception with more context raise Exception(error_message) from e ############################################################################### # HFInferenceLLM: A wrapper for Hugging Face models that matches the LLM interface. ############################################################################### class HFInferenceLLM(LLM): model: str = None provider: str = "hf-inference" api_key: str = "" max_tokens: int = 4000 # Increased from 500 to 4000 by default def __init__(self, model: str, provider: str = "hf-inference", api_key: str = "", max_tokens: int = 4000): super().__init__() self.model = model self.provider = provider self.api_key = api_key self.max_tokens = max_tokens # Validate API key if not api_key: logging.error(f"No API key provided for model {model}") raise ValueError(f"API key is required for Hugging Face Inference API access to {model}") # Check if model is available try: # Create a client to validate connection client = InferenceClient(provider=provider, api_key=api_key) logging.info(f"Successfully initialized client for model: {model}") except Exception as e: logging.error(f"Failed to initialize client for model {model}: {str(e)}") raise ValueError(f"Could not initialize Hugging Face client for {model}: {str(e)}") @property def _llm_type(self) -> str: return "hf_inference" def _call(self, prompt: str, stop: Optional[List[str]] = None) -> str: """ Calls the Hugging Face API using the provided prompt. The `stop` parameter is not implemented (or forwarded) here. """ return generate_model_output( model=self.model, provider=self.provider, api_key=self.api_key, prompt=prompt, max_tokens=self.max_tokens ) def get_num_tokens(self, prompt: str) -> int: """ A simple implementation that counts tokens as space-separated words. You may replace this with a more accurate token counter. """ return len(prompt.split()) ############################################################################### # get_llm: Return an LLM instance based on the selected model. ############################################################################### def get_llm(model_name: str, provider: str = "hf-inference"): hf_token = os.getenv("HUGGINGFACEHUB_API_TOKEN") if not hf_token: logging.error("HUGGINGFACEHUB_API_TOKEN environment variable not set") raise ValueError("HUGGINGFACEHUB_API_TOKEN environment variable is required for Hugging Face models") if model_name == "BPGenerateAI": openai_api_key = os.getenv("OPENAI_API_KEY") if not openai_api_key: logging.error("OPENAI_API_KEY environment variable not set") raise ValueError("OPENAI_API_KEY environment variable is required for GPT models") return ChatOpenAI( model_name="gpt-4.1-mini", temperature=0.7, max_tokens=6000, openai_api_key=openai_api_key ) elif model_name == "BPSuggestionsAI": openai_api_key = os.getenv("OPENAI_API_KEY") if not openai_api_key: logging.error("OPENAI_API_KEY environment variable not set") raise ValueError("OPENAI_API_KEY environment variable is required for GPT models") return ChatOpenAI( model_name="gpt-4.1-nano", temperature=0.7, max_tokens=4000, openai_api_key=openai_api_key ) elif model_name.lower() == "gpt-4.1-nano": openai_api_key = os.getenv("OPENAI_API_KEY") if not openai_api_key: raise ValueError("OPENAI_API_KEY is required for GPT models") return ChatOpenAI( model_name="gpt-4.1-nano", temperature=0.7, max_tokens=4000, openai_api_key=openai_api_key ) else: # For all other models, use the specified provider return HFInferenceLLM( model=model_name, provider=provider, api_key=hf_token, max_tokens=4000 ) ############################################################################### # FastAPI Endpoints ############################################################################### @app.get("/suggestions", response_model=Dict[str, Any]) async def get_suggestions( business_idea: str, model: str, question: str, prompt: str, provider: str, temperature: float, maxTokens: int, exclude: List[str] = Query([]) ) -> Dict[str, Any]: try: # Use the prompt from the frontend formatted_prompt = prompt.format( business_idea=business_idea, question=question, exclude=", ".join(exclude) if exclude else "" ) # Get the LLM instance with frontend-provided configuration llm = get_llm(model, provider) if hasattr(llm, 'temperature'): llm.temperature = temperature if hasattr(llm, 'max_tokens'): llm.max_tokens = maxTokens suggestion_prompt = PromptTemplate( input_variables=[], template=formatted_prompt ) suggestion_chain: RunnableSequence = suggestion_prompt | llm raw_result = await asyncio.to_thread(suggestion_chain.invoke, {}) # Normalize AIMessage or list[AIMessage] → plain string if isinstance(raw_result, AIMessage): raw_text = raw_result.content elif ( isinstance(raw_result, (list, tuple)) and raw_result and isinstance(raw_result[0], AIMessage) ): raw_text = "\n".join(msg.content for msg in raw_result) else: raw_text = str(raw_result) # Clean up the suggestions suggestion_array = [ re.sub(r'^\s*[\-\d\.\)\s]+', '', line).strip() for line in raw_text.split("\n") if line.strip() ] if not suggestion_array: suggestion_array = ["No suggestions available"] return {"suggestions": suggestion_array} except Exception as e: raise HTTPException(status_code=500, detail=f"Error generating suggestions: {str(e)}") @app.post("/generate") async def generate_business_plan(request: Request, data: GenerateRequest): """ Generate a business plan using the provided prompt template and variables. """ try: # Get the LLM instance based on the selected model and provider logging.info(f"Initializing model: {data.model} with provider: {data.provider}") llm_selected = get_llm(data.model) # Set the temperature and max tokens from the request if hasattr(llm_selected, 'temperature'): llm_selected.temperature = data.temperature if hasattr(llm_selected, 'max_tokens'): llm_selected.max_tokens = data.maxTokens # Create the prompt template plan_prompt = PromptTemplate( input_variables=list(data.promptVariables.keys()), template=data.prompt ) # Create the chain plan_chain: RunnableSequence = plan_prompt | llm_selected logging.info(f"Generating business plan with model: {data.model}") try: # Invoke the chain with the variables raw_plan = await asyncio.to_thread( plan_chain.invoke, data.promptVariables ) # ── NEW: derive userId from JWT (ignore client-sent userId) verified_user_id = get_user_id_from_request(request) if verified_user_id: data.userId = verified_user_id # enforce verified owner # else keep anonymousId path if provided # Unwrap AIMessage or list of AIMessages into a single markdown string if isinstance(raw_plan, AIMessage): plan_text = raw_plan.content elif isinstance(raw_plan, (list, tuple)) and raw_plan and isinstance(raw_plan[0], AIMessage): plan_text = "\n".join(msg.content for msg in raw_plan) else: plan_text = str(raw_plan) # Extract summary from the generated plan summary = extract_summary_from_markdown(plan_text) # ── Persist to database if pool is available pool = getattr(app.state, "db_pool", None) if not pool: logging.warning("DB pool not available; skipping persistence.") return { "summary": summary or "Generated Business Plan", "plan": plan_text, } # If generating a specific section if data.sectionKey: business_plan_id = await _ensure_business_plan( pool, bp_id=data.businessPlanId, summary=summary, full_plan="", # recomputed after section save model=data.model, answers=data.answers, plan_type=data.planType, user_id=data.userId, anon_id=data.anonymousId, country_code=data.countryCode, ) await _upsert_section( pool, business_plan_id=business_plan_id, section_key=data.sectionKey, content=plan_text ) # Recompute full plan after section write full_plan = await _recompute_full_plan(pool, business_plan_id=business_plan_id) # Optional: mark complete if all sections present is_complete = False async with pool.acquire() as conn: row = await conn.fetchrow( 'SELECT * FROM "BusinessPlanSection" WHERE "businessPlanId"=$1', business_plan_id ) if row: is_complete = _all_sections_present(row) return { "summary": summary, "plan": plan_text, "businessPlanId": business_plan_id, "sections": [{"key": data.sectionKey, "content": plan_text}], "isComplete": is_complete } # Non-section — treat as a whole plan business_plan_id = await _ensure_business_plan( pool, bp_id=data.businessPlanId, summary=summary, full_plan=plan_text, model=data.model, answers=data.answers, plan_type=data.planType, user_id=data.userId, anon_id=data.anonymousId, country_code=data.countryCode, ) return { "summary": summary, "plan": plan_text, "businessPlanId": business_plan_id } except Exception as e: error_message = f"Error generating business plan with model {data.model}: {str(e)}" logging.error(error_message) raise HTTPException(status_code=500, detail=error_message) except Exception as e: error_message = f"Error initializing model {data.model}: {str(e)}" logging.error(error_message) raise HTTPException(status_code=500, detail=error_message) def create_pdf_document(request: ExportRequest): """Create a PROFESSIONAL PDF document from the business plan data using FPDF2 NOTE: Now uses matplotlib charts exclusively - old base64 chart logic commented out""" try: from fpdf import FPDF except ImportError: raise Exception("fpdf2 is not installed. Please install it with: pip install fpdf2") # Filter out empty sections to prevent blank pages filtered_sections = filter_empty_sections(request.sections) if not filtered_sections: raise Exception("No valid sections found after filtering. All sections appear to be empty.") # Generate table of contents toc_items = generate_table_of_contents(filtered_sections, request.businessIdea) # Create PDF with professional settings pdf = FPDF() pdf.set_auto_page_break(auto=True, margin=15) # Reduced margin to allow more charts per page pdf.add_page() # Professional color scheme - Grey, Black, and White primary_color = (0, 0, 0) # Black - for headings header_box_color = (128, 128, 128) # Darker gray - for front page header box accent_color = (0, 0, 0) # Black - for accent lines (changed from blue) text_color = (51, 51, 51) # Dark gray light_gray = (128, 128, 128) # Light gray # Professional fonts (fallback to Arial if not available) title_font = 'Arial' body_font = 'Arial' # Helper functions for consistent styling def add_header(pdf, text, size=24, y_offset=25, primary_color=(0,0,0), accent_color=(0,0,0)): pdf.set_font("Arial", "B", size) pdf.set_text_color(*primary_color) pdf.set_xy(25, y_offset) pdf.cell(0, 18, text, ln=True) # Calculate text width and make line autofit text_width = pdf.get_string_width(text) line_width = min(text_width + 10, 160) # Add 10mm padding, max 160mm (page width - margins) pdf.set_draw_color(200, 200, 200) # Light gray line pdf.set_line_width(0.3) # Thin line pdf.line(25, y_offset + 18, 25 + line_width, y_offset + 18) return y_offset + 25 def add_subheader(pdf, text, size=16, y_offset=None, primary_color=(0,0,0)): if y_offset is None: y_offset = pdf.get_y() + 10 pdf.set_font("Arial", "B", size) pdf.set_text_color(*primary_color) pdf.set_xy(25, y_offset) pdf.cell(0, 14, text, ln=True) # Calculate text width and make line autofit text_width = pdf.get_string_width(text) line_width = min(text_width + 8, 160) # Add 8mm padding, max 160mm (page width - margins) pdf.set_draw_color(200,200,200) pdf.set_line_width(0.4) pdf.line(25, y_offset + 14, 25 + line_width, y_offset + 14) return y_offset + 20 def add_body_text(pdf, text, y_offset=None, text_color=(51,51,51)): if y_offset is None: y_offset = pdf.get_y() + 5 pdf.set_font("Arial", "", 12) pdf.set_text_color(*text_color) pdf.set_xy(25, y_offset) pdf.multi_cell(160, 8, text) return pdf.get_y() def check_page_break(pdf, required_height=120, bottom_margin=300): """Check if we need a page break and return the Y position to use""" y = pdf.get_y() if y + required_height > bottom_margin: pdf.add_page() return 25 return y # ===== COVER PAGE ===== def add_cover_page(): # PURPLE BACKGROUND - Full page purple gradient effect pdf.set_fill_color(139, 92, 246) # Purple base pdf.rect(0, 0, 210, 297, 'F') # Full page purple # Add purple gradient effect with multiple rectangles pdf.set_fill_color(124, 58, 237) # Darker purple pdf.rect(0, 0, 210, 100, 'F') # Top section pdf.set_fill_color(147, 51, 234) # Medium purple pdf.rect(0, 100, 210, 100, 'F') # Middle section pdf.set_fill_color(168, 85, 247) # Lighter purple pdf.rect(0, 200, 210, 97, 'F') # Bottom section # Company/Project name - WHITE TEXT pdf.set_font(title_font, "B", 32) pdf.set_text_color(255, 255, 255) # Pure white business_name = request.businessIdea or "Business Plan" pdf.ln(15) pdf.cell(0, 25, business_name, ln=True, align='C') # Subtitle - WHITE TEXT pdf.set_font(title_font, "B", 18) pdf.set_text_color(255, 255, 255) # Pure white pdf.cell(0, 12, "Strategic Business Plan", ln=True, align='C') # Add some space pdf.ln(40) # Document info box - WHITE BACKGROUND with purple border pdf.set_fill_color(255, 255, 255) # White background pdf.set_draw_color(139, 92, 246) # Purple border pdf.set_line_width(2) # Thick purple border pdf.rect(25, 120, 160, 80, 'DF') # Filled with border pdf.set_font(body_font, "B", 14) pdf.set_text_color(139, 92, 246) # Purple text for title pdf.set_xy(35, 130) pdf.cell(0, 10, "Document Information", ln=True) pdf.set_font(body_font, "", 12) pdf.set_text_color(51, 51, 51) # Dark text for content pdf.set_xy(35, 145) pdf.cell(0, 8, f"Generated: {time.strftime('%B %d, %Y')}", ln=True) pdf.set_xy(35, 153) pdf.cell(0, 8, f"Format: {request.format.upper()}", ln=True) pdf.set_xy(35, 161) pdf.cell(0, 8, f"Sections: {len(request.sections)}", ln=True) pdf.set_xy(35, 169) pdf.cell(0, 8, f"Charts: {len(request.chartImages) if request.chartImages else 0}", ln=True) # Footer - WHITE TEXT on purple background pdf.set_font(body_font, "", 10) pdf.set_text_color(255, 255, 255) # White text pdf.set_xy(0, 280) pdf.cell(0, 8, "Confidential Business Document", ln=True, align='C') # ===== SECTION PAGES ===== def add_sections_with_charts(): # Parse charts from embedded chart data in section content embedded_charts = parse_embedded_chart_data(filtered_sections) if embedded_charts: # Create actual charts from the parsed data matplotlib_charts = [] chart_info_map = {} # Map section names to their charts for chart_info in embedded_charts: chart_bytes = create_chart_from_data(chart_info, request.businessIdea or "Business") if chart_bytes: matplotlib_charts.append(chart_bytes) # Map this chart to its source section source_section = chart_info["source_section"] if source_section not in chart_info_map: chart_info_map[source_section] = [] chart_info_map[source_section].append({ "chart_bytes": chart_bytes, "title": chart_info["title"], "type": chart_info["type"] }) else: pass else: matplotlib_charts = [] chart_info_map = {} total_charts_placed = 0 # Sort sections according to predefined order sorted_sections = sort_sections_by_order(filtered_sections) for i, section in enumerate(sorted_sections): # Always start each section on a new page pdf.add_page() # Accent line below header (not covering it) pdf.set_fill_color(*accent_color) pdf.rect(0, 15, 210, 2, 'F') # Below header, thinner line # Section title (properly positioned) pdf.set_font(title_font, "B", 20) pdf.set_text_color(*primary_color) pdf.set_xy(25, 25) # Back to normal position pdf.cell(0, 18, section.title, ln=True) # Add subtle line under section title - consistent with subheaders text_width = pdf.get_string_width(section.title) line_width = min(text_width + 8, 160) # Add 8mm padding, max 160mm pdf.set_draw_color(200, 200, 200) # Light gray line pdf.set_line_width(0.3) # Thin line pdf.line(25, pdf.get_y(), 25 + line_width, pdf.get_y()) # Consistent spacing after section title pdf.ln(8) # Section content (properly positioned) pdf.set_font(body_font, "", 12) pdf.set_text_color(*text_color) pdf.set_xy(25, pdf.get_y()) # Use current Y position for consistent spacing # Process content content_text = clean_text_for_pdf(section.content) content_text = convert_currency_symbols_to_iso(content_text) # Remove chart data markers content_text = re.sub(r'.*?', '', content_text, flags=re.DOTALL) content_text = re.sub(r'.*$', '', content_text, flags=re.DOTALL) # Remove the section title from content to prevent duplication (since we already added it above) section_title_clean = section.title.strip() content_lines = content_text.split('\n') filtered_lines = [] for line in content_lines: line_clean = line.strip() # Skip lines that match the section title (with or without markdown) if (line_clean == section_title_clean or line_clean == f"**{section_title_clean}**" or line_clean == f"# {section_title_clean}" or line_clean == f"## {section_title_clean}"): continue filtered_lines.append(line) content_text = '\n'.join(filtered_lines) # Enhance subheadings and remove duplicates content_text = enhance_subheadings_with_bold(content_text) content_text = remove_duplicate_headings(content_text) # Add content with proper markdown rendering if content_text and content_text.strip(): render_markdown_to_pdf(pdf, content_text, primary_color, accent_color, text_color) else: # Add placeholder text to prevent blank page pdf.multi_cell(160, 8, f"Content for {section.title} section is being processed...") # Add minimal spacer only if we have content if content_text and content_text.strip(): pdf.ln(3) # Small space after content # ---- Charts for this section ---- charts_added = 0 # IMPORTANT: initialize to avoid NameError section_charts = chart_info_map.get(section.title, []) if section_charts: # Group charts that can fit on the same page charts_per_page = 0 max_charts_per_page = 5 # Allow up to 5 charts per page for chart_info in section_charts: try: # Check if we need a new page for this chart chart_height_needed = 95 # Chart height (75) + title (20) + minimal spacing # Smart page break logic - allow multiple charts per page current_y = pdf.get_y() charts_per_page += 1 # Only force page break if absolutely necessary - allow up to 5 charts per page if (current_y + chart_height_needed > 290) or (charts_per_page > 5): pdf.add_page() # Header/footer removed for now y_title = 25 # Start at normal position charts_per_page = 1 # Reset counter for new page else: y_title = current_y # Add minimal spacing before chart if not at top of page if y_title > 25: # If not at top of page, add minimal space pdf.ln(3) # Minimal spacing to fit more charts per page # Subheader (title) and subtle rule y_title = add_subheader(pdf, chart_info["title"], 16, y_title) # Centered image with minimal border - optimized for more charts per page img_width = 100 # Optimized for smaller charts (6x4.5 ratio) x = (210 - img_width) / 2 y_img = y_title # Save image to temp file with tempfile.NamedTemporaryFile(delete=False, suffix=".png") as tmp_img: tmp_img.write(chart_info["chart_bytes"]) temp_img_path = tmp_img.name # Chart dimensions (no border needed) chart_height = 75 # Optimized height for 6:4.5 aspect ratio (100 * 4.5/6) chart_width = img_width # Image pdf.image(temp_img_path, x=x, y=y_img, w=chart_width) # Move below image and clean up (spacing based on actual chart height) pdf.ln(chart_height + 5) # Chart height (75) + minimal spacing between charts try: os.remove(temp_img_path) except Exception: pass charts_added += 1 total_charts_placed += 1 except Exception as chart_error: continue else: pass # Each section is already on its own page, no need for complex page break logic # ===== BUILD THE PDF ===== try: # Add all sections try: add_cover_page() except Exception as e: raise Exception(f"Cover page failed: {str(e)}") try: add_table_of_contents_page(pdf, toc_items, request.businessIdea) except Exception as e: raise Exception(f"Table of contents failed: {str(e)}") # Executive summary is now handled as part of the sections # No separate page needed - the first section contains the executive summary try: add_sections_with_charts() except Exception as e: raise Exception(f"Sections with charts failed: {str(e)}") # CRITICAL CHECK: Ensure we have content in the PDF total_pages = pdf.page_no() if total_pages < 2: # Should have at least cover + 1 content page # Add a content page to prevent blank PDF pdf.add_page() pdf.set_font("Arial", "B", 16) pdf.set_text_color(255, 0, 0) pdf.cell(0, 20, "Content Processing Issue", ln=True, align='C') pdf.set_font("Arial", "", 12) pdf.set_text_color(0, 0, 0) pdf.cell(0, 10, "The PDF generation encountered an issue with content processing.", ln=True, align='C') pdf.cell(0, 10, "Please check the server logs for detailed information.", ln=True, align='C') # Get PDF output and convert to bytes try: pdf_output = pdf.output(dest='S') if isinstance(pdf_output, bytearray): pdf_bytes = bytes(pdf_output) else: pdf_bytes = pdf_output.encode('latin-1') if isinstance(pdf_output, str) else pdf_output # CRITICAL SAFETY CHECK: Ensure PDF is not empty if len(pdf_bytes) < 1000: # PDF should be at least 1KB # Try to add some content to prevent blank PDF pdf.add_page() pdf.set_font("Arial", "B", 16) pdf.set_text_color(255, 0, 0) # Red text pdf.cell(0, 20, "PDF Generation Issue Detected", ln=True, align='C') pdf.set_font("Arial", "", 12) pdf.set_text_color(0, 0, 0) pdf.cell(0, 10, "If you see this message, there was an issue with content processing.", ln=True, align='C') pdf.cell(0, 10, "Please check the server logs for details.", ln=True, align='C') # Regenerate PDF with error message pdf_output = pdf.output(dest='S') if isinstance(pdf_output, bytearray): pdf_bytes = bytes(pdf_output) else: pdf_bytes = pdf_output.encode('latin-1') if isinstance(pdf_output, str) else pdf_output return pdf_bytes, f"business_plan_{request.businessIdea or 'export'}.pdf" except Exception as e: raise Exception(f"PDF conversion failed: {str(e)}") except Exception as e: logging.error(f"PDF generation failed: {e}") raise Exception(f"Failed to generate professional PDF: {str(e)}") def create_word_document(request: ExportRequest): """Create a Word document from the business plan data with front page, TOC, and proper page breaks""" try: from docx import Document from docx.shared import Inches, Pt, RGBColor from docx.enum.text import WD_ALIGN_PARAGRAPH, WD_TAB_ALIGNMENT from docx.oxml import OxmlElement from docx.oxml.ns import qn except ImportError: raise Exception("python-docx is not installed. Please install it with: pip install python-docx") # Filter out empty sections to prevent blank pages filtered_sections = filter_empty_sections(request.sections) if not filtered_sections: raise Exception("No valid sections found after filtering. All sections appear to be empty.") doc = Document() # ===== FRONT PAGE ===== # Add top spacing doc.add_paragraph() doc.add_paragraph() # Business name as main title with enhanced styling - PURPLE THEME title = doc.add_heading(request.businessIdea or "Business Plan", 0) title.alignment = WD_ALIGN_PARAGRAPH.CENTER # Style the title with larger font and PURPLE color for run in title.runs: run.font.size = Pt(36) run.font.color.rgb = RGBColor(139, 92, 246) # PURPLE # Add decorative line under title - PURPLE doc.add_paragraph() line_para = doc.add_paragraph() line_para.paragraph_format.alignment = WD_ALIGN_PARAGRAPH.CENTER line_run = line_para.add_run("─" * 50) line_run.font.size = Pt(12) line_run.font.color.rgb = RGBColor(139, 92, 246) # PURPLE # Add subtitle with enhanced styling - PURPLE doc.add_paragraph() subtitle = doc.add_paragraph("Professional Business Plan Document") subtitle.alignment = WD_ALIGN_PARAGRAPH.CENTER subtitle_run = subtitle.runs[0] subtitle_run.font.size = Pt(18) subtitle_run.font.color.rgb = RGBColor(124, 58, 237) # DARKER PURPLE subtitle_run.italic = True # Add more spacing doc.add_paragraph() doc.add_paragraph() doc.add_paragraph() # Add document info in a styled box - PURPLE BORDER info_para = doc.add_paragraph() info_para.paragraph_format.alignment = WD_ALIGN_PARAGRAPH.CENTER # Add background color and border styling info_run = info_para.add_run("Prepared by: Business Plan Generator") info_run.font.size = Pt(14) info_run.font.color.rgb = RGBColor(139, 92, 246) # PURPLE doc.add_paragraph() date_para = doc.add_paragraph() date_para.paragraph_format.alignment = WD_ALIGN_PARAGRAPH.CENTER date_run = date_para.add_run(f"Date: {time.strftime('%B %Y')}") date_run.font.size = Pt(14) date_run.font.color.rgb = RGBColor(139, 92, 246) # PURPLE # Add more spacing before footer doc.add_paragraph() doc.add_paragraph() doc.add_paragraph() doc.add_paragraph() # Add footer with enhanced styling - PURPLE footer = doc.add_paragraph("Confidential Business Information") footer.alignment = WD_ALIGN_PARAGRAPH.CENTER footer_run = footer.runs[0] footer_run.font.size = Pt(12) footer_run.font.color.rgb = RGBColor(139, 92, 246) # PURPLE footer_run.bold = True # Add decorative line above footer - PURPLE doc.add_paragraph() footer_line_para = doc.add_paragraph() footer_line_para.paragraph_format.alignment = WD_ALIGN_PARAGRAPH.CENTER footer_line_run = footer_line_para.add_run("─" * 40) footer_line_run.font.size = Pt(8) footer_line_run.font.color.rgb = RGBColor(139, 92, 246) # PURPLE # ===== TABLE OF CONTENTS ===== # Add page break before TOC doc.add_page_break() # Generate TOC entries toc_items = generate_table_of_contents(filtered_sections, request.businessIdea or "Business") # Add TOC title toc_title = doc.add_heading("Table of Contents", level=1) toc_title.alignment = WD_ALIGN_PARAGRAPH.CENTER # Add decorative line under TOC title doc.add_paragraph() line_para = doc.add_paragraph() line_para.paragraph_format.alignment = WD_ALIGN_PARAGRAPH.CENTER line_run = line_para.add_run("─" * 40) # Decorative line line_run.font.size = Pt(8) line_run.font.color.rgb = None # Use default color # Add spacing before TOC entries doc.add_paragraph() # Add TOC entries with page numbers and dot leaders for item in toc_items: if item["level"] == 1: # Main section - bold, larger font p = doc.add_paragraph() p.paragraph_format.space_after = Pt(8) # Add spacing between main sections # Add section title title_run = p.add_run(item["title"]) title_run.bold = True title_run.font.size = Pt(12) # Add tab character and page number p.add_run("\t") page_run = p.add_run(f"{item['page']}") page_run.font.size = Pt(12) # Set tab position to right-align page numbers tab_stops = p.paragraph_format.tab_stops tab_stops.add_tab_stop(Inches(6.5), WD_TAB_ALIGNMENT.RIGHT) else: # Subsection - regular font, indented p = doc.add_paragraph() p.paragraph_format.space_after = Pt(4) # Smaller spacing for subsections p.paragraph_format.left_indent = Inches(0.3) # Indent subsections # Add subsection title subtitle_run = p.add_run(item["title"]) subtitle_run.font.size = Pt(10) # Add tab character and page number p.add_run("\t") page_run = p.add_run(f"{item['page']}") page_run.font.size = Pt(10) # Set tab position to right-align page numbers tab_stops = p.paragraph_format.tab_stops tab_stops.add_tab_stop(Inches(6.5), WD_TAB_ALIGNMENT.RIGHT) # Executive Summary is now handled as a regular section above # Sort sections according to predefined order sorted_sections = sort_sections_by_order(filtered_sections) # ===== MAIN SECTIONS ===== for i, section in enumerate(sorted_sections): if section.content.strip(): # Add page break before each section (including Executive Summary) doc.add_page_break() # Add section heading doc.add_heading(section.title, level=1) # Clean and process content clean_content = clean_text_for_pdf(section.content) clean_content = convert_currency_symbols_to_iso(clean_content) # Remove chart data markers clean_content = re.sub(r'.*?', '', clean_content, flags=re.DOTALL) clean_content = re.sub(r'.*$', '', clean_content, flags=re.DOTALL) # Remove the section title from content to prevent duplication section_title_clean = section.title.strip() content_lines = clean_content.split('\n') filtered_lines = [] for line in content_lines: line_clean = line.strip() # Skip lines that match the section title (with or without markdown) if (line_clean == section_title_clean or line_clean == f"**{section_title_clean}**" or line_clean == f"# {section_title_clean}" or line_clean == f"## {section_title_clean}"): continue filtered_lines.append(line) clean_content = '\n'.join(filtered_lines) # Render markdown content with proper heading styling render_markdown_to_docx(doc, clean_content) # Add charts for this section if they exist embedded_charts = parse_embedded_chart_data([section]) if embedded_charts: for chart_info in embedded_charts: try: chart_bytes = create_chart_from_data(chart_info, request.businessIdea or "Business") if chart_bytes: # Add chart title chart_title = chart_info["title"] doc.add_heading(f"{chart_title}", level=2) # Save image temporarily with tempfile.NamedTemporaryFile(delete=False, suffix=".png") as tmp_img: tmp_img.write(chart_bytes) temp_img_path = tmp_img.name # Add image to Word document doc.add_picture(temp_img_path, width=Inches(6.0)) # 6 inches width # Clean up temp file os.remove(temp_img_path) except Exception as chart_error: logging.warning(f"Failed to add chart for section '{section.title}': {chart_error}") continue doc.add_paragraph() # Charts are now handled inline with the sections above # Save to bytes buffer = io.BytesIO() doc.save(buffer) buffer.seek(0) doc_bytes = buffer.getvalue() buffer.close() filename = f"business_plan_{request.businessIdea or 'export'}.docx" return doc_bytes, filename @app.post("/export", response_model=ExportResponse) async def export_business_plan(request: ExportRequest): """ Export a business plan in the specified format (PDF or Word). This endpoint creates documents and returns them as base64 data for frontend upload to UploadThing. FLOW: Server generates document → Returns base64 data → Frontend uploads to UploadThing → User gets download link """ try: # Generate unique request ID for tracking request_id = str(id(request)) # Add request fingerprint for duplicate detection request_fingerprint = f"{request.businessIdea}_{request.format}_{len(request.sections)}_{hash(str(request.sections))}" # Check if this is a duplicate request (within 5 seconds) current_time = time.time() if hasattr(export_business_plan, 'last_requests'): # Clean old requests (older than 5 seconds) export_business_plan.last_requests = { fp: timestamp for fp, timestamp in export_business_plan.last_requests.items() if current_time - timestamp < 5 } # Check for duplicate if request_fingerprint in export_business_plan.last_requests: raise HTTPException( status_code=429, detail="Duplicate export request detected. Please wait a few seconds before trying again." ) else: export_business_plan.last_requests = {} # Record this request export_business_plan.last_requests[request_fingerprint] = current_time # Validate request format if request.format not in ["pdf", "word"]: raise HTTPException(status_code=400, detail="Invalid format. Only 'pdf' and 'word' are supported.") # Validate required fields if not request.summary: raise HTTPException(status_code=400, detail="Summary is required for export.") if not request.sections: raise HTTPException(status_code=400, detail="Sections are required for export.") if len(request.sections) == 0: raise HTTPException(status_code=400, detail="At least one section is required for export.") # Check section content for i, section in enumerate(request.sections): if not section.content or not section.content.strip(): raise HTTPException(status_code=400, detail=f"Section '{section.title}' has no content.") logging.info(f"Exporting business plan for: {request.businessIdea} in {request.format.upper()} format") logging.info(f"Request has {len(request.sections)} sections and summary length: {len(request.summary)}") # Check required packages before proceeding try: if request.format == "pdf": import fpdf else: import docx except ImportError as import_error: raise HTTPException( status_code=500, detail=f"Required package not available: {str(import_error)}" ) # Create the document based on format try: if request.format == "pdf": doc_bytes, filename = create_pdf_document(request) else: # word doc_bytes, filename = create_word_document(request) except ImportError as import_error: raise HTTPException( status_code=500, detail=f"Missing required package for {request.format.upper()} generation: {str(import_error)}" ) except Exception as doc_error: logging.error(f"Document creation failed: {doc_error}") raise HTTPException( status_code=500, detail=f"Failed to create {request.format.upper()} document: {str(doc_error)}" ) # Return document data to frontend for UploadThing upload try: # Encode document bytes as base64 for frontend transmission import base64 document_data = base64.b64encode(doc_bytes).decode('utf-8') response = ExportResponse( success=True, downloadUrl="", # Will be set by frontend after UploadThing upload filename=filename, size=len(doc_bytes), message=f"Document generated successfully as {request.format.upper()}. Ready for frontend upload to UploadThing.", documentData=document_data # Base64 encoded document data ) return response except Exception as data_error: logging.error(f"Failed to prepare document data: {data_error}") raise HTTPException( status_code=500, detail=f"Failed to prepare document data: {str(data_error)}" ) except HTTPException: # Re-raise HTTP exceptions (validation errors) raise except Exception as e: # Catch any other unexpected errors error_message = f"Export failed: {str(e)}" logging.error(error_message) raise HTTPException(status_code=500, detail=error_message) @app.get("/download/{filename}") async def download_file(filename: str): """Download endpoint to serve the generated export files""" file_path = os.path.join("temp_exports", filename) if not os.path.exists(file_path): raise HTTPException(status_code=404, detail="File not found") return FileResponse(file_path, filename=filename) @app.get("/") def root(): html_content = """
AI-Powered Business Planning Made Simple