Spaces:
Runtime error
Runtime error
| 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 | |
| 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'<!--CHARTDATASTART-->.*?<!--CHARTDATAEND-->', '', cleaned, flags=re.DOTALL) | |
| cleaned = re.sub(r'<!--CHARTDATASTART-->.*$', '', cleaned, flags=re.DOTALL) | |
| cleaned = re.sub(r'<!--CHARTDATAEND-->', '', 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)}") | |
| 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 | |
| ############################################################################### | |
| 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)}") | |
| 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'<!--CHARTDATASTART-->.*?<!--CHARTDATAEND-->', '', content_text, flags=re.DOTALL) | |
| content_text = re.sub(r'<!--CHARTDATASTART-->.*$', '', 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'<!--CHARTDATASTART-->.*?<!--CHARTDATAEND-->', '', clean_content, flags=re.DOTALL) | |
| clean_content = re.sub(r'<!--CHARTDATASTART-->.*$', '', 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 | |
| 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) | |
| 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) | |
| def root(): | |
| html_content = """ | |
| <!DOCTYPE html> | |
| <html lang="en"> | |
| <head> | |
| <meta charset="UTF-8"> | |
| <meta name="viewport" content="width=device-width, initial-scale=1.0"> | |
| <title>Business Plan Generator</title> | |
| <style> | |
| * { | |
| margin: 0; | |
| padding: 0; | |
| box-sizing: border-box; | |
| } | |
| body { | |
| font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif; | |
| background: linear-gradient(135deg, #8B5CF6, #7C3AED, #6D28D9); | |
| min-height: 100vh; | |
| color: white; | |
| display: flex; | |
| flex-direction: column; | |
| align-items: center; | |
| justify-content: center; | |
| text-align: center; | |
| padding: 20px; | |
| } | |
| .container { | |
| background: rgba(255, 255, 255, 0.1); | |
| backdrop-filter: blur(10px); | |
| border-radius: 20px; | |
| padding: 40px; | |
| box-shadow: 0 8px 32px rgba(0, 0, 0, 0.3); | |
| border: 1px solid rgba(255, 255, 255, 0.2); | |
| max-width: 800px; | |
| width: 100%; | |
| } | |
| h1 { | |
| font-size: 3.5rem; | |
| margin-bottom: 20px; | |
| text-shadow: 2px 2px 4px rgba(0, 0, 0, 0.3); | |
| background: linear-gradient(45deg, #FBBF24, #F59E0B); | |
| -webkit-background-clip: text; | |
| -webkit-text-fill-color: transparent; | |
| background-clip: text; | |
| } | |
| .subtitle { | |
| font-size: 1.5rem; | |
| margin-bottom: 30px; | |
| opacity: 0.9; | |
| font-weight: 300; | |
| } | |
| .status { | |
| background: rgba(34, 197, 94, 0.2); | |
| border: 1px solid rgba(34, 197, 94, 0.3); | |
| border-radius: 10px; | |
| padding: 15px; | |
| margin-bottom: 30px; | |
| font-size: 1.1rem; | |
| } | |
| .endpoints { | |
| display: grid; | |
| grid-template-columns: repeat(auto-fit, minmax(300px, 1fr)); | |
| gap: 20px; | |
| margin-bottom: 30px; | |
| } | |
| .endpoint-card { | |
| background: rgba(255, 255, 255, 0.1); | |
| border-radius: 15px; | |
| padding: 20px; | |
| border: 1px solid rgba(255, 255, 255, 0.2); | |
| transition: transform 0.3s ease, box-shadow 0.3s ease; | |
| } | |
| .endpoint-card:hover { | |
| transform: translateY(-5px); | |
| box-shadow: 0 10px 25px rgba(0, 0, 0, 0.2); | |
| } | |
| .endpoint-title { | |
| font-weight: bold; | |
| margin-bottom: 10px; | |
| color: #FBBF24; | |
| } | |
| .endpoint-desc { | |
| opacity: 0.8; | |
| line-height: 1.5; | |
| } | |
| .info-grid { | |
| display: grid; | |
| grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); | |
| gap: 20px; | |
| margin-top: 30px; | |
| } | |
| .info-card { | |
| background: rgba(255, 255, 255, 0.1); | |
| border-radius: 10px; | |
| padding: 15px; | |
| text-align: center; | |
| } | |
| .info-value { | |
| font-size: 1.5rem; | |
| font-weight: bold; | |
| color: #FBBF24; | |
| margin-bottom: 5px; | |
| } | |
| .info-label { | |
| opacity: 0.8; | |
| font-size: 0.9rem; | |
| } | |
| .floating-shapes { | |
| position: fixed; | |
| top: 0; | |
| left: 0; | |
| width: 100%; | |
| height: 100%; | |
| pointer-events: none; | |
| z-index: -1; | |
| } | |
| .shape { | |
| position: absolute; | |
| background: rgba(255, 255, 255, 0.1); | |
| border-radius: 50%; | |
| animation: float 6s ease-in-out infinite; | |
| } | |
| .shape:nth-child(1) { | |
| width: 80px; | |
| height: 80px; | |
| top: 20%; | |
| left: 10%; | |
| animation-delay: 0s; | |
| } | |
| .shape:nth-child(2) { | |
| width: 120px; | |
| height: 120px; | |
| top: 60%; | |
| right: 10%; | |
| animation-delay: 2s; | |
| } | |
| .shape:nth-child(3) { | |
| width: 60px; | |
| height: 60px; | |
| bottom: 20%; | |
| left: 20%; | |
| animation-delay: 4s; | |
| } | |
| @keyframes float { | |
| 0%, 100% { transform: translateY(0px) rotate(0deg); } | |
| 50% { transform: translateY(-20px) rotate(180deg); } | |
| } | |
| @media (max-width: 768px) { | |
| h1 { font-size: 2.5rem; } | |
| .subtitle { font-size: 1.2rem; } | |
| .container { padding: 20px; } | |
| .endpoints { grid-template-columns: 1fr; } | |
| } | |
| </style> | |
| </head> | |
| <body> | |
| <div class="floating-shapes"> | |
| <div class="shape"></div> | |
| <div class="shape"></div> | |
| <div class="shape"></div> | |
| </div> | |
| <div class="container"> | |
| <h1>🚀 Business Plan Generator</h1> | |
| <p class="subtitle">AI-Powered Business Planning Made Simple</p> | |
| <div class="status"> | |
| ✅ FastAPI is running successfully | |
| </div> | |
| <div class="endpoints"> | |
| <div class="endpoint-card"> | |
| <div class="endpoint-title">📊 Generate Business Plans</div> | |
| <div class="endpoint-desc">Create comprehensive business plans using AI models</div> | |
| </div> | |
| <div class="endpoint-card"> | |
| <div class="endpoint-title">💡 Get Suggestions</div> | |
| <div class="endpoint-desc">Receive AI-powered business suggestions and insights</div> | |
| </div> | |
| <div class="endpoint-card"> | |
| <div class="endpoint-title">📄 Export Documents</div> | |
| <div class="endpoint-desc">Export plans in PDF or Word format</div> | |
| </div> | |
| <div class="endpoint-card"> | |
| <div class="endpoint-title">💾 Database Integration</div> | |
| <div class="endpoint-desc">Persist and manage business plans with Supabase</div> | |
| </div> | |
| </div> | |
| <div class="info-grid"> | |
| <div class="info-card"> | |
| <div class="info-value">PDF</div> | |
| <div class="info-label">Export Format</div> | |
| </div> | |
| <div class="info-card"> | |
| <div class="info-value">Word</div> | |
| <div class="info-label">Export Format</div> | |
| </div> | |
| <div class="info-card"> | |
| <div class="info-value">1.0.0</div> | |
| <div class="info-label">Version</div> | |
| </div> | |
| <div class="info-card"> | |
| <div class="info-value">HF Export</div> | |
| <div class="info-label">Features</div> | |
| </div> | |
| </div> | |
| </div> | |
| </body> | |
| </html> | |
| """ | |
| from fastapi.responses import HTMLResponse | |
| return HTMLResponse(content=html_content) | |
| def health_check(): | |
| """ | |
| Health check endpoint to verify API connectivity and model availability. | |
| """ | |
| hf_token = os.getenv("HUGGINGFACEHUB_API_TOKEN") | |
| openai_api_key = os.getenv("OPENAI_API_KEY") | |
| status = { | |
| "api": "healthy", | |
| "models": {} | |
| } | |
| # Check Hugging Face models | |
| models_to_check = ["Mistral", "Qwen-2.5", "Llama", "Gemma", "Phi-3"] | |
| for model_name in models_to_check: | |
| try: | |
| # Just initialize the model without running inference | |
| if model_name == "": | |
| client = InferenceClient(provider="hf-inference", api_key=hf_token) | |
| # Just a simple check that the model exists | |
| client.model_info("mistralai/Mistral-7B-Instruct-v0.3") | |
| status["models"][model_name] = "available" | |
| elif model_name == "Qwen-2.5": | |
| # Check Qwen-2.5 availability | |
| client = InferenceClient(provider="hf-inference", api_key=hf_token) | |
| client.model_info("Qwen/Qwen2.5-7B-Instruct") | |
| status["models"][model_name] = "available" | |
| elif model_name == "Llama": | |
| # Check Llama availability | |
| client = InferenceClient(provider="hf-inference", api_key=hf_token) | |
| client.model_info("meta-llama/Llama-2-7b-chat-hf") | |
| status["models"][model_name] = "available" | |
| else: | |
| # Simplified check for other models | |
| status["models"][model_name] = "not checked" | |
| except Exception as e: | |
| status["models"][model_name] = f"error: {str(e)}" | |
| # Check OpenAI/GPT model | |
| if openai_api_key: | |
| try: | |
| status["models"]["GPT"] = "available" | |
| except Exception as e: | |
| status["models"]["GPT"] = f"error: {str(e)}" | |
| else: | |
| status["models"]["GPT"] = "unavailable (missing API key)" | |
| return status | |
| def fix_base64_padding(base64_string: str) -> str: | |
| """Fix common base64 padding issues""" | |
| # Remove any whitespace or newlines | |
| base64_string = base64_string.strip() | |
| # Add padding if needed | |
| missing_padding = len(base64_string) % 4 | |
| if missing_padding: | |
| base64_string += '=' * (4 - missing_padding) | |
| return base64_string | |
| def robust_base64_decode(base64_string: str) -> Optional[bytes]: | |
| """Robustly decode base64 string with error handling and padding correction""" | |
| try: | |
| # First try direct decoding | |
| return base64.b64decode(base64_string) | |
| except Exception as e1: | |
| try: | |
| # Try with padding correction | |
| fixed_string = fix_base64_padding(base64_string) | |
| return base64.b64decode(fixed_string) | |
| except Exception as e2: | |
| try: | |
| # Try with URL-safe base64 | |
| return base64.urlsafe_b64decode(fix_base64_padding(base64_string)) | |
| except Exception as e3: | |
| try: | |
| # Try with standard base64 ignoring padding | |
| return base64.b64decode(base64_string + '==', validate=False) | |
| except Exception as e4: | |
| return None | |
| def parse_embedded_chart_data(sections: List[ExportSection]) -> List[Dict[str, Any]]: | |
| """Parse chart data embedded in section content using CHART_DATA markers""" | |
| charts = [] | |
| for section in sections: | |
| if section.content: | |
| # Look for chart data markers | |
| chart_matches = re.findall(r'<!--CHART_DATA_START-->(.*?)<!--CHART_DATA_END-->', section.content, re.DOTALL) | |
| for chart_data in chart_matches: | |
| try: | |
| # Parse the chart data array | |
| chart_array = eval(chart_data.strip()) | |
| # Each chart is an array with: [Section, ChartType, Title, Period, Data] | |
| for chart_item in chart_array: | |
| if len(chart_item) >= 5: | |
| section_name = chart_item[0] | |
| chart_type = chart_item[1] # DC=Doughnut, BG=Bar Graph, etc. | |
| chart_title = chart_item[2] | |
| period = chart_item[3] | |
| data = chart_item[4] | |
| chart_info = { | |
| "section": section_name, | |
| "type": chart_type, | |
| "title": chart_title, | |
| "period": period, | |
| "data": data, | |
| "source_section": section.title | |
| } | |
| charts.append(chart_info) | |
| except Exception as e: | |
| continue | |
| return charts | |
| def create_chart_from_data(chart_info: Dict[str, Any], business_idea: str) -> bytes: | |
| """Create a matplotlib chart from parsed chart data""" | |
| try: | |
| chart_type = chart_info["type"] | |
| chart_title = chart_info["title"] | |
| data = chart_info["data"] | |
| if chart_type == "DC": # Doughnut Chart | |
| fig, ax = plt.subplots(figsize=(6, 4.5)) # Smaller size for better page fit | |
| # Extract labels and values from data | |
| labels = [item[0] for item in data] | |
| values = [item[1] for item in data] | |
| # Create doughnut chart | |
| wedges, texts, autotexts = ax.pie(values, labels=labels, autopct='%1.1f%%', startangle=90) | |
| ax.add_patch(plt.Circle((0, 0), 0.7, fc='white')) | |
| ax.set_title(f"{business_idea} - {chart_title}", fontsize=14, fontweight='bold', pad=15) # Smaller font | |
| elif chart_type == "BG": # Bar Graph | |
| fig, ax = plt.subplots(figsize=(6, 4.5)) # Smaller size for better page fit | |
| # Extract labels and values from data | |
| labels = [item[0] for item in data] | |
| values = [item[1] for item in data] | |
| # Create bar chart | |
| bars = ax.bar(labels, values, color=['#002060', '#FFD700', '#87CEEB', '#90EE90', '#FFB6C1']) | |
| ax.set_title(f"{business_idea} - {chart_title}", fontsize=14, fontweight='bold', pad=15) # Smaller font | |
| ax.set_ylabel('Value', fontsize=10) # Smaller font | |
| ax.set_xlabel('Category', fontsize=10) # Smaller font | |
| # Add value labels on bars | |
| for bar in bars: | |
| height = bar.get_height() | |
| ax.text(bar.get_x() + bar.get_width()/2., height, | |
| f'{height:,.0f}', ha='center', va='bottom', fontsize=9) # Smaller font | |
| plt.xticks(rotation=45) | |
| else: # Default to bar chart | |
| fig, ax = plt.subplots(figsize=(6, 4.5)) # Smaller size for better page fit | |
| labels = [item[0] for item in data] | |
| values = [item[1] for item in data] | |
| bars = ax.bar(labels, values) | |
| ax.set_title(f"{business_idea} - {chart_title}", fontsize=14, fontweight='bold', pad=15) # Smaller font | |
| plt.xticks(rotation=45) | |
| plt.tight_layout() | |
| # Save to bytes | |
| img_buffer = io.BytesIO() | |
| plt.savefig(img_buffer, format='png', dpi=300, bbox_inches='tight') | |
| img_buffer.seek(0) | |
| chart_bytes = img_buffer.getvalue() | |
| plt.close(fig) | |
| return chart_bytes | |
| except Exception as e: | |
| return None | |
| # Add these helper functions after the existing helper functions, before the create_pdf_document function | |
| def generate_table_of_contents(sections: List[ExportSection], business_idea: str) -> List[Dict[str, Any]]: | |
| """Generate a structured table of contents with page numbers following SECTION_ORDER""" | |
| toc_items = [] | |
| # Create a mapping from section titles to section objects for easy lookup | |
| section_map = {section.title: section for section in sections if section.content and section.content.strip()} | |
| # Add business plan sections in the predefined SECTION_ORDER | |
| for i, expected_title in enumerate(SECTION_ORDER): | |
| # Find the corresponding section | |
| section = section_map.get(expected_title) | |
| if section: | |
| # Each section starts on a new page, so page number is 3 + i (cover=1, TOC=2, sections start at 3) | |
| page_number = 3 + i | |
| toc_items.append({ | |
| "title": section.title, | |
| "level": 1, | |
| "page": page_number, | |
| "section_type": "section" | |
| }) | |
| # Extract subheadings from section content - limit to prevent TOC overflow | |
| subheadings = extract_subheadings(section.content) | |
| # Only include first 2 most important subheadings per section to keep TOC compact | |
| max_subheadings = min(2, len(subheadings)) # Reduced from 3 to 2 | |
| for j, subheading in enumerate(subheadings[:max_subheadings]): | |
| toc_items.append({ | |
| "title": subheading, | |
| "level": 2, | |
| "page": page_number, | |
| "section_type": "subsection" | |
| }) | |
| return toc_items | |
| def extract_subheadings(content: str) -> List[str]: | |
| """Extract subheadings from markdown content""" | |
| subheadings = [] | |
| lines = content.split('\n') | |
| for line in lines: | |
| line = line.strip() | |
| # Look for markdown headers (##, ###, etc.) | |
| if line.startswith('##') and not line.startswith('###'): | |
| # Remove markdown formatting and clean up | |
| subheading = re.sub(r'^##+\s*', '', line) | |
| subheading = re.sub(r'[*_`~]', '', subheading) | |
| if subheading.strip(): | |
| subheadings.append(subheading.strip()) | |
| return subheadings | |
| def add_table_of_contents_page(pdf, toc_items: List[Dict[str, Any]], business_idea: str): | |
| """Add a professional table of contents page following SECTION_ORDER""" | |
| # TOC items are already in the correct order from generate_table_of_contents | |
| sorted_toc_items = toc_items | |
| # 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 | |
| # Page title - more compact | |
| pdf.set_font("Arial", "B", 20) # Reduced from 24 to 20 | |
| pdf.set_text_color(*primary_color) | |
| pdf.set_xy(25, 20) # Reduced from 25 to 20 | |
| pdf.cell(0, 15, "Table of Contents", ln=True) # Reduced from 18 to 15 | |
| # Calculate text width and make line autofit | |
| text_width = pdf.get_string_width("Table of Contents") | |
| line_width = min(text_width + 12, 160) # Add 12mm padding, max 160mm | |
| # Add subtle line under title - consistent styling | |
| pdf.set_draw_color(200, 200, 200) # Light gray line | |
| pdf.set_line_width(0.3) # Thin line | |
| pdf.line(25, 35, 25 + line_width, 35) | |
| # TOC content - optimized for single page layout | |
| y_position = 50 # Reduced from 60 to 50 | |
| pages_used = 1 | |
| for item in sorted_toc_items: | |
| # Only add new page if absolutely necessary (very close to bottom) | |
| # Increased threshold significantly to force single-page TOC | |
| if y_position > 290: # Very close to bottom margin | |
| pdf.add_page() | |
| y_position = 25 | |
| pages_used += 1 | |
| # Indent based on level | |
| indent = 0 if item["level"] == 1 else 15 | |
| # Font and color based on level | |
| if item["level"] == 1: | |
| pdf.set_font("Arial", "B", 12) | |
| pdf.set_text_color(*primary_color) | |
| else: | |
| pdf.set_font("Arial", "", 10) | |
| pdf.set_text_color(*text_color) | |
| # Title | |
| pdf.set_xy(25 + indent, y_position) | |
| pdf.cell(120, 8, item["title"], ln=False) | |
| # Dots leading to page number | |
| pdf.set_font("Arial", "", 8) | |
| pdf.set_text_color(*light_gray) | |
| # Calculate dots position | |
| title_width = pdf.get_string_width(item["title"]) | |
| dots_start = 25 + indent + title_width + 5 | |
| dots_end = 160 | |
| # Draw dots | |
| for x in range(int(dots_start), int(dots_end), 3): | |
| pdf.set_xy(x, y_position + 3) | |
| pdf.cell(1, 1, ".", ln=False) | |
| # Page number | |
| pdf.set_xy(160, y_position) | |
| pdf.cell(0, 8, str(item["page"]), ln=False) | |
| # Reduced spacing between items for better page utilization | |
| y_position += 8 # Reduced from 10 to 8 for even tighter spacing | |
| # Add some spacing at the end | |
| pdf.ln(20) | |
| def filter_empty_sections(sections: List[ExportSection]) -> List[ExportSection]: | |
| """Filter out sections with no content to prevent empty pages""" | |
| filtered_sections = [] | |
| for section in sections: | |
| if section.content and section.content.strip(): | |
| # Clean the content to check if it's actually meaningful | |
| cleaned_content = clean_text_for_pdf(section.content) | |
| if cleaned_content and len(cleaned_content.strip()) > 10: # At least 10 characters | |
| filtered_sections.append(section) | |
| else: | |
| pass | |
| else: | |
| pass | |
| return filtered_sections | |
| def enhance_subheadings_with_bold(content: str) -> str: | |
| """Enhance subheadings with bold formatting and ensure no duplicates""" | |
| if not content: | |
| return content | |
| lines = content.split('\n') | |
| enhanced_lines = [] | |
| seen_headings = set() | |
| for line in lines: | |
| line = line.strip() | |
| if not line: | |
| enhanced_lines.append('') | |
| continue | |
| # Check if this is a subheading (## or ###) | |
| if line.startswith('##') and not line.startswith('###'): | |
| # Extract the heading text | |
| heading_text = re.sub(r'^##+\s*', '', line) | |
| heading_text = re.sub(r'[*_`~]', '', heading_text).strip() | |
| # Check for duplicates | |
| if heading_text.lower() in seen_headings: | |
| continue | |
| seen_headings.add(heading_text.lower()) | |
| # Make it bold and ensure proper formatting | |
| enhanced_line = f"**{heading_text}**" | |
| enhanced_lines.append(enhanced_line) | |
| else: | |
| enhanced_lines.append(line) | |
| return '\n'.join(enhanced_lines) | |
| def remove_duplicate_headings(content: str) -> str: | |
| """Remove duplicate headings from content""" | |
| if not content: | |
| return content | |
| lines = content.split('\n') | |
| cleaned_lines = [] | |
| seen_headings = set() | |
| for line in lines: | |
| line = line.strip() | |
| if not line: | |
| cleaned_lines.append('') | |
| continue | |
| # Check if this is any type of heading | |
| if line.startswith('#'): | |
| heading_text = re.sub(r'^#+\s*', '', line) | |
| heading_text = re.sub(r'[*_`~]', '', heading_text).strip() | |
| # Check for duplicates (case-insensitive) | |
| if heading_text.lower() in seen_headings: | |
| continue | |
| seen_headings.add(heading_text.lower()) | |
| cleaned_lines.append(line) | |
| else: | |
| cleaned_lines.append(line) | |
| return '\n'.join(cleaned_lines) | |
| def render_markdown_to_pdf(pdf, content: str, primary_color, accent_color, text_color): | |
| """Render markdown content to PDF with proper heading styling""" | |
| if not content: | |
| return | |
| lines = content.split('\n') | |
| current_y = pdf.get_y() | |
| for line in lines: | |
| line = line.strip() | |
| if not line: | |
| pdf.ln(3) # Consistent space for empty lines | |
| continue | |
| # Handle different heading levels | |
| if line.startswith("# "): # H1 - Main heading (like "Company Profile") | |
| text = line[2:].strip() | |
| pdf.set_font("Arial", "B", 20) # Bold, size 20 | |
| pdf.set_text_color(*primary_color) # Black | |
| pdf.set_xy(25, current_y) | |
| pdf.multi_cell(160, 10, text) | |
| # Calculate text width and make line autofit | |
| text_width = pdf.get_string_width(text) | |
| line_width = min(text_width + 12, 160) # Add 12mm padding, max 160mm | |
| # Add subtle line under main heading - consistent with section titles | |
| pdf.set_draw_color(200, 200, 200) # Light gray line | |
| pdf.set_line_width(0.3) # Thin line | |
| pdf.line(25, current_y + 10, 25 + line_width, current_y + 10) | |
| current_y = pdf.get_y() | |
| pdf.ln(8) # Consistent space after main heading | |
| elif line.startswith("## ") or re.match(r'^\d+\.\d+\s+', line): # H2 - Subheading or numbered subheading | |
| text = line[3:].strip() if line.startswith("## ") else line.strip() | |
| pdf.set_font("Arial", "B", 16) # Bold, size 16 | |
| pdf.set_text_color(*primary_color) # Black | |
| pdf.set_xy(25, current_y) | |
| pdf.multi_cell(160, 8, text) | |
| # 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 | |
| # Add subtle line under subheading - consistent styling | |
| pdf.set_draw_color(200, 200, 200) # Light gray line | |
| pdf.set_line_width(0.3) # Thin line | |
| pdf.line(25, current_y + 8, 25 + line_width, current_y + 8) | |
| current_y = pdf.get_y() | |
| pdf.ln(6) # Consistent space after subheading | |
| elif re.match(r'^\d+\.\d+\s+[A-Z]', line): # Numbered subheadings like "3.6 SWOT Analysis" | |
| text = line.strip() | |
| pdf.set_font("Arial", "B", 16) # Bold, size 16 | |
| pdf.set_text_color(*primary_color) # Black | |
| pdf.set_xy(25, current_y) | |
| pdf.multi_cell(160, 8, text) | |
| # 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 | |
| # Add subtle line under subheading - consistent styling | |
| pdf.set_draw_color(200, 200, 200) # Light gray line | |
| pdf.set_line_width(0.3) # Thin line | |
| pdf.line(25, current_y + 8, 25 + line_width, current_y + 8) | |
| current_y = pdf.get_y() | |
| pdf.ln(6) # Consistent space after subheading | |
| elif line.startswith("### ") or line.startswith("#### "): # H3/H4 - Smaller subheadings | |
| text = line[4:].strip() if line.startswith("### ") else line[5:].strip() | |
| pdf.set_font("Arial", "B", 14) # Bold, size 14 | |
| pdf.set_text_color(*primary_color) # Black | |
| pdf.set_xy(25, current_y) | |
| pdf.multi_cell(160, 8, text) | |
| # 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 | |
| # Subtle accent line - consistent styling | |
| pdf.set_draw_color(200, 200, 200) # Light gray line | |
| pdf.set_line_width(0.2) # Very thin line | |
| pdf.line(25, current_y + 8, 25 + line_width, current_y + 8) | |
| current_y = pdf.get_y() | |
| pdf.ln(4) # Consistent space after subheading | |
| elif line.startswith("**") and line.endswith("**"): # Bold text that might be a heading | |
| text = line[2:-2].strip() # Remove ** markers | |
| # Check if this looks like a main heading (no numbers, not too long) | |
| if not re.match(r'^\d+\.', text) and len(text) < 50: | |
| pdf.set_font("Arial", "B", 18) # Bold, size 18 | |
| pdf.set_text_color(*primary_color) # Black | |
| pdf.set_xy(25, current_y) | |
| pdf.multi_cell(160, 9, text) | |
| # 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 | |
| # Add subtle line under heading - consistent styling | |
| pdf.set_draw_color(200, 200, 200) # Light gray line | |
| pdf.set_line_width(0.3) # Thin line | |
| pdf.line(25, current_y + 2, 25 + line_width, current_y + 2) | |
| current_y = pdf.get_y() | |
| pdf.ln(6) # Consistent space after heading | |
| else: | |
| # Regular bold text | |
| pdf.set_font("Arial", "B", 12) # Bold, size 12 | |
| pdf.set_text_color(*text_color) # Dark gray | |
| pdf.set_xy(25, current_y) | |
| pdf.multi_cell(160, 8, text) | |
| current_y = pdf.get_y() | |
| else: # Normal paragraph text | |
| pdf.set_font("Arial", "", 12) # Regular font, size 12 | |
| pdf.set_text_color(*text_color) # Dark gray | |
| pdf.set_xy(25, current_y) | |
| pdf.multi_cell(160, 8, line) | |
| current_y = pdf.get_y() | |
| # Canonical currency map for symbol resolution (matches TypeScript version) | |
| canonical_currency_map = { | |
| # Standard currency symbols | |
| "$": "USD", | |
| "£": "GBP", | |
| "€": "EUR", | |
| "¥": "JPY", | |
| "₹": "INR", | |
| "₽": "RUB", | |
| "₩": "KRW", | |
| "₪": "ILS", | |
| "₺": "TRY", | |
| "₴": "UAH", | |
| "₼": "AZN", | |
| "₸": "KZT", | |
| "₾": "GEL", | |
| "֏": "AMD", | |
| "₲": "PYG", | |
| "₡": "CRC", | |
| "₣": "XPF", | |
| "₦": "NGN", | |
| "₵": "GHS", | |
| "₨": "PKR", | |
| "₱": "PHP", | |
| "₫": "VND", | |
| "₭": "LAK", | |
| "៛": "KHR", | |
| "৳": "BDT", | |
| "؋": "AFN", | |
| "﷼": "IRR", | |
| # Custom symbols | |
| "‡": "CUSTOM_DAGGER", | |
| "†": "CUSTOM_DAGGER_SINGLE", | |
| # Letter-based currencies | |
| "R": "ZAR", # South African Rand | |
| "C$": "CAD", # Canadian Dollar | |
| "A$": "AUD", # Australian Dollar | |
| "R$": "BRL", # Brazilian Real | |
| "S/": "PEN", # Peruvian Sol | |
| "Bs": "BOB", # Bolivian Boliviano | |
| "Q": "GTQ", # Guatemalan Quetzal | |
| "L": "HNL", # Honduran Lempira | |
| "ƒ": "AWG", # Aruban Florin | |
| "Vt": "VUV", # Vanuatu Vatu | |
| "T$": "TOP", # Tongan Pa'anga | |
| "T": "WST", # Samoan Tala | |
| "лв": "BGN", # Bulgarian Lev | |
| "Kč": "CZK", # Czech Koruna | |
| "kr": "DKK", # Danish Krone | |
| "Ft": "HUF", # Hungarian Forint | |
| "zł": "PLN", # Polish Zloty | |
| "lei": "RON", # Romanian Leu | |
| "CHF": "CHF", # Swiss Franc | |
| "KM": "BAM", # Bosnian Marka | |
| "ден": "MKD", # North Macedonian Denar | |
| "дин": "RSD", # Serbian Dinar | |
| "so'm": "UZS", # Uzbekistani Som | |
| "NT$": "TWD", # Taiwan Dollar | |
| "HK$": "HKD", # Hong Kong Dollar | |
| "S$": "SGD", # Singapore Dollar | |
| "RM": "MYR", # Malaysian Ringgit | |
| "฿": "THB", # Thai Baht | |
| "Rp": "IDR", # Indonesian Rupiah | |
| "K": "MMK", # Myanmar Kyat | |
| "Rs": "LKR", # Sri Lankan Rupee | |
| "Nu": "BTN", # Bhutanese Ngultrum | |
| "MVR": "MVR", # Maldivian Rufiyaa | |
| # Middle Eastern currencies | |
| "ع.د": "IQD", # Iraqi Dinar | |
| "ل.ل": "LBP", # Lebanese Pound | |
| "د.ا": "JOD", # Jordanian Dinar | |
| "ر.س": "SAR", # Saudi Riyal | |
| "د.إ": "AED", # UAE Dirham | |
| "ر.ق": "QAR", # Qatari Riyal | |
| "د.ك": "KWD", # Kuwaiti Dinar | |
| "د.ب": "BHD", # Bahraini Dinar | |
| "ر.ع.": "OMR", # Omani Rial | |
| "ر.ي": "YER", # Yemeni Rial | |
| "ج.م": "EGP", # Egyptian Pound | |
| "ل.د": "LYD", # Libyan Dinar | |
| "د.ت": "TND", # Tunisian Dinar | |
| "د.ج": "DZD", # Algerian Dinar | |
| "د.م.": "MAD", # Moroccan Dirham | |
| "ج.س.": "SDG", # Sudanese Pound | |
| "SSP": "SSP", # South Sudanese Pound | |
| # African currencies | |
| "KSh": "KES", # Kenyan Shilling | |
| "USh": "UGX", # Ugandan Shilling | |
| "TSh": "TZS", # Tanzanian Shilling | |
| "FRw": "RWF", # Rwandan Franc | |
| "FBu": "BIF", # Burundian Franc | |
| "MT": "MZN", # Mozambican Metical | |
| "P": "BWP", # Botswanan Pula | |
| "N$": "NAD", # Namibian Dollar | |
| "Ar": "MGA", # Malagasy Ariary | |
| "CF": "KMF", # Comorian Franc | |
| "MK": "MWK", # Malawian Kwacha | |
| "ZK": "ZMW", # Zambian Kwacha | |
| "Kz": "AOA", # Angolan Kwanza | |
| "FC": "CDF", # Congolese Franc | |
| "FCFA": "XAF", # Central African Franc | |
| "Db": "STD", # Sao Tomean Dobra | |
| "D": "GMD", # Gambian Dalasi | |
| "FG": "GNF", # Guinean Franc | |
| "CFA": "XOF", # West African Franc | |
| "Le": "SLL", # Sierra Leonean Leone | |
| "L$": "LRD", # Liberian Dollar | |
| "Br": "ETB", # Ethiopian Birr | |
| "Nfk": "ERN", # Eritrean Nakfa | |
| "Fdj": "DJF", # Djiboutian Franc | |
| "S": "SOS", # Somali Shilling | |
| } | |
| def convert_currency_symbols_to_iso(text: str) -> str: | |
| """ | |
| Convert currency symbols to ISO codes for PDF compatibility | |
| Matches the TypeScript canonicalCurrencyMap logic | |
| """ | |
| import re # Import at function level to avoid scope issues | |
| processed_text = text | |
| # Track all conversions for detailed logging | |
| conversions_made = [] | |
| total_conversions = 0 | |
| # Process each currency symbol in the canonical map | |
| for symbol, iso_code in canonical_currency_map.items(): | |
| if symbol in processed_text: | |
| # Check if it's a single character | |
| is_single_character = len(symbol) == 1 | |
| is_unicode_currency = is_single_character and ord(symbol) > 127 # Non-ASCII | |
| is_latin_single_letter = is_single_character and symbol.isalpha() and ord(symbol) < 128 | |
| # Handle multi-character symbols (like "Le", "KSh", etc.) | |
| is_multi_char_symbol = len(symbol) > 1 | |
| # Skip plain Latin single letters (to avoid converting words like "A", "R", etc.) | |
| # but DO convert single-character Unicode currency symbols like €, £, ¥, ؋, etc. | |
| if not is_unicode_currency and is_latin_single_letter: | |
| # Only convert if it's followed by a number (currency context) | |
| # Pattern: R500, R 500, R500,000 etc. | |
| pattern = rf'\b{re.escape(symbol)}\s*(\d{{1,3}}(?:,?\d{{3}})*(?:\.\d{{2}})?)\b' | |
| matches = re.findall(pattern, processed_text) | |
| if matches: | |
| old_text = processed_text | |
| processed_text = re.sub(pattern, rf'{iso_code} \1', processed_text) | |
| if old_text != processed_text: | |
| conversions_made.append(f"{symbol} → {iso_code} (context: {matches})") | |
| total_conversions += len(matches) | |
| continue | |
| # For all other symbols (Unicode currency symbols, multi-character symbols) | |
| # Count occurrences before replacement | |
| old_count = processed_text.count(symbol) | |
| # Replace all occurrences | |
| old_text = processed_text | |
| processed_text = processed_text.replace(symbol, iso_code) | |
| # Check if replacement actually happened | |
| if old_text != processed_text: | |
| conversions_made.append(f"{symbol} → {iso_code} ({old_count} instances)") | |
| total_conversions += old_count | |
| return processed_text | |
| def clean_unicode_for_pdf(text: str) -> str: | |
| # 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(): | |
| text = text.replace(unicode_char, replacement) | |
| # Simple text cleaning - keep basic punctuation and common symbols | |
| text = ''.join(char for char in text if ord(char) < 128 or char in '•°$€£¥₹₩₽₪₺₴₼₸₾֏₲₡₣₦₵₨₱₫₭៛৳؋﷼%') | |
| return text | |
| def render_markdown_to_docx(doc, content: str): | |
| """Render markdown content to Word document with proper heading styling""" | |
| if not content: | |
| return | |
| lines = content.split('\n') | |
| for line in lines: | |
| line = line.strip() | |
| if not line: | |
| doc.add_paragraph() # Empty paragraph for spacing | |
| continue | |
| # Handle different heading levels | |
| if line.startswith("# "): # H1 - Main heading (like "Company Profile") | |
| text = line[2:].strip() | |
| doc.add_heading(text, level=1) | |
| elif line.startswith("## ") or re.match(r'^\d+\.\d+\s+', line): # H2 - Subheading or numbered subheading | |
| text = line[3:].strip() if line.startswith("## ") else line.strip() | |
| doc.add_heading(text, level=2) | |
| elif re.match(r'^\d+\.\d+\s+[A-Z]', line): # Numbered subheadings like "3.6 SWOT Analysis" | |
| text = line.strip() | |
| doc.add_heading(text, level=2) | |
| elif line.startswith("### ") or line.startswith("#### "): # H3/H4 - Smaller subheadings | |
| text = line[4:].strip() if line.startswith("### ") else line[5:].strip() | |
| doc.add_heading(text, level=3) | |
| elif line.startswith("**") and line.endswith("**"): # Bold text that might be a heading | |
| text = line[2:-2].strip() # Remove ** markers | |
| # Check if this looks like a main heading (no numbers, not too long) | |
| if not re.match(r'^\d+\.', text) and len(text) < 50: | |
| doc.add_heading(text, level=1) # Treat as main heading | |
| else: | |
| # Regular bold text | |
| doc.add_paragraph(text) | |
| else: # Normal paragraph text | |
| doc.add_paragraph(line) | |
| def clean_text_for_docx(text: str) -> str: | |
| # 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(): | |
| text = text.replace(unicode_char, replacement) | |
| # Simple text cleaning - keep basic punctuation and common symbols | |
| text = ''.join(char for char in text if ord(char) < 128 or char in '•°$€£¥₹₩₽₪₺₴₼₸₾֏₲₡₣₦₵₨₱₫₭៛৳؋﷼%') | |
| return text |