devZenaight commited on
Commit
255c52c
·
1 Parent(s): f020eb1
Files changed (1) hide show
  1. app/main.py +1017 -108
app/main.py CHANGED
@@ -1,10 +1,811 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  ##########################
2
- #NEW TEST#
3
  ##########################
4
  import os
5
  import asyncio
6
  import logging
7
  from typing import List, Dict, Any, Optional
 
 
 
 
 
8
  import re
9
  from collections import OrderedDict
10
  import time as _time
@@ -55,6 +856,7 @@ _WAITERS_LOCK = asyncio.Lock()
55
 
56
  class QueueOverloaded(Exception):
57
  pass
 
58
  # =========================
59
  # Idempotency (dedupe retries)
60
  # =========================
@@ -62,6 +864,8 @@ _IDEMP_CACHE: "OrderedDict[str, tuple[float, dict]]" = OrderedDict()
62
  _IDEMP_TTL_SECONDS = int(os.getenv("IDEMP_TTL_SECONDS", "600"))
63
 
64
  def _idem_store(key: str, payload: dict) -> None:
 
 
65
  now = _time.time()
66
  _IDEMP_CACHE[key] = (now, payload)
67
  # prune expired / cap size
@@ -280,6 +1084,68 @@ def write_section_error(businessPlanId: str, sectionKey: str, error_msg: str) ->
280
  "updatedAt": now_iso(),
281
  }, on_conflict="businessPlanId").execute()
282
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
283
  # Check for required environment variables
284
  def check_environment_variables():
285
  hf_token = os.getenv("HUGGINGFACEHUB_API_TOKEN")
@@ -328,6 +1194,7 @@ app.add_middleware(
328
  allow_headers=["*"],
329
  expose_headers=["*"]
330
  )
 
331
  # Fixed list of business questions (order matters)
332
  QUESTIONS = [
333
  "What is your business name?",
@@ -342,7 +1209,9 @@ QUESTIONS = [
342
  "How will you acquire customers?",
343
  ]
344
 
345
- # Data model for incoming business plan generation requests
 
 
346
  class GenerateRequest(BaseModel):
347
  answers: List[str]
348
  model: str
@@ -358,8 +1227,37 @@ class GenerateRequest(BaseModel):
358
  planType: Optional[str] = "free"
359
  countryCode: Optional[str] = None
360
  sectionKey: Optional[str] = None # required for per-section save
361
- idempotencyKey: Optional[str] = None # ← ADD THIS
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
362
 
 
 
 
 
 
 
363
  def generate_model_output(model: str, provider: str, api_key: str, prompt: str, max_tokens: int = 4000) -> str:
364
  """
365
  A helper function that wraps the Hugging Face Inference API call.
@@ -378,18 +1276,13 @@ def generate_model_output(model: str, provider: str, api_key: str, prompt: str,
378
  except Exception as e:
379
  error_message = f"Error generating output with model {model}: {str(e)}"
380
  logging.error(error_message)
381
- # Re-raise the exception with more context
382
  raise Exception(error_message) from e
383
 
384
- ###############################################################################
385
- # HFInferenceLLM: A wrapper for Hugging Face models that matches the LLM interface.
386
- ###############################################################################
387
  class HFInferenceLLM(LLM):
388
  model: str = None
389
  provider: str = "hf-inference"
390
  api_key: str = ""
391
  max_tokens: int = 4000 # Increased from 500 to 4000 by default
392
-
393
 
394
  def __init__(self, model: str, provider: str = "hf-inference", api_key: str = "", max_tokens: int = 4000):
395
  super().__init__()
@@ -405,7 +1298,6 @@ class HFInferenceLLM(LLM):
405
 
406
  # Check if model is available
407
  try:
408
- # Create a client to validate connection
409
  _ = get_cached_hf_client(provider=provider, api_key=api_key)
410
  logging.info(f"Successfully initialized client for model: {model}")
411
  except Exception as e:
@@ -417,10 +1309,6 @@ class HFInferenceLLM(LLM):
417
  return "hf_inference"
418
 
419
  def _call(self, prompt: str, stop: Optional[List[str]] = None) -> str:
420
- """
421
- Calls the Hugging Face API using the provided prompt.
422
- The `stop` parameter is not implemented (or forwarded) here.
423
- """
424
  return generate_model_output(
425
  model=self.model,
426
  provider=self.provider,
@@ -430,15 +1318,8 @@ class HFInferenceLLM(LLM):
430
  )
431
 
432
  def get_num_tokens(self, prompt: str) -> int:
433
- """
434
- A simple implementation that counts tokens as space-separated words.
435
- You may replace this with a more accurate token counter.
436
- """
437
  return len(prompt.split())
438
 
439
- ###############################################################################
440
- # get_llm: Return an LLM instance based on the selected model.
441
- ###############################################################################
442
  def get_llm(model_name: str, provider: str = "hf-inference"):
443
  if model_name in ("BPGenerateAI", "BPSuggestionsAI") or model_name.lower().startswith("gpt-"):
444
  openai_api_key = os.getenv("OPENAI_API_KEY")
@@ -457,56 +1338,9 @@ def get_llm(model_name: str, provider: str = "hf-inference"):
457
  raise ValueError("HUGGINGFACEHUB_API_TOKEN is required for Hugging Face models")
458
  return HFInferenceLLM(model=model_name, provider=provider, api_key=hf_token, max_tokens=4000)
459
 
460
- # hf_token = os.getenv("HUGGINGFACEHUB_API_TOKEN")
461
- # if not hf_token:
462
- # logging.error("HUGGINGFACEHUB_API_TOKEN environment variable not set")
463
- # raise ValueError("HUGGINGFACEHUB_API_TOKEN environment variable is required for Hugging Face models")
464
-
465
- # if model_name == "BPGenerateAI":
466
- # openai_api_key = os.getenv("OPENAI_API_KEY")
467
- # if not openai_api_key:
468
- # logging.error("OPENAI_API_KEY environment variable not set")
469
- # raise ValueError("OPENAI_API_KEY environment variable is required for GPT models")
470
- # return ChatOpenAI(
471
- # model_name="gpt-4.1-mini",
472
- # temperature=0.7,
473
- # max_tokens=6000,
474
- # openai_api_key=openai_api_key
475
- # )
476
- # elif model_name == "BPSuggestionsAI":
477
- # openai_api_key = os.getenv("OPENAI_API_KEY")
478
- # if not openai_api_key:
479
- # logging.error("OPENAI_API_KEY environment variable not set")
480
- # raise ValueError("OPENAI_API_KEY environment variable is required for GPT models")
481
- # return ChatOpenAI(
482
- # model_name="gpt-4.1-nano",
483
- # temperature=0.7,
484
- # max_tokens=4000,
485
- # openai_api_key=openai_api_key
486
- # )
487
- # elif model_name.lower() == "gpt-4.1-nano":
488
- # openai_api_key = os.getenv("OPENAI_API_KEY")
489
- # if not openai_api_key:
490
- # raise ValueError("OPENAI_API_KEY is required for GPT models")
491
- # return ChatOpenAI(
492
- # model_name="gpt-4.1-nano",
493
- # temperature=0.7,
494
- # max_tokens=4000,
495
- # openai_api_key=openai_api_key
496
- # )
497
- # else:
498
- # # For all other models, use the specified provider
499
- # return HFInferenceLLM(
500
- # model=model_name,
501
- # provider=provider,
502
- # api_key=hf_token,
503
- # max_tokens=4000
504
- # )
505
-
506
- ###############################################################################
507
  # FastAPI Endpoints
508
- ###############################################################################
509
-
510
  @app.get("/suggestions", response_model=Dict[str, Any])
511
  async def get_suggestions(
512
  business_idea: str,
@@ -519,14 +1353,12 @@ async def get_suggestions(
519
  exclude: List[str] = Query([])
520
  ) -> Dict[str, Any]:
521
  try:
522
- # Use the prompt from the frontend
523
  formatted_prompt = prompt.format(
524
  business_idea=business_idea,
525
  question=question,
526
  exclude=", ".join(exclude) if exclude else ""
527
  )
528
 
529
- # Get the LLM instance with frontend-provided configuration
530
  llm = get_llm(model, provider)
531
  if hasattr(llm, 'temperature'):
532
  llm.temperature = temperature
@@ -539,10 +1371,8 @@ async def get_suggestions(
539
  )
540
  suggestion_chain: RunnableSequence = suggestion_prompt | llm
541
 
542
- # resilience wrapper
543
  raw_result = await resilient_invoke(suggestion_chain, {})
544
 
545
- # Normalize AIMessage or list[AIMessage] → plain string
546
  if isinstance(raw_result, AIMessage):
547
  raw_text = raw_result.content
548
  elif (
@@ -554,7 +1384,6 @@ async def get_suggestions(
554
  else:
555
  raw_text = str(raw_result)
556
 
557
- # Clean up the suggestions
558
  suggestion_array = [
559
  re.sub(r'^\s*[\-\d\.\)\s]+', '', line).strip()
560
  for line in raw_text.split("\n")
@@ -570,6 +1399,110 @@ async def get_suggestions(
570
  except Exception as e:
571
  raise HTTPException(status_code=500, detail=f"Error generating suggestions: {str(e)}")
572
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
573
  @app.post("/generate")
574
  async def generate_business_plan(data: GenerateRequest, request: Request):
575
  """
@@ -587,9 +1520,6 @@ async def generate_business_plan(data: GenerateRequest, request: Request):
587
  # Get the LLM instance based on the selected model and provider
588
  logging.info(f"[plan={data.businessPlanId}|section={data.sectionKey}] model={data.model} provider={data.provider} init")
589
  llm_selected = get_llm(data.model, data.provider)
590
- # Get the LLM instance based on the selected model and provider
591
- # logging.info(f"Initializing model: {data.model} with provider: {data.provider}")
592
- # llm_selected = get_llm(data.model, data.provider) # ✅ provider bugfix
593
 
594
  # Set the temperature and max tokens from the request
595
  if hasattr(llm_selected, 'temperature'):
@@ -632,7 +1562,7 @@ async def generate_business_plan(data: GenerateRequest, request: Request):
632
  except Exception as _:
633
  pass
634
 
635
- # FREE/FULL gating (small, safe)
636
  if not is_allowed_for_plan(plan_type, data.sectionKey):
637
  raise HTTPException(status_code=403, detail=f"Section '{data.sectionKey}' not allowed for planType='{plan_type}'")
638
 
@@ -654,6 +1584,12 @@ async def generate_business_plan(data: GenerateRequest, request: Request):
654
  # persist success
655
  write_section_success(plan_id, data.sectionKey, plan_text)
656
 
 
 
 
 
 
 
657
  resp = {
658
  "saved": True,
659
  "businessPlanId": plan_id,
@@ -662,7 +1598,7 @@ async def generate_business_plan(data: GenerateRequest, request: Request):
662
  "currentSection": data.sectionKey,
663
  "nextSectionIndex": None,
664
  }
665
- if idem_key: _idem_store(idem_key, resp)
666
  return resp
667
 
668
  except QueueOverloaded:
@@ -713,7 +1649,7 @@ async def generate_business_plan(data: GenerateRequest, request: Request):
713
  "businessPlanId": plan_id,
714
  "saved": True
715
  }
716
- if idem_key: _idem_store(idem_key, resp)
717
  return resp
718
 
719
  except QueueOverloaded:
@@ -754,37 +1690,10 @@ def health_check():
754
  "concurrency_limit": GEN_CONCURRENCY_LIMIT,
755
  }
756
 
757
- # Check Hugging Face models
758
- models_to_check = ["Mistral", "Qwen-2.5", "Llama", "Gemma", "Phi-3"]
759
-
760
- # for model_name in models_to_check:
761
- # try:
762
- # # Just initialize the model without running inference
763
- # if model_name == "":
764
- # client = InferenceClient(provider="hf-inference", api_key=hf_token)
765
- # # Just a simple check that the model exists
766
- # client.model_info("mistralai/Mistral-7B-Instruct-v0.3")
767
- # status["models"][model_name] = "available"
768
- # elif model_name == "Qwen-2.5":
769
- # # Check Qwen-2.5 availability
770
- # client = InferenceClient(provider="hf-inference", api_key=hf_token)
771
- # client.model_info("Qwen/Qwen2.5-7B-Instruct")
772
- # status["models"][model_name] = "available"
773
- # elif model_name == "Llama":
774
- # # Check Llama availability
775
- # client = InferenceClient(provider="hf-inference", api_key=hf_token)
776
- # client.model_info("meta-llama/Llama-2-7b-chat-hf")
777
- # status["models"][model_name] = "available"
778
- # else:
779
- # # Simplified check for other models
780
- # status["models"][model_name] = "not checked"
781
- # except Exception as e:
782
- # status["models"][model_name] = f"error: {str(e)}"
783
-
784
  # Check OpenAI/GPT model
785
  if openai_api_key:
786
  try:
787
- status["models"]["GPT"] = "available"
788
  except Exception as e:
789
  status["models"]["GPT"] = f"error: {str(e)}"
790
  else:
 
1
+ # ##########################
2
+ # #NEW TEST#
3
+ # ##########################
4
+ # import os
5
+ # import asyncio
6
+ # import logging
7
+ # from typing import List, Dict, Any, Optional
8
+ # import re
9
+ # from collections import OrderedDict
10
+ # import time as _time
11
+ # from datetime import datetime, timezone
12
+ # import functools
13
+ # from concurrent.futures import ThreadPoolExecutor
14
+ # from asyncio import TimeoutError as AsyncTimeoutError
15
+
16
+ # from dotenv import load_dotenv
17
+ # load_dotenv() # Load environment variables from .env into os.environ
18
+
19
+ # from fastapi import FastAPI, HTTPException, Request
20
+ # from fastapi.middleware.cors import CORSMiddleware
21
+ # from fastapi import Query
22
+ # from pydantic import BaseModel
23
+
24
+ # # Import LangChain components
25
+ # from langchain_openai import ChatOpenAI
26
+ # from langchain.prompts import PromptTemplate
27
+ # from langchain.schema.runnable import RunnableSequence
28
+ # from langchain.schema import AIMessage
29
+
30
+ # # Import the base LLM class to build our custom wrapper
31
+ # from langchain.llms.base import LLM
32
+
33
+ # from huggingface_hub import InferenceClient
34
+
35
+ # # --- Supabase (server-side only) ---
36
+ # from supabase import create_client, Client
37
+
38
+ # # Set up logging
39
+ # logging.basicConfig(level=logging.INFO)
40
+
41
+ # # =========================
42
+ # # Lightweight worker system
43
+ # # =========================
44
+ # GEN_CONCURRENCY_LIMIT = int(os.getenv("GEN_CONCURRENCY_LIMIT", "6")) # inflight generations per process
45
+ # GEN_THREAD_WORKERS = int(os.getenv("GEN_THREAD_WORKERS", "8")) # worker threads per process
46
+ # REQUEST_TIMEOUT_SECONDS = float(os.getenv("REQUEST_TIMEOUT_SECONDS", "120"))
47
+ # GEN_MAX_RETRIES = int(os.getenv("GEN_MAX_RETRIES", "2"))
48
+ # GEN_MAX_QUEUE = int(os.getenv("GEN_MAX_QUEUE", "64")) # soft queue size before 503
49
+
50
+ # GEN_SEMAPHORE = asyncio.BoundedSemaphore(GEN_CONCURRENCY_LIMIT)
51
+ # EXECUTOR = ThreadPoolExecutor(max_workers=GEN_THREAD_WORKERS)
52
+
53
+ # _WAITERS = 0
54
+ # _WAITERS_LOCK = asyncio.Lock()
55
+
56
+ # class QueueOverloaded(Exception):
57
+ # pass
58
+ # # =========================
59
+ # # Idempotency (dedupe retries)
60
+ # # =========================
61
+ # _IDEMP_CACHE: "OrderedDict[str, tuple[float, dict]]" = OrderedDict()
62
+ # _IDEMP_TTL_SECONDS = int(os.getenv("IDEMP_TTL_SECONDS", "600"))
63
+
64
+ # def _idem_store(key: str, payload: dict) -> None:
65
+ # now = _time.time()
66
+ # _IDEMP_CACHE[key] = (now, payload)
67
+ # # prune expired / cap size
68
+ # for k in list(_IDEMP_CACHE.keys()):
69
+ # ts, _ = _IDEMP_CACHE[k]
70
+ # if now - ts > _IDEMP_TTL_SECONDS or len(_IDEMP_CACHE) > 500:
71
+ # _IDEMP_CACHE.pop(k, None)
72
+
73
+ # def _idem_get(key: Optional[str]) -> Optional[dict]:
74
+ # if not key:
75
+ # return None
76
+ # v = _IDEMP_CACHE.get(key)
77
+ # if not v:
78
+ # return None
79
+ # ts, payload = v
80
+ # if _time.time() - ts > _IDEMP_TTL_SECONDS:
81
+ # _IDEMP_CACHE.pop(key, None)
82
+ # return None
83
+ # return payload
84
+
85
+ # async def _enter_queue():
86
+ # global _WAITERS
87
+ # async with _WAITERS_LOCK:
88
+ # if _WAITERS >= GEN_MAX_QUEUE:
89
+ # raise QueueOverloaded("Server busy")
90
+ # _WAITERS += 1
91
+
92
+ # async def _leave_queue():
93
+ # global _WAITERS
94
+ # async with _WAITERS_LOCK:
95
+ # _WAITERS = max(0, _WAITERS - 1)
96
+
97
+ # async def _run_in_pool(fn, *args, **kwargs):
98
+ # loop = asyncio.get_running_loop()
99
+ # return await loop.run_in_executor(EXECUTOR, functools.partial(fn, *args, **kwargs))
100
+
101
+ # async def resilient_invoke(chain, variables):
102
+ # """Queue -> semaphore -> (retry + timeout) -> blocking chain.invoke in worker thread."""
103
+ # await _enter_queue()
104
+ # try:
105
+ # async with GEN_SEMAPHORE:
106
+ # for attempt in range(GEN_MAX_RETRIES + 1):
107
+ # try:
108
+ # return await asyncio.wait_for(
109
+ # _run_in_pool(chain.invoke, variables),
110
+ # timeout=REQUEST_TIMEOUT_SECONDS,
111
+ # )
112
+ # except Exception as e:
113
+ # last = attempt == GEN_MAX_RETRIES
114
+ # logging.warning(f"[gen] attempt {attempt+1}/{GEN_MAX_RETRIES+1} failed: {e}")
115
+ # if last:
116
+ # raise
117
+ # await asyncio.sleep(0.6 * (2 ** attempt)) # 0.6s, 1.2s, 2.4s ...
118
+ # finally:
119
+ # await _leave_queue()
120
+
121
+ # # -----------------------------------------
122
+ # # Optional: reuse HF clients to lower churn
123
+ # # -----------------------------------------
124
+ # _HF_CLIENTS: dict[tuple[str, str], InferenceClient] = {}
125
+
126
+ # def get_cached_hf_client(provider: str, api_key: str) -> InferenceClient:
127
+ # key = (provider or "hf-inference", api_key or "")
128
+ # c = _HF_CLIENTS.get(key)
129
+ # if c is None:
130
+ # c = InferenceClient(provider=provider, api_key=api_key)
131
+ # _HF_CLIENTS[key] = c
132
+ # return c
133
+
134
+ # # =========================
135
+ # # Supabase client & helpers
136
+ # # =========================
137
+ # SUPABASE_URL = os.getenv("SUPABASE_URL")
138
+ # SUPABASE_SERVICE_ROLE_KEY = os.getenv("SUPABASE_SERVICE_ROLE_KEY")
139
+ # ALLOW_PLAN_CREATE = os.getenv("ALLOW_PLAN_CREATE", "true").lower() == "true"
140
+
141
+ # sb: Optional[Client] = None
142
+ # if SUPABASE_URL and SUPABASE_SERVICE_ROLE_KEY:
143
+ # sb = create_client(SUPABASE_URL, SUPABASE_SERVICE_ROLE_KEY)
144
+ # else:
145
+ # logging.warning("Supabase env not configured (SUPABASE_URL / SUPABASE_SERVICE_ROLE_KEY). DB writes will be skipped.")
146
+
147
+ # def now_iso() -> str:
148
+ # return datetime.now(timezone.utc).isoformat()
149
+
150
+ # # Section → DB columns (same columns for Free & Full)
151
+ # SECTION_MAP = {
152
+ # "prompt_ExecutiveSummary": {"content":"executiveSummary","gen":"isExecutiveSummaryGenerating","done":"isExecutiveSummaryComplete"},
153
+ # "prompt_ExecutiveSummary_Free": {"content":"executiveSummary","gen":"isExecutiveSummaryGenerating","done":"isExecutiveSummaryComplete"},
154
+ # "prompt_CompanyProfile": {"content":"companyProfile","gen":"isCompanyProfileGenerating","done":"isCompanyProfileComplete"},
155
+ # "prompt_CompanyProfile_Free": {"content":"companyProfile","gen":"isCompanyProfileGenerating","done":"isCompanyProfileComplete"},
156
+ # "prompt_MarketAnalysis": {"content":"marketAnalysis","gen":"isMarketAnalysisGenerating","done":"isMarketAnalysisComplete"},
157
+ # "prompt_MarketAnalysis_Free": {"content":"marketAnalysis","gen":"isMarketAnalysisGenerating","done":"isMarketAnalysisComplete"},
158
+ # "prompt_ProductOrService": {"content":"productOrService","gen":"isProductOrServiceGenerating","done":"isProductOrServiceComplete"},
159
+ # "prompt_ProductOrService_Free": {"content":"productOrService","gen":"isProductOrServiceGenerating","done":"isProductOrServiceComplete"},
160
+ # "prompt_BusinessModel": {"content":"businessModel","gen":"isBusinessModelGenerating","done":"isBusinessModelComplete"},
161
+ # "prompt_BusinessModel_Free": {"content":"businessModel","gen":"isBusinessModelGenerating","done":"isBusinessModelComplete"},
162
+ # "prompt_MarketingGrowth": {"content":"marketingGrowth","gen":"isMarketingGrowthGenerating","done":"isMarketingGrowthComplete"},
163
+ # "prompt_MarketingGrowth_Free": {"content":"marketingGrowth","gen":"isMarketingGrowthGenerating","done":"isMarketingGrowthComplete"},
164
+ # "prompt_OperationsPlan": {"content":"operationsPlan","gen":"isOperationsPlanGenerating","done":"isOperationsPlanComplete"},
165
+ # "prompt_OperationsPlan_Free": {"content":"operationsPlan","gen":"isOperationsPlanGenerating","done":"isOperationsPlanComplete"},
166
+ # "prompt_ManagementTeam": {"content":"managementTeam","gen":"isManagementTeamGenerating","done":"isManagementTeamComplete"},
167
+ # "prompt_ManagementTeam_Free": {"content":"managementTeam","gen":"isManagementTeamGenerating","done":"isManagementTeamComplete"},
168
+ # "prompt_FinancialPlanFunding": {"content":"financialPlanFunding","gen":"isFinancialPlanFundingGenerating","done":"isFinancialPlanFundingComplete"},
169
+ # }
170
+
171
+ # # FREE gating (visible subset only)
172
+ # FREE_VISIBLE_KEYS = {
173
+ # "prompt_ExecutiveSummary_Free",
174
+ # "prompt_CompanyProfile_Free",
175
+ # "prompt_ProductOrService_Free",
176
+ # "prompt_MarketingGrowth_Free",
177
+ # }
178
+ # FREE_HIDDEN_KEYS = {
179
+ # "prompt_MarketAnalysis_Free",
180
+ # "prompt_BusinessModel_Free",
181
+ # "prompt_OperationsPlan_Free",
182
+ # "prompt_ManagementTeam_Free",
183
+ # "prompt_FinancialPlanFunding",
184
+ # }
185
+ # FULL_KEYS = {
186
+ # "prompt_ExecutiveSummary",
187
+ # "prompt_CompanyProfile",
188
+ # "prompt_MarketAnalysis",
189
+ # "prompt_ProductOrService",
190
+ # "prompt_BusinessModel",
191
+ # "prompt_MarketingGrowth",
192
+ # "prompt_OperationsPlan",
193
+ # "prompt_ManagementTeam",
194
+ # "prompt_FinancialPlanFunding",
195
+ # }
196
+
197
+ # def is_allowed_for_plan(plan_type: str, section_key: str) -> bool:
198
+ # if plan_type == "full":
199
+ # return section_key in FULL_KEYS or section_key in FREE_VISIBLE_KEYS or section_key in FREE_HIDDEN_KEYS
200
+ # # free
201
+ # return section_key in FREE_VISIBLE_KEYS
202
+
203
+ # def ensure_plan_and_section(
204
+ # businessPlanId: Optional[str],
205
+ # *,
206
+ # userId: Optional[str],
207
+ # anonymousId: Optional[str],
208
+ # planType: Optional[str],
209
+ # countryCode: Optional[str],
210
+ # model: str,
211
+ # answers: Optional[List[str]],
212
+ # ) -> str:
213
+ # """Return a valid businessPlanId. Create plan (and empty section row) if allowed and missing."""
214
+ # if sb is None:
215
+ # raise HTTPException(status_code=500, detail="Supabase not configured on server")
216
+
217
+ # if businessPlanId:
218
+ # # ensure a BusinessPlanSection row exists
219
+ # exists = sb.table("BusinessPlanSection").select("id").eq("businessPlanId", businessPlanId).limit(1).execute()
220
+ # if not exists.data:
221
+ # sb.table("BusinessPlanSection").insert({
222
+ # "businessPlanId": businessPlanId,
223
+ # "createdAt": now_iso(),
224
+ # "updatedAt": now_iso(),
225
+ # }).execute()
226
+ # return businessPlanId
227
+
228
+ # if not ALLOW_PLAN_CREATE:
229
+ # raise HTTPException(status_code=400, detail="businessPlanId is required")
230
+
231
+ # if not (userId or anonymousId):
232
+ # raise HTTPException(status_code=400, detail="userId or anonymousId required to create plan")
233
+
234
+ # created = sb.table("BusinessPlan").insert({
235
+ # "summary": "Business Plan",
236
+ # "fullPlan": "",
237
+ # "model": model,
238
+ # "answers": answers or [],
239
+ # "planType": (planType or "free"),
240
+ # "countryCode": countryCode,
241
+ # "userId": userId,
242
+ # "anonymousId": anonymousId,
243
+ # "createdAt": now_iso(),
244
+ # "updatedAt": now_iso(),
245
+ # }).execute()
246
+ # new_id = created.data[0]["id"]
247
+ # sb.table("BusinessPlanSection").insert({
248
+ # "businessPlanId": new_id,
249
+ # "createdAt": now_iso(),
250
+ # "updatedAt": now_iso(),
251
+ # }).execute()
252
+ # return new_id
253
+
254
+ # def set_section_generating(businessPlanId: str, sectionKey: str, on: bool) -> None:
255
+ # if sb is None: return
256
+ # m = SECTION_MAP[sectionKey]
257
+ # sb.table("BusinessPlanSection").upsert({
258
+ # "businessPlanId": businessPlanId,
259
+ # m["gen"]: on,
260
+ # "updatedAt": now_iso(),
261
+ # }, on_conflict="businessPlanId").execute()
262
+
263
+ # def write_section_success(businessPlanId: str, sectionKey: str, content: str) -> None:
264
+ # if sb is None: return
265
+ # m = SECTION_MAP[sectionKey]
266
+ # sb.table("BusinessPlanSection").upsert({
267
+ # "businessPlanId": businessPlanId,
268
+ # m["content"]: content,
269
+ # m["gen"]: False,
270
+ # m["done"]: True,
271
+ # "updatedAt": now_iso(),
272
+ # }, on_conflict="businessPlanId").execute()
273
+
274
+ # def write_section_error(businessPlanId: str, sectionKey: str, error_msg: str) -> None:
275
+ # if sb is None: return
276
+ # m = SECTION_MAP[sectionKey]
277
+ # sb.table("BusinessPlanSection").upsert({
278
+ # "businessPlanId": businessPlanId,
279
+ # m["gen"]: False,
280
+ # "updatedAt": now_iso(),
281
+ # }, on_conflict="businessPlanId").execute()
282
+
283
+ # # Check for required environment variables
284
+ # def check_environment_variables():
285
+ # hf_token = os.getenv("HUGGINGFACEHUB_API_TOKEN")
286
+ # if not hf_token:
287
+ # logging.warning("⚠️ HUGGINGFACEHUB_API_TOKEN not set. Hugging Face models will not work properly.")
288
+ # else:
289
+ # logging.info("✅ HUGGINGFACEHUB_API_TOKEN is set")
290
+
291
+ # openai_api_key = os.getenv("OPENAI_API_KEY")
292
+ # if not openai_api_key:
293
+ # logging.warning("⚠️ OPENAI_API_KEY not set. OpenAI GPT models will not work properly.")
294
+ # else:
295
+ # logging.info("✅ OPENAI_API_KEY is set")
296
+
297
+ # # Run the environment variable check
298
+ # check_environment_variables()
299
+
300
+ # # Display information about model size limitations
301
+ # logging.info("=" * 80)
302
+ # logging.info("MODEL SIZE LIMITATIONS:")
303
+ # logging.info("The free tier of Hugging Face Inference API limits models to 10GB.")
304
+ # logging.info("Large models like Qwen-2.5-7B (15GB) and Llama-2-7B (13GB) exceed this limit.")
305
+ # logging.info("We've configured smaller alternative models as replacements.")
306
+ # logging.info("For full-sized models, upgrade to Hugging Face Pro subscription.")
307
+ # logging.info("=" * 80)
308
+
309
+ # # Create the FastAPI app
310
+ # app = FastAPI()
311
+
312
+ # # Add logging middleware
313
+ # @app.middleware("http")
314
+ # async def log_requests(request: Request, call_next):
315
+ # start_time = _time.time()
316
+ # response = await call_next(request)
317
+ # process_time = _time.time() - start_time
318
+ # logging.info(f"Request: {request.method} {request.url.path} - Status: {response.status_code} - Time: {process_time:.2f}s")
319
+ # return response
320
+
321
+ # # Configure CORS for Hugging Face Spaces
322
+ # allowed = [o.strip() for o in os.getenv("FASTAPI_ALLOWED_ORIGINS", "").split(",") if o.strip()]
323
+ # app.add_middleware(
324
+ # CORSMiddleware,
325
+ # allow_origins=allowed or ["http://localhost:3000"],
326
+ # allow_credentials=True,
327
+ # allow_methods=["POST","GET","OPTIONS"],
328
+ # allow_headers=["*"],
329
+ # expose_headers=["*"]
330
+ # )
331
+ # # Fixed list of business questions (order matters)
332
+ # QUESTIONS = [
333
+ # "What is your business name?",
334
+ # "What product or service do you offer?",
335
+ # "Who is your target customer?",
336
+ # "What problem does your business solve?",
337
+ # "Who are your competitors?",
338
+ # "What is your unique value proposition?",
339
+ # "What is your pricing strategy?",
340
+ # "What are your short-term goals?",
341
+ # "What are your long-term goals?",
342
+ # "How will you acquire customers?",
343
+ # ]
344
+
345
+ # # Data model for incoming business plan generation requests
346
+ # class GenerateRequest(BaseModel):
347
+ # answers: List[str]
348
+ # model: str
349
+ # prompt: str # The prompt template from the database
350
+ # promptVariables: Dict[str, str] # Variables to fill in the template
351
+ # provider: str # The provider (e.g., "hf-inference", "together")
352
+ # temperature: float # Temperature setting
353
+ # maxTokens: int # Max tokens setting
354
+ # # New: minimal IDs for DB persistence
355
+ # businessPlanId: Optional[str] = None
356
+ # userId: Optional[str] = None
357
+ # anonymousId: Optional[str] = None
358
+ # planType: Optional[str] = "free"
359
+ # countryCode: Optional[str] = None
360
+ # sectionKey: Optional[str] = None # required for per-section save
361
+ # idempotencyKey: Optional[str] = None # ← ADD THIS
362
+
363
+ # def generate_model_output(model: str, provider: str, api_key: str, prompt: str, max_tokens: int = 4000) -> str:
364
+ # """
365
+ # A helper function that wraps the Hugging Face Inference API call.
366
+ # """
367
+ # try:
368
+ # logging.info(f"Initializing InferenceClient with provider: {provider}")
369
+ # client = get_cached_hf_client(provider=provider, api_key=api_key) # reuse client
370
+ # logging.info(f"Sending request to model: {model} with prompt length: {len(prompt)} and max_tokens: {max_tokens}")
371
+ # completion = client.chat.completions.create(
372
+ # model=model,
373
+ # messages=[{"role": "user", "content": prompt}],
374
+ # max_tokens=max_tokens,
375
+ # )
376
+ # logging.info(f"Successfully received response from model: {model} with content length: {len(completion.choices[0].message.content)}")
377
+ # return completion.choices[0].message.content
378
+ # except Exception as e:
379
+ # error_message = f"Error generating output with model {model}: {str(e)}"
380
+ # logging.error(error_message)
381
+ # # Re-raise the exception with more context
382
+ # raise Exception(error_message) from e
383
+
384
+ # ###############################################################################
385
+ # # HFInferenceLLM: A wrapper for Hugging Face models that matches the LLM interface.
386
+ # ###############################################################################
387
+ # class HFInferenceLLM(LLM):
388
+ # model: str = None
389
+ # provider: str = "hf-inference"
390
+ # api_key: str = ""
391
+ # max_tokens: int = 4000 # Increased from 500 to 4000 by default
392
+
393
+
394
+ # def __init__(self, model: str, provider: str = "hf-inference", api_key: str = "", max_tokens: int = 4000):
395
+ # super().__init__()
396
+ # self.model = model
397
+ # self.provider = provider
398
+ # self.api_key = api_key
399
+ # self.max_tokens = max_tokens
400
+
401
+ # # Validate API key
402
+ # if not api_key:
403
+ # logging.error(f"No API key provided for model {model}")
404
+ # raise ValueError(f"API key is required for Hugging Face Inference API access to {model}")
405
+
406
+ # # Check if model is available
407
+ # try:
408
+ # # Create a client to validate connection
409
+ # _ = get_cached_hf_client(provider=provider, api_key=api_key)
410
+ # logging.info(f"Successfully initialized client for model: {model}")
411
+ # except Exception as e:
412
+ # logging.error(f"Failed to initialize client for model {model}: {str(e)}")
413
+ # raise ValueError(f"Could not initialize Hugging Face client for {model}: {str(e)}")
414
+
415
+ # @property
416
+ # def _llm_type(self) -> str:
417
+ # return "hf_inference"
418
+
419
+ # def _call(self, prompt: str, stop: Optional[List[str]] = None) -> str:
420
+ # """
421
+ # Calls the Hugging Face API using the provided prompt.
422
+ # The `stop` parameter is not implemented (or forwarded) here.
423
+ # """
424
+ # return generate_model_output(
425
+ # model=self.model,
426
+ # provider=self.provider,
427
+ # api_key=self.api_key,
428
+ # prompt=prompt,
429
+ # max_tokens=self.max_tokens
430
+ # )
431
+
432
+ # def get_num_tokens(self, prompt: str) -> int:
433
+ # """
434
+ # A simple implementation that counts tokens as space-separated words.
435
+ # You may replace this with a more accurate token counter.
436
+ # """
437
+ # return len(prompt.split())
438
+
439
+ # ###############################################################################
440
+ # # get_llm: Return an LLM instance based on the selected model.
441
+ # ###############################################################################
442
+ # def get_llm(model_name: str, provider: str = "hf-inference"):
443
+ # if model_name in ("BPGenerateAI", "BPSuggestionsAI") or model_name.lower().startswith("gpt-"):
444
+ # openai_api_key = os.getenv("OPENAI_API_KEY")
445
+ # if not openai_api_key:
446
+ # logging.error("OPENAI_API_KEY environment variable not set")
447
+ # raise ValueError("OPENAI_API_KEY environment variable is required for GPT models")
448
+ # if model_name == "BPGenerateAI":
449
+ # return ChatOpenAI(model_name="gpt-4.1-mini", temperature=0.7, max_tokens=6000, openai_api_key=openai_api_key)
450
+ # if model_name == "BPSuggestionsAI":
451
+ # return ChatOpenAI(model_name="gpt-4.1-nano", temperature=0.7, max_tokens=4000, openai_api_key=openai_api_key)
452
+ # return ChatOpenAI(model_name=model_name, temperature=0.7, max_tokens=4000, openai_api_key=openai_api_key)
453
+
454
+ # hf_token = os.getenv("HUGGINGFACEHUB_API_TOKEN")
455
+ # if not hf_token:
456
+ # logging.error("HUGGINGFACEHUB_API_TOKEN environment variable not set")
457
+ # raise ValueError("HUGGINGFACEHUB_API_TOKEN is required for Hugging Face models")
458
+ # return HFInferenceLLM(model=model_name, provider=provider, api_key=hf_token, max_tokens=4000)
459
+
460
+ # # hf_token = os.getenv("HUGGINGFACEHUB_API_TOKEN")
461
+ # # if not hf_token:
462
+ # # logging.error("HUGGINGFACEHUB_API_TOKEN environment variable not set")
463
+ # # raise ValueError("HUGGINGFACEHUB_API_TOKEN environment variable is required for Hugging Face models")
464
+
465
+ # # if model_name == "BPGenerateAI":
466
+ # # openai_api_key = os.getenv("OPENAI_API_KEY")
467
+ # # if not openai_api_key:
468
+ # # logging.error("OPENAI_API_KEY environment variable not set")
469
+ # # raise ValueError("OPENAI_API_KEY environment variable is required for GPT models")
470
+ # # return ChatOpenAI(
471
+ # # model_name="gpt-4.1-mini",
472
+ # # temperature=0.7,
473
+ # # max_tokens=6000,
474
+ # # openai_api_key=openai_api_key
475
+ # # )
476
+ # # elif model_name == "BPSuggestionsAI":
477
+ # # openai_api_key = os.getenv("OPENAI_API_KEY")
478
+ # # if not openai_api_key:
479
+ # # logging.error("OPENAI_API_KEY environment variable not set")
480
+ # # raise ValueError("OPENAI_API_KEY environment variable is required for GPT models")
481
+ # # return ChatOpenAI(
482
+ # # model_name="gpt-4.1-nano",
483
+ # # temperature=0.7,
484
+ # # max_tokens=4000,
485
+ # # openai_api_key=openai_api_key
486
+ # # )
487
+ # # elif model_name.lower() == "gpt-4.1-nano":
488
+ # # openai_api_key = os.getenv("OPENAI_API_KEY")
489
+ # # if not openai_api_key:
490
+ # # raise ValueError("OPENAI_API_KEY is required for GPT models")
491
+ # # return ChatOpenAI(
492
+ # # model_name="gpt-4.1-nano",
493
+ # # temperature=0.7,
494
+ # # max_tokens=4000,
495
+ # # openai_api_key=openai_api_key
496
+ # # )
497
+ # # else:
498
+ # # # For all other models, use the specified provider
499
+ # # return HFInferenceLLM(
500
+ # # model=model_name,
501
+ # # provider=provider,
502
+ # # api_key=hf_token,
503
+ # # max_tokens=4000
504
+ # # )
505
+
506
+ # ###############################################################################
507
+ # # FastAPI Endpoints
508
+ # ###############################################################################
509
+
510
+ # @app.get("/suggestions", response_model=Dict[str, Any])
511
+ # async def get_suggestions(
512
+ # business_idea: str,
513
+ # model: str,
514
+ # question: str,
515
+ # prompt: str,
516
+ # provider: str,
517
+ # temperature: float,
518
+ # maxTokens: int,
519
+ # exclude: List[str] = Query([])
520
+ # ) -> Dict[str, Any]:
521
+ # try:
522
+ # # Use the prompt from the frontend
523
+ # formatted_prompt = prompt.format(
524
+ # business_idea=business_idea,
525
+ # question=question,
526
+ # exclude=", ".join(exclude) if exclude else ""
527
+ # )
528
+
529
+ # # Get the LLM instance with frontend-provided configuration
530
+ # llm = get_llm(model, provider)
531
+ # if hasattr(llm, 'temperature'):
532
+ # llm.temperature = temperature
533
+ # if hasattr(llm, 'max_tokens'):
534
+ # llm.max_tokens = maxTokens
535
+
536
+ # suggestion_prompt = PromptTemplate(
537
+ # input_variables=[],
538
+ # template=formatted_prompt
539
+ # )
540
+ # suggestion_chain: RunnableSequence = suggestion_prompt | llm
541
+
542
+ # # resilience wrapper
543
+ # raw_result = await resilient_invoke(suggestion_chain, {})
544
+
545
+ # # Normalize AIMessage or list[AIMessage] → plain string
546
+ # if isinstance(raw_result, AIMessage):
547
+ # raw_text = raw_result.content
548
+ # elif (
549
+ # isinstance(raw_result, (list, tuple))
550
+ # and raw_result
551
+ # and isinstance(raw_result[0], AIMessage)
552
+ # ):
553
+ # raw_text = "\n".join(msg.content for msg in raw_result)
554
+ # else:
555
+ # raw_text = str(raw_result)
556
+
557
+ # # Clean up the suggestions
558
+ # suggestion_array = [
559
+ # re.sub(r'^\s*[\-\d\.\)\s]+', '', line).strip()
560
+ # for line in raw_text.split("\n")
561
+ # if line.strip()
562
+ # ]
563
+ # if not suggestion_array:
564
+ # suggestion_array = ["No suggestions available"]
565
+ # return {"suggestions": suggestion_array}
566
+ # except QueueOverloaded:
567
+ # raise HTTPException(status_code=503, detail="Server busy, please retry shortly")
568
+ # except AsyncTimeoutError:
569
+ # raise HTTPException(status_code=504, detail="Generation timed out")
570
+ # except Exception as e:
571
+ # raise HTTPException(status_code=500, detail=f"Error generating suggestions: {str(e)}")
572
+
573
+ # @app.post("/generate")
574
+ # async def generate_business_plan(data: GenerateRequest, request: Request):
575
+ # """
576
+ # Generate a business plan using the provided prompt template and variables.
577
+ # - If sectionKey provided: generate that section, save to Supabase, return ACK + preview.
578
+ # - Else: one-shot body written to BusinessPlan.fullPlan (if businessPlanId is available/created).
579
+ # """
580
+ # try:
581
+ # # Idempotency fast path (header or body)
582
+ # idem_key = data.idempotencyKey or request.headers.get("Idempotency-Key")
583
+ # cached = _idem_get(idem_key)
584
+ # if cached:
585
+ # return cached
586
+
587
+ # # Get the LLM instance based on the selected model and provider
588
+ # logging.info(f"[plan={data.businessPlanId}|section={data.sectionKey}] model={data.model} provider={data.provider} init")
589
+ # llm_selected = get_llm(data.model, data.provider)
590
+ # # Get the LLM instance based on the selected model and provider
591
+ # # logging.info(f"Initializing model: {data.model} with provider: {data.provider}")
592
+ # # llm_selected = get_llm(data.model, data.provider) # ✅ provider bugfix
593
+
594
+ # # Set the temperature and max tokens from the request
595
+ # if hasattr(llm_selected, 'temperature'):
596
+ # llm_selected.temperature = data.temperature
597
+ # if hasattr(llm_selected, 'max_tokens'):
598
+ # llm_selected.max_tokens = data.maxTokens
599
+
600
+ # # Create the prompt template
601
+ # plan_prompt = PromptTemplate(
602
+ # input_variables=list(data.promptVariables.keys()),
603
+ # template=data.prompt
604
+ # )
605
+ # plan_chain: RunnableSequence = plan_prompt | llm_selected
606
+
607
+ # logging.info(f"Generating with model: {data.model}")
608
+
609
+ # # If we have a sectionKey, we will persist per-section
610
+ # if data.sectionKey:
611
+ # if data.sectionKey not in SECTION_MAP:
612
+ # raise HTTPException(status_code=400, detail=f"Unknown sectionKey: {data.sectionKey}")
613
+
614
+ # # ensure plan + section container
615
+ # plan_id = ensure_plan_and_section(
616
+ # data.businessPlanId,
617
+ # userId=data.userId,
618
+ # anonymousId=data.anonymousId,
619
+ # planType=data.planType,
620
+ # countryCode=data.countryCode or data.promptVariables.get("country"),
621
+ # model=data.model,
622
+ # answers=data.answers,
623
+ # )
624
+
625
+ # # authoritative planType from DB (if available)
626
+ # plan_type = (data.planType or "free").lower()
627
+ # try:
628
+ # if sb is not None:
629
+ # sel = sb.table("BusinessPlan").select("planType").eq("id", plan_id).limit(1).execute()
630
+ # if sel.data:
631
+ # plan_type = (sel.data[0]["planType"] or "free").lower()
632
+ # except Exception as _:
633
+ # pass
634
+
635
+ # # FREE/FULL gating (small, safe)
636
+ # if not is_allowed_for_plan(plan_type, data.sectionKey):
637
+ # raise HTTPException(status_code=403, detail=f"Section '{data.sectionKey}' not allowed for planType='{plan_type}'")
638
+
639
+ # # mark generating
640
+ # set_section_generating(plan_id, data.sectionKey, True)
641
+
642
+ # try:
643
+ # # generate with resilience
644
+ # raw_plan = await resilient_invoke(plan_chain, data.promptVariables)
645
+
646
+ # # unwrap to text
647
+ # if isinstance(raw_plan, AIMessage):
648
+ # plan_text = raw_plan.content
649
+ # elif isinstance(raw_plan, (list, tuple)) and raw_plan and isinstance(raw_plan[0], AIMessage):
650
+ # plan_text = "\n".join(msg.content for msg in raw_plan)
651
+ # else:
652
+ # plan_text = str(raw_plan)
653
+
654
+ # # persist success
655
+ # write_section_success(plan_id, data.sectionKey, plan_text)
656
+
657
+ # resp = {
658
+ # "saved": True,
659
+ # "businessPlanId": plan_id,
660
+ # "sections": [{"key": data.sectionKey, "content": plan_text}],
661
+ # "isComplete": True,
662
+ # "currentSection": data.sectionKey,
663
+ # "nextSectionIndex": None,
664
+ # }
665
+ # if idem_key: _idem_store(idem_key, resp)
666
+ # return resp
667
+
668
+ # except QueueOverloaded:
669
+ # # clear generating flag
670
+ # write_section_error(plan_id, data.sectionKey, "queue overloaded")
671
+ # raise HTTPException(status_code=503, detail="Server busy, please retry shortly")
672
+ # except AsyncTimeoutError:
673
+ # write_section_error(plan_id, data.sectionKey, "timeout")
674
+ # raise HTTPException(status_code=504, detail="Generation timed out")
675
+ # except Exception as e:
676
+ # write_section_error(plan_id, data.sectionKey, str(e))
677
+ # error_message = f"Error generating section with model {data.model}: {str(e)}"
678
+ # logging.error(error_message)
679
+ # raise HTTPException(status_code=500, detail=error_message)
680
+
681
+ # # No sectionKey → one-shot body (optional), persist to BusinessPlan.fullPlan
682
+ # try:
683
+ # # ensure plan row exists (may create if allowed)
684
+ # plan_id = ensure_plan_and_section(
685
+ # data.businessPlanId,
686
+ # userId=data.userId,
687
+ # anonymousId=data.anonymousId,
688
+ # planType=data.planType,
689
+ # countryCode=data.countryCode or data.promptVariables.get("country"),
690
+ # model=data.model,
691
+ # answers=data.answers,
692
+ # )
693
+
694
+ # raw_plan = await resilient_invoke(plan_chain, data.promptVariables)
695
+
696
+ # if isinstance(raw_plan, AIMessage):
697
+ # plan_text = raw_plan.content
698
+ # elif isinstance(raw_plan, (list, tuple)) and raw_plan and isinstance(raw_plan[0], AIMessage):
699
+ # plan_text = "\n".join(msg.content for msg in raw_plan)
700
+ # else:
701
+ # plan_text = str(raw_plan)
702
+
703
+ # # best-effort persist
704
+ # if sb is not None:
705
+ # sb.table("BusinessPlan").update({
706
+ # "fullPlan": plan_text,
707
+ # "updatedAt": now_iso(),
708
+ # }).eq("id", plan_id).execute()
709
+
710
+ # resp = {
711
+ # "summary": "Generated Business Plan",
712
+ # "plan": plan_text,
713
+ # "businessPlanId": plan_id,
714
+ # "saved": True
715
+ # }
716
+ # if idem_key: _idem_store(idem_key, resp)
717
+ # return resp
718
+
719
+ # except QueueOverloaded:
720
+ # raise HTTPException(status_code=503, detail="Server busy, please retry shortly")
721
+ # except AsyncTimeoutError:
722
+ # raise HTTPException(status_code=504, detail="Generation timed out")
723
+ # except Exception as e:
724
+ # error_message = f"Error generating business plan with model {data.model}: {str(e)}"
725
+ # logging.error(error_message)
726
+ # raise HTTPException(status_code=500, detail=error_message)
727
+
728
+ # except HTTPException:
729
+ # raise
730
+ # except Exception as e:
731
+ # error_message = f"Error initializing model {data.model}: {str(e)}"
732
+ # logging.error(error_message)
733
+ # raise HTTPException(status_code=500, detail=error_message)
734
+
735
+ # @app.get("/")
736
+ # def root():
737
+ # return {"status": "FastAPI is running 🚀"}
738
+
739
+ # @app.get("/health")
740
+ # def health_check():
741
+ # """
742
+ # Health check endpoint to verify API connectivity and model availability.
743
+ # """
744
+ # hf_token = os.getenv("HUGGINGFACEHUB_API_TOKEN")
745
+ # openai_api_key = os.getenv("OPENAI_API_KEY")
746
+
747
+ # status = {
748
+ # "api": "healthy",
749
+ # "models": {}
750
+ # }
751
+
752
+ # status["queue"] = {
753
+ # "waiters": _WAITERS,
754
+ # "concurrency_limit": GEN_CONCURRENCY_LIMIT,
755
+ # }
756
+
757
+ # # Check Hugging Face models
758
+ # models_to_check = ["Mistral", "Qwen-2.5", "Llama", "Gemma", "Phi-3"]
759
+
760
+ # # for model_name in models_to_check:
761
+ # # try:
762
+ # # # Just initialize the model without running inference
763
+ # # if model_name == "":
764
+ # # client = InferenceClient(provider="hf-inference", api_key=hf_token)
765
+ # # # Just a simple check that the model exists
766
+ # # client.model_info("mistralai/Mistral-7B-Instruct-v0.3")
767
+ # # status["models"][model_name] = "available"
768
+ # # elif model_name == "Qwen-2.5":
769
+ # # # Check Qwen-2.5 availability
770
+ # # client = InferenceClient(provider="hf-inference", api_key=hf_token)
771
+ # # client.model_info("Qwen/Qwen2.5-7B-Instruct")
772
+ # # status["models"][model_name] = "available"
773
+ # # elif model_name == "Llama":
774
+ # # # Check Llama availability
775
+ # # client = InferenceClient(provider="hf-inference", api_key=hf_token)
776
+ # # client.model_info("meta-llama/Llama-2-7b-chat-hf")
777
+ # # status["models"][model_name] = "available"
778
+ # # else:
779
+ # # # Simplified check for other models
780
+ # # status["models"][model_name] = "not checked"
781
+ # # except Exception as e:
782
+ # # status["models"][model_name] = f"error: {str(e)}"
783
+
784
+ # # Check OpenAI/GPT model
785
+ # if openai_api_key:
786
+ # try:
787
+ # status["models"]["GPT"] = "available"
788
+ # except Exception as e:
789
+ # status["models"]["GPT"] = f"error: {str(e)}"
790
+ # else:
791
+ # status["models"]["GPT"] = "unavailable (missing API key)"
792
+
793
+ # return status
794
+
795
+
796
+
797
  ##########################
798
+ # #NEW TEST#
799
  ##########################
800
  import os
801
  import asyncio
802
  import logging
803
  from typing import List, Dict, Any, Optional
804
+ # Literal fallback for older Python
805
+ try:
806
+ from typing import Literal
807
+ except ImportError:
808
+ from typing_extensions import Literal
809
  import re
810
  from collections import OrderedDict
811
  import time as _time
 
856
 
857
  class QueueOverloaded(Exception):
858
  pass
859
+
860
  # =========================
861
  # Idempotency (dedupe retries)
862
  # =========================
 
864
  _IDEMP_TTL_SECONDS = int(os.getenv("IDEMP_TTL_SECONDS", "600"))
865
 
866
  def _idem_store(key: str, payload: dict) -> None:
867
+ if not key:
868
+ return
869
  now = _time.time()
870
  _IDEMP_CACHE[key] = (now, payload)
871
  # prune expired / cap size
 
1084
  "updatedAt": now_iso(),
1085
  }, on_conflict="businessPlanId").execute()
1086
 
1087
+ # ---- Completion & full plan rebuild helpers ----
1088
+
1089
+ # Order used when we rebuild the full plan text
1090
+ SECTION_ORDER_FULL = [
1091
+ "prompt_ExecutiveSummary",
1092
+ "prompt_CompanyProfile",
1093
+ "prompt_MarketAnalysis",
1094
+ "prompt_ProductOrService",
1095
+ "prompt_BusinessModel",
1096
+ "prompt_MarketingGrowth",
1097
+ "prompt_OperationsPlan",
1098
+ "prompt_ManagementTeam",
1099
+ "prompt_FinancialPlanFunding",
1100
+ ]
1101
+ SECTION_ORDER_FREE = [
1102
+ "prompt_ExecutiveSummary_Free",
1103
+ "prompt_CompanyProfile_Free",
1104
+ "prompt_ProductOrService_Free",
1105
+ "prompt_MarketingGrowth_Free",
1106
+ ]
1107
+
1108
+ def _required_keys_for_plan(plan_type: str) -> List[str]:
1109
+ return SECTION_ORDER_FULL if (plan_type or "free").lower() == "full" else SECTION_ORDER_FREE
1110
+
1111
+ def _content_col_for_key(section_key: str) -> str:
1112
+ return SECTION_MAP[section_key]["content"]
1113
+
1114
+ def check_and_update_plan_completion(businessPlanId: str, plan_type: str) -> None:
1115
+ if sb is None: return
1116
+ required = [k for k in _required_keys_for_plan(plan_type) if k in SECTION_MAP]
1117
+ if not required:
1118
+ return
1119
+ cols = [SECTION_MAP[k]["done"] for k in required]
1120
+ sel = sb.table("BusinessPlanSection").select(",".join(cols)).eq("businessPlanId", businessPlanId).limit(1).execute()
1121
+ if not sel.data:
1122
+ return
1123
+ done = all(sel.data[0].get(c) for c in cols)
1124
+ if done:
1125
+ rebuild_full_plan(businessPlanId, plan_type)
1126
+
1127
+ def rebuild_full_plan(businessPlanId: str, plan_type: str) -> None:
1128
+ if sb is None: return
1129
+ order = [k for k in _required_keys_for_plan(plan_type) if k in SECTION_MAP]
1130
+ cols = list({SECTION_MAP[k]["content"] for k in order})
1131
+ res = sb.table("BusinessPlanSection").select(",".join(cols)).eq("businessPlanId", businessPlanId).limit(1).execute()
1132
+ if not res.data:
1133
+ return
1134
+ row = res.data[0]
1135
+ parts: list[str] = []
1136
+ for k in order:
1137
+ m = SECTION_MAP.get(k)
1138
+ if not m:
1139
+ continue
1140
+ txt = (row.get(m["content"]) or "").strip()
1141
+ if txt:
1142
+ parts.append(txt)
1143
+ full_text = "\n\n".join(parts)
1144
+ sb.table("BusinessPlan").update({
1145
+ "fullPlan": full_text,
1146
+ "updatedAt": now_iso()
1147
+ }).eq("id", businessPlanId).execute()
1148
+
1149
  # Check for required environment variables
1150
  def check_environment_variables():
1151
  hf_token = os.getenv("HUGGINGFACEHUB_API_TOKEN")
 
1194
  allow_headers=["*"],
1195
  expose_headers=["*"]
1196
  )
1197
+
1198
  # Fixed list of business questions (order matters)
1199
  QUESTIONS = [
1200
  "What is your business name?",
 
1209
  "How will you acquire customers?",
1210
  ]
1211
 
1212
+ # ----------------------
1213
+ # Request models
1214
+ # ----------------------
1215
  class GenerateRequest(BaseModel):
1216
  answers: List[str]
1217
  model: str
 
1227
  planType: Optional[str] = "free"
1228
  countryCode: Optional[str] = None
1229
  sectionKey: Optional[str] = None # required for per-section save
1230
+ idempotencyKey: Optional[str] = None # idempotency
1231
+
1232
+ class SectionSpec(BaseModel):
1233
+ key: str
1234
+ prompt: str
1235
+ promptVariables: Dict[str, str]
1236
+
1237
+ class StartPlanGenerationRequest(BaseModel):
1238
+ # generation config
1239
+ model: str
1240
+ provider: str
1241
+ temperature: float
1242
+ maxTokens: int
1243
+
1244
+ # plan context
1245
+ planType: Literal["free", "full"] = "free"
1246
+ countryCode: Optional[str] = None
1247
+ answers: List[str]
1248
+
1249
+ # identity / persistence
1250
+ businessPlanId: Optional[str] = None
1251
+ userId: Optional[str] = None
1252
+ anonymousId: Optional[str] = None
1253
+ idempotencyKey: Optional[str] = None
1254
 
1255
+ # all sections at once
1256
+ sections: List[SectionSpec]
1257
+
1258
+ # ----------------------
1259
+ # LLM plumbing
1260
+ # ----------------------
1261
  def generate_model_output(model: str, provider: str, api_key: str, prompt: str, max_tokens: int = 4000) -> str:
1262
  """
1263
  A helper function that wraps the Hugging Face Inference API call.
 
1276
  except Exception as e:
1277
  error_message = f"Error generating output with model {model}: {str(e)}"
1278
  logging.error(error_message)
 
1279
  raise Exception(error_message) from e
1280
 
 
 
 
1281
  class HFInferenceLLM(LLM):
1282
  model: str = None
1283
  provider: str = "hf-inference"
1284
  api_key: str = ""
1285
  max_tokens: int = 4000 # Increased from 500 to 4000 by default
 
1286
 
1287
  def __init__(self, model: str, provider: str = "hf-inference", api_key: str = "", max_tokens: int = 4000):
1288
  super().__init__()
 
1298
 
1299
  # Check if model is available
1300
  try:
 
1301
  _ = get_cached_hf_client(provider=provider, api_key=api_key)
1302
  logging.info(f"Successfully initialized client for model: {model}")
1303
  except Exception as e:
 
1309
  return "hf_inference"
1310
 
1311
  def _call(self, prompt: str, stop: Optional[List[str]] = None) -> str:
 
 
 
 
1312
  return generate_model_output(
1313
  model=self.model,
1314
  provider=self.provider,
 
1318
  )
1319
 
1320
  def get_num_tokens(self, prompt: str) -> int:
 
 
 
 
1321
  return len(prompt.split())
1322
 
 
 
 
1323
  def get_llm(model_name: str, provider: str = "hf-inference"):
1324
  if model_name in ("BPGenerateAI", "BPSuggestionsAI") or model_name.lower().startswith("gpt-"):
1325
  openai_api_key = os.getenv("OPENAI_API_KEY")
 
1338
  raise ValueError("HUGGINGFACEHUB_API_TOKEN is required for Hugging Face models")
1339
  return HFInferenceLLM(model=model_name, provider=provider, api_key=hf_token, max_tokens=4000)
1340
 
1341
+ # ----------------------
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1342
  # FastAPI Endpoints
1343
+ # ----------------------
 
1344
  @app.get("/suggestions", response_model=Dict[str, Any])
1345
  async def get_suggestions(
1346
  business_idea: str,
 
1353
  exclude: List[str] = Query([])
1354
  ) -> Dict[str, Any]:
1355
  try:
 
1356
  formatted_prompt = prompt.format(
1357
  business_idea=business_idea,
1358
  question=question,
1359
  exclude=", ".join(exclude) if exclude else ""
1360
  )
1361
 
 
1362
  llm = get_llm(model, provider)
1363
  if hasattr(llm, 'temperature'):
1364
  llm.temperature = temperature
 
1371
  )
1372
  suggestion_chain: RunnableSequence = suggestion_prompt | llm
1373
 
 
1374
  raw_result = await resilient_invoke(suggestion_chain, {})
1375
 
 
1376
  if isinstance(raw_result, AIMessage):
1377
  raw_text = raw_result.content
1378
  elif (
 
1384
  else:
1385
  raw_text = str(raw_result)
1386
 
 
1387
  suggestion_array = [
1388
  re.sub(r'^\s*[\-\d\.\)\s]+', '', line).strip()
1389
  for line in raw_text.split("\n")
 
1399
  except Exception as e:
1400
  raise HTTPException(status_code=500, detail=f"Error generating suggestions: {str(e)}")
1401
 
1402
+ # ---- Bulk plan generation worker ----
1403
+ async def run_section_job(
1404
+ *,
1405
+ plan_id: str,
1406
+ plan_type: str,
1407
+ section: SectionSpec,
1408
+ model_name: str,
1409
+ provider: str,
1410
+ temperature: float,
1411
+ max_tokens: int,
1412
+ ) -> None:
1413
+ if section.key not in SECTION_MAP:
1414
+ logging.warning(f"[plan={plan_id}] Unknown section key '{section.key}', skipping")
1415
+ return
1416
+
1417
+ if not is_allowed_for_plan(plan_type, section.key):
1418
+ logging.info(f"[plan={plan_id}] Section '{section.key}' not allowed for planType='{plan_type}', skipping")
1419
+ return
1420
+
1421
+ llm = get_llm(model_name, provider)
1422
+ if hasattr(llm, "temperature"): llm.temperature = temperature
1423
+ if hasattr(llm, "max_tokens"): llm.max_tokens = max_tokens
1424
+
1425
+ tmpl = PromptTemplate(input_variables=list(section.promptVariables.keys()), template=section.prompt)
1426
+ chain: RunnableSequence = tmpl | llm
1427
+
1428
+ set_section_generating(plan_id, section.key, True)
1429
+
1430
+ try:
1431
+ raw = await resilient_invoke(chain, section.promptVariables)
1432
+ text = (
1433
+ raw.content if isinstance(raw, AIMessage)
1434
+ else ("\n".join(m.content for m in raw) if isinstance(raw, (list, tuple)) and raw and isinstance(raw[0], AIMessage)
1435
+ else str(raw))
1436
+ )
1437
+ write_section_success(plan_id, section.key, text)
1438
+ except Exception as e:
1439
+ write_section_error(plan_id, section.key, str(e))
1440
+ logging.exception(f"[plan={plan_id}] section={section.key} failed: {e}")
1441
+ finally:
1442
+ try:
1443
+ check_and_update_plan_completion(plan_id, plan_type)
1444
+ except Exception as e:
1445
+ logging.warning(f"[plan={plan_id}] completion check failed: {e}")
1446
+
1447
+ # ---- New bulk endpoint: send all sections at once ----
1448
+ @app.post("/start_plan_generation")
1449
+ async def start_plan_generation(req: StartPlanGenerationRequest, request: Request):
1450
+ # Idempotency
1451
+ idem_key = req.idempotencyKey or request.headers.get("Idempotency-Key")
1452
+ cached = _idem_get(idem_key)
1453
+ if cached:
1454
+ return cached
1455
+
1456
+ # ensure plan exists (may create)
1457
+ plan_id = ensure_plan_and_section(
1458
+ req.businessPlanId,
1459
+ userId=req.userId,
1460
+ anonymousId=req.anonymousId,
1461
+ planType=req.planType,
1462
+ countryCode=req.countryCode,
1463
+ model=req.model,
1464
+ answers=req.answers,
1465
+ )
1466
+
1467
+ # authoritative planType from DB if present
1468
+ plan_type = (req.planType or "free").lower()
1469
+ try:
1470
+ if sb is not None:
1471
+ sel = sb.table("BusinessPlan").select("planType").eq("id", plan_id).limit(1).execute()
1472
+ if sel.data:
1473
+ plan_type = (sel.data[0]["planType"] or "free").lower()
1474
+ except Exception:
1475
+ pass
1476
+
1477
+ # Filter only allowed sections & mark generating up-front for instant UI feedback
1478
+ allowed_sections = [s for s in req.sections if is_allowed_for_plan(plan_type, s.key)]
1479
+ for s in allowed_sections:
1480
+ try:
1481
+ set_section_generating(plan_id, s.key, True)
1482
+ except Exception:
1483
+ pass
1484
+
1485
+ # Spawn jobs (fire-and-forget; resilient_invoke handles queue/timeout/retry)
1486
+ for s in allowed_sections:
1487
+ asyncio.create_task(run_section_job(
1488
+ plan_id=plan_id,
1489
+ plan_type=plan_type,
1490
+ section=s,
1491
+ model_name=req.model,
1492
+ provider=req.provider,
1493
+ temperature=req.temperature,
1494
+ max_tokens=req.maxTokens,
1495
+ ))
1496
+
1497
+ resp = {
1498
+ "businessPlanId": plan_id,
1499
+ "acceptedSections": [s.key for s in allowed_sections],
1500
+ "status": "processing"
1501
+ }
1502
+ _idem_store(idem_key, resp)
1503
+ return resp
1504
+
1505
+ # ---- Legacy / single-section endpoint (kept intact) ----
1506
  @app.post("/generate")
1507
  async def generate_business_plan(data: GenerateRequest, request: Request):
1508
  """
 
1520
  # Get the LLM instance based on the selected model and provider
1521
  logging.info(f"[plan={data.businessPlanId}|section={data.sectionKey}] model={data.model} provider={data.provider} init")
1522
  llm_selected = get_llm(data.model, data.provider)
 
 
 
1523
 
1524
  # Set the temperature and max tokens from the request
1525
  if hasattr(llm_selected, 'temperature'):
 
1562
  except Exception as _:
1563
  pass
1564
 
1565
+ # FREE/FULL gating
1566
  if not is_allowed_for_plan(plan_type, data.sectionKey):
1567
  raise HTTPException(status_code=403, detail=f"Section '{data.sectionKey}' not allowed for planType='{plan_type}'")
1568
 
 
1584
  # persist success
1585
  write_section_success(plan_id, data.sectionKey, plan_text)
1586
 
1587
+ # check completion for this plan after single-section regen
1588
+ try:
1589
+ check_and_update_plan_completion(plan_id, plan_type)
1590
+ except Exception:
1591
+ pass
1592
+
1593
  resp = {
1594
  "saved": True,
1595
  "businessPlanId": plan_id,
 
1598
  "currentSection": data.sectionKey,
1599
  "nextSectionIndex": None,
1600
  }
1601
+ _idem_store(idem_key, resp)
1602
  return resp
1603
 
1604
  except QueueOverloaded:
 
1649
  "businessPlanId": plan_id,
1650
  "saved": True
1651
  }
1652
+ _idem_store(idem_key, resp)
1653
  return resp
1654
 
1655
  except QueueOverloaded:
 
1690
  "concurrency_limit": GEN_CONCURRENCY_LIMIT,
1691
  }
1692
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1693
  # Check OpenAI/GPT model
1694
  if openai_api_key:
1695
  try:
1696
+ status["models"]["GPT"] = "available"
1697
  except Exception as e:
1698
  status["models"]["GPT"] = f"error: {str(e)}"
1699
  else: