GitHub Actions commited on
Commit
e561008
·
1 Parent(s): 4c1c07f

Sync from GitHub · 2026-06-25 17:24 UTC

Browse files
.env.example ADDED
@@ -0,0 +1,12 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # VenThrive FastAPI Backend — Environment Variables
2
+ # Copy to .env and fill in values (same source as frontend .env.local)
3
+
4
+ GROQ_API_KEY=
5
+ FIREBASE_SERVICE_ACCOUNT= # JSON string from Firebase Console → Project Settings → Service Accounts
6
+
7
+ # CORS: comma-separated list of allowed origins
8
+ ALLOWED_ORIGINS=http://localhost:3000,https://venthrive.com
9
+
10
+ # Optional: Pinecone (used in Stage 6 when AI Chat is built)
11
+ PINECONE_API_KEY=
12
+ PINECONE_INDEX_NAME=venthrive-bff
.gitattributes DELETED
@@ -1,35 +0,0 @@
1
- *.7z filter=lfs diff=lfs merge=lfs -text
2
- *.arrow filter=lfs diff=lfs merge=lfs -text
3
- *.bin filter=lfs diff=lfs merge=lfs -text
4
- *.bz2 filter=lfs diff=lfs merge=lfs -text
5
- *.ckpt filter=lfs diff=lfs merge=lfs -text
6
- *.ftz filter=lfs diff=lfs merge=lfs -text
7
- *.gz filter=lfs diff=lfs merge=lfs -text
8
- *.h5 filter=lfs diff=lfs merge=lfs -text
9
- *.joblib filter=lfs diff=lfs merge=lfs -text
10
- *.lfs.* filter=lfs diff=lfs merge=lfs -text
11
- *.mlmodel filter=lfs diff=lfs merge=lfs -text
12
- *.model filter=lfs diff=lfs merge=lfs -text
13
- *.msgpack filter=lfs diff=lfs merge=lfs -text
14
- *.npy filter=lfs diff=lfs merge=lfs -text
15
- *.npz filter=lfs diff=lfs merge=lfs -text
16
- *.onnx filter=lfs diff=lfs merge=lfs -text
17
- *.ot filter=lfs diff=lfs merge=lfs -text
18
- *.parquet filter=lfs diff=lfs merge=lfs -text
19
- *.pb filter=lfs diff=lfs merge=lfs -text
20
- *.pickle filter=lfs diff=lfs merge=lfs -text
21
- *.pkl filter=lfs diff=lfs merge=lfs -text
22
- *.pt filter=lfs diff=lfs merge=lfs -text
23
- *.pth filter=lfs diff=lfs merge=lfs -text
24
- *.rar filter=lfs diff=lfs merge=lfs -text
25
- *.safetensors filter=lfs diff=lfs merge=lfs -text
26
- saved_model/**/* filter=lfs diff=lfs merge=lfs -text
27
- *.tar.* filter=lfs diff=lfs merge=lfs -text
28
- *.tar filter=lfs diff=lfs merge=lfs -text
29
- *.tflite filter=lfs diff=lfs merge=lfs -text
30
- *.tgz filter=lfs diff=lfs merge=lfs -text
31
- *.wasm filter=lfs diff=lfs merge=lfs -text
32
- *.xz filter=lfs diff=lfs merge=lfs -text
33
- *.zip filter=lfs diff=lfs merge=lfs -text
34
- *.zst filter=lfs diff=lfs merge=lfs -text
35
- *tfevents* filter=lfs diff=lfs merge=lfs -text
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
Dockerfile ADDED
@@ -0,0 +1,17 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ FROM python:3.11-slim
2
+
3
+ WORKDIR /app
4
+
5
+ # Install dependencies first (cached layer)
6
+ COPY requirements.txt .
7
+ RUN pip install --no-cache-dir -r requirements.txt
8
+
9
+ # Copy source
10
+ COPY . .
11
+
12
+ # HF Spaces routes traffic to port 7860.
13
+ # Cloud Run uses $PORT (default 8080). Both work via the PORT env var.
14
+ ENV PORT=7860
15
+ EXPOSE 7860
16
+
17
+ CMD ["sh", "-c", "uvicorn main:app --host 0.0.0.0 --port ${PORT}"]
README.md CHANGED
@@ -1,10 +1,16 @@
1
  ---
2
- title: Venthrive Api
3
- emoji: 🌖
4
- colorFrom: green
5
  colorTo: yellow
6
  sdk: docker
 
7
  pinned: false
 
8
  ---
9
 
10
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
 
 
1
  ---
2
+ title: VenThrive API
3
+ emoji: 🟠
4
+ colorFrom: red
5
  colorTo: yellow
6
  sdk: docker
7
+ app_port: 7860
8
  pinned: false
9
+ short_description: FastAPI backend for VenThrive business platform
10
  ---
11
 
12
+ # VenThrive API
13
+
14
+ FastAPI backend for the VenThrive AI-powered business operating system.
15
+
16
+ All endpoints require a valid Firebase ID token (`Authorization: Bearer <token>`).
auth.py ADDED
@@ -0,0 +1,31 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from fastapi import Header, HTTPException, Depends
2
+ from firebase_admin import auth as firebase_auth
3
+
4
+
5
+ async def verify_firebase_token(authorization: str = Header(...)) -> dict:
6
+ """
7
+ FastAPI dependency that verifies a Firebase ID token from the
8
+ Authorization: Bearer <token> header.
9
+
10
+ Returns the decoded token payload (contains uid, email, etc.).
11
+ Raises 401 if the token is missing, malformed, or expired.
12
+
13
+ All protected routes must declare: user = Depends(verify_firebase_token)
14
+ """
15
+ if not authorization.startswith("Bearer "):
16
+ raise HTTPException(
17
+ status_code=401,
18
+ detail="Authorization header must be 'Bearer <token>'",
19
+ )
20
+
21
+ id_token = authorization.removeprefix("Bearer ").strip()
22
+
23
+ try:
24
+ decoded = firebase_auth.verify_id_token(id_token)
25
+ return decoded
26
+ except firebase_auth.ExpiredIdTokenError:
27
+ raise HTTPException(status_code=401, detail="Token has expired. Please sign in again.")
28
+ except firebase_auth.InvalidIdTokenError:
29
+ raise HTTPException(status_code=401, detail="Invalid token.")
30
+ except Exception:
31
+ raise HTTPException(status_code=401, detail="Authentication failed.")
main.py ADDED
@@ -0,0 +1,108 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+ import json
3
+ from contextlib import asynccontextmanager
4
+
5
+ # ─── Load environment FIRST — before any local imports that read env vars ─────
6
+ # Routes instantiate Groq/Firebase clients at module level, so env vars must
7
+ # be present before those modules are imported.
8
+ from dotenv import load_dotenv
9
+ load_dotenv()
10
+
11
+ from fastapi import FastAPI, Request
12
+ from fastapi.middleware.cors import CORSMiddleware
13
+ from fastapi.responses import JSONResponse
14
+ from slowapi import Limiter, _rate_limit_exceeded_handler
15
+ from slowapi.util import get_remote_address
16
+ from slowapi.errors import RateLimitExceeded
17
+ import firebase_admin
18
+ from firebase_admin import credentials
19
+
20
+ from routes import bff, chat, transcribe, documents, consultation, planning, admin
21
+
22
+
23
+ # ─── Firebase Admin SDK initialisation ───────────────────────────────────────
24
+ def _init_firebase():
25
+ if firebase_admin._apps:
26
+ return # already initialised (e.g. during hot-reload)
27
+
28
+ sa_json = os.environ.get("FIREBASE_SERVICE_ACCOUNT")
29
+ if sa_json:
30
+ try:
31
+ cred_dict = json.loads(sa_json)
32
+ cred = credentials.Certificate(cred_dict)
33
+ except json.JSONDecodeError as e:
34
+ raise RuntimeError(f"FIREBASE_SERVICE_ACCOUNT is not valid JSON: {e}")
35
+ else:
36
+ # Fall back to application default credentials (GCP environment)
37
+ cred = credentials.ApplicationDefault()
38
+
39
+ firebase_admin.initialize_app(cred)
40
+
41
+
42
+ _init_firebase()
43
+
44
+
45
+ # ─── Rate limiter ─────────────────────────────────────────────────────────────
46
+ limiter = Limiter(key_func=get_remote_address, default_limits=["60/minute"])
47
+
48
+
49
+ # ─── App lifecycle ────────────────────────────────────────────────────────────
50
+ @asynccontextmanager
51
+ async def lifespan(app: FastAPI):
52
+ print("VenThrive API starting up...")
53
+ yield
54
+ print("VenThrive API shutting down.")
55
+
56
+
57
+ # ─── FastAPI app ──────────────────────────────────────────────────────────────
58
+ app = FastAPI(
59
+ title="VenThrive API",
60
+ version="1.0.0",
61
+ description="AI backend for VenThrive — Nigerian business operating system",
62
+ # Disable auto-generated docs in production
63
+ docs_url="/docs" if os.environ.get("ENV") != "production" else None,
64
+ redoc_url=None,
65
+ lifespan=lifespan,
66
+ )
67
+
68
+ app.state.limiter = limiter
69
+ app.add_exception_handler(RateLimitExceeded, _rate_limit_exceeded_handler)
70
+
71
+
72
+ # ─── CORS ─────────────────────────────────────────────────────────────────────
73
+ allowed_origins_str = os.environ.get("ALLOWED_ORIGINS", "http://localhost:3000")
74
+ allowed_origins = [o.strip() for o in allowed_origins_str.split(",")]
75
+
76
+ app.add_middleware(
77
+ CORSMiddleware,
78
+ allow_origins=allowed_origins,
79
+ allow_credentials=True,
80
+ allow_methods=["GET", "POST"],
81
+ allow_headers=["Authorization", "Content-Type"],
82
+ )
83
+
84
+
85
+ # ─── Global error handler ─────────────────────────────────────────────────────
86
+ @app.exception_handler(Exception)
87
+ async def global_exception_handler(request: Request, exc: Exception):
88
+ # Never expose internal error details to the client in production
89
+ return JSONResponse(
90
+ status_code=500,
91
+ content={"detail": "An unexpected error occurred. Please try again."},
92
+ )
93
+
94
+
95
+ # ─── Routes ───────────────────────────────────────────────────────────────────
96
+ app.include_router(bff.router, prefix="/api/bff", tags=["BFF"])
97
+ app.include_router(chat.router, prefix="/api/chat", tags=["Chat"])
98
+ app.include_router(transcribe.router, prefix="/api", tags=["Transcribe"])
99
+ app.include_router(documents.router, prefix="/api/documents", tags=["Documents"])
100
+ app.include_router(consultation.router, prefix="/api/consultation", tags=["Consultation"])
101
+ app.include_router(planning.router, prefix="/api/planning", tags=["Planning"])
102
+ app.include_router(admin.router, prefix="/api/admin", tags=["Admin"])
103
+
104
+
105
+ # ─── Health check (unauthenticated) ──────────────────────────────────────────
106
+ @app.get("/health", tags=["System"])
107
+ def health_check():
108
+ return {"status": "ok", "service": "venthrive-api"}
requirements.txt ADDED
@@ -0,0 +1,13 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ fastapi==0.115.0
2
+ uvicorn[standard]==0.30.6
3
+ python-dotenv==1.0.1
4
+ firebase-admin==6.5.0
5
+ groq==0.11.0
6
+ slowapi==0.1.9
7
+ python-multipart==0.0.12
8
+ pydantic==2.9.2
9
+ httpx==0.27.2
10
+ # Pinecone vector database (v9+ includes built-in Inference API — no local ML needed)
11
+ pinecone>=9.0.0
12
+ # PDF text extraction for book ingestion script (pure Python, Windows-compatible)
13
+ pypdf>=4.0.0
routes/__init__.py ADDED
File without changes
routes/admin.py ADDED
@@ -0,0 +1,136 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Admin Routes — Protected by admin role verification
3
+ ──────────────────────────────────────────────────────────────────────────────
4
+ All admin actions use Firebase Admin SDK (bypasses Firestore rules).
5
+ The requesting user must have role: 'admin' in their Firestore document.
6
+
7
+ Routes:
8
+ GET /api/admin/users → list all users (paginated)
9
+ PUT /api/admin/users/{uid} → update locked fields (fullName, phone, role)
10
+ DELETE /api/admin/users/{uid} → delete user account + Firestore data
11
+ GET /api/admin/stats → platform overview stats
12
+ """
13
+
14
+ from fastapi import APIRouter, HTTPException, Depends
15
+ from pydantic import BaseModel
16
+ from firebase_admin import auth as fb_auth, firestore
17
+ from auth import verify_firebase_token
18
+
19
+ router = APIRouter()
20
+
21
+
22
+ def _verify_admin(user: dict) -> str:
23
+ """Verifies the requesting user has role: 'admin'. Returns their UID."""
24
+ uid = user.get("uid")
25
+ if not uid:
26
+ raise HTTPException(status_code=401, detail="Unauthorized.")
27
+ db = firestore.client()
28
+ doc = db.collection("user_data").document(uid).get()
29
+ role = doc.to_dict().get("role", "user") if doc.exists else "user"
30
+ if role != "admin":
31
+ raise HTTPException(status_code=403, detail="Admin access required.")
32
+ return uid
33
+
34
+
35
+ class UpdateUserRequest(BaseModel):
36
+ fullName: str | None = None
37
+ phone: str | None = None
38
+ role: str | None = None # only 'user' or 'admin'
39
+
40
+
41
+ @router.get("/users")
42
+ def list_users(
43
+ limit: int = 50,
44
+ user: dict = Depends(verify_firebase_token),
45
+ ):
46
+ _verify_admin(user)
47
+ db = firestore.client()
48
+ docs = db.collection("user_data").limit(limit).stream()
49
+ users = []
50
+ for doc in docs:
51
+ d = doc.to_dict()
52
+ users.append({
53
+ "uid": doc.id,
54
+ "fullName": d.get("fullName", ""),
55
+ "email": d.get("email", ""),
56
+ "phone": d.get("phone", ""),
57
+ "role": d.get("role", "user"),
58
+ "xpTotal": d.get("xpTotal", 0),
59
+ "createdAt": str(d.get("createdAt", "")),
60
+ })
61
+ return {"users": users, "count": len(users)}
62
+
63
+
64
+ @router.put("/users/{target_uid}")
65
+ def update_user(
66
+ target_uid: str,
67
+ body: UpdateUserRequest,
68
+ user: dict = Depends(verify_firebase_token),
69
+ ):
70
+ _verify_admin(user)
71
+
72
+ if body.role and body.role not in ("user", "admin"):
73
+ raise HTTPException(status_code=400, detail="role must be 'user' or 'admin'")
74
+
75
+ db = firestore.client()
76
+ updates = {}
77
+ if body.fullName is not None: updates["fullName"] = body.fullName
78
+ if body.phone is not None: updates["phone"] = body.phone
79
+ if body.role is not None: updates["role"] = body.role
80
+
81
+ if not updates:
82
+ raise HTTPException(status_code=400, detail="Nothing to update.")
83
+
84
+ db.collection("user_data").document(target_uid).update(updates)
85
+
86
+ # Sync display name to Firebase Auth if fullName changed
87
+ if body.fullName:
88
+ try:
89
+ fb_auth.update_user(target_uid, display_name=body.fullName)
90
+ except Exception:
91
+ pass # non-critical
92
+
93
+ return {"success": True, "updated": list(updates.keys())}
94
+
95
+
96
+ @router.delete("/users/{target_uid}")
97
+ def delete_user(
98
+ target_uid: str,
99
+ user: dict = Depends(verify_firebase_token),
100
+ ):
101
+ admin_uid = _verify_admin(user)
102
+ if target_uid == admin_uid:
103
+ raise HTTPException(status_code=400, detail="Admins cannot delete their own account.")
104
+
105
+ db = firestore.client()
106
+
107
+ # Delete Firestore user data (businesses subcollection + root doc)
108
+ user_ref = db.collection("user_data").document(target_uid)
109
+ biz_docs = user_ref.collection("businesses").stream()
110
+ for biz in biz_docs:
111
+ # Delete nested subcollections of each business
112
+ for sub in ["bff", "profile_output", "documents", "kanban", "milestones", "sessions", "team"]:
113
+ for sdoc in biz.reference.collection(sub).stream():
114
+ sdoc.reference.delete()
115
+ biz.reference.delete()
116
+ user_ref.delete()
117
+
118
+ # Delete Firebase Auth account
119
+ try:
120
+ fb_auth.delete_user(target_uid)
121
+ except fb_auth.UserNotFoundError:
122
+ pass # already deleted
123
+
124
+ return {"success": True, "deleted": target_uid}
125
+
126
+
127
+ @router.get("/stats")
128
+ def platform_stats(user: dict = Depends(verify_firebase_token)):
129
+ _verify_admin(user)
130
+ db = firestore.client()
131
+ user_count = len(list(db.collection("user_data").stream()))
132
+ # Count businesses across all users (approximate - stream limit applies)
133
+ return {
134
+ "totalUsers": user_count,
135
+ "note": "Deep counts require Cloud Functions — this shows top-level totals only."
136
+ }
routes/bff.py ADDED
@@ -0,0 +1,323 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import json
2
+ from fastapi import APIRouter, HTTPException, Depends
3
+ from pydantic import BaseModel
4
+ from firebase_admin import firestore
5
+ from auth import verify_firebase_token
6
+ from utils.groq_client import call_groq_json, HEAVY_MODEL
7
+ from utils.pinecone_client import query_book_context
8
+
9
+ router = APIRouter()
10
+
11
+
12
+ # ─── Request schemas ──────────────────────────────────────────────────────────
13
+
14
+ class GenerateFollowupsRequest(BaseModel):
15
+ businessId: str
16
+ section: int # 1, 2, or 3
17
+ sectionData: dict
18
+
19
+
20
+ class GenerateProfileRequest(BaseModel):
21
+ userId: str
22
+ businessId: str
23
+
24
+
25
+ # ─── Helpers ──────────────────────────────────────────────────────────────────
26
+
27
+ def _db():
28
+ return firestore.client()
29
+
30
+
31
+ def _get_bff_data(user_id: str, business_id: str) -> dict:
32
+ """Reads all BFF sections from Firestore for a given business."""
33
+ db = _db()
34
+ biz_ref = (
35
+ db.collection("user_data")
36
+ .document(user_id)
37
+ .collection("businesses")
38
+ .document(business_id)
39
+ )
40
+ sections = {}
41
+ for section_id in [
42
+ "section1", "section1_followups",
43
+ "section2", "section2_followups",
44
+ "section3", "section3_followups",
45
+ ]:
46
+ doc = biz_ref.collection("bff").document(section_id).get()
47
+ if doc.exists:
48
+ sections[section_id] = doc.to_dict()
49
+ return sections
50
+
51
+
52
+ def _call_groq_json(system_prompt: str, user_prompt: str) -> dict:
53
+ """Thin wrapper — delegates to the key-rotating Groq client utility."""
54
+ return call_groq_json(system_prompt, user_prompt, model=HEAVY_MODEL, max_tokens=1500)
55
+
56
+
57
+ # ─── Route: Generate follow-up questions ─────────────────────────────────────
58
+
59
+ @router.post("/generate-followups")
60
+ def generate_followups(
61
+ body: GenerateFollowupsRequest,
62
+ user: dict = Depends(verify_firebase_token),
63
+ ):
64
+ """
65
+ After each BFF section is submitted, generates 3–5 personalized
66
+ follow-up questions specific to the user's sector and answers.
67
+ Questions use plain English — no business jargon.
68
+ """
69
+ if body.section not in (1, 2, 3):
70
+ raise HTTPException(status_code=400, detail="section must be 1, 2, or 3.")
71
+
72
+ section_names = {1: "WHAT (identity)", 2: "WHY (purpose)", 3: "HOW (operations)"}
73
+
74
+ # Fetch relevant BFF philosophy passages from "Built on Rock"
75
+ bff_query = f"Business Footing Framework section {body.section} {json.dumps(body.sectionData)[:200]}"
76
+ book_ctx = query_book_context(bff_query, top_k=2)
77
+
78
+ system_prompt = (
79
+ "You are a business consultant helping a Nigerian entrepreneur understand their business better. "
80
+ "Generate follow-up questions based on what they have already shared. "
81
+ "Questions MUST use simple, everyday English — no business jargon. "
82
+ "Think: how would a friendly, experienced mentor ask? "
83
+ "Return ONLY valid JSON."
84
+ + (f"\n\n{book_ctx}" if book_ctx else "")
85
+ )
86
+
87
+ user_prompt = f"""
88
+ The entrepreneur just completed Section {body.section} ({section_names[body.section]}) of their Business Footing Framework.
89
+
90
+ Their answers:
91
+ {json.dumps(body.sectionData, indent=2)}
92
+
93
+ Generate 3-5 follow-up questions to deepen understanding of their business.
94
+ Make each question specific to what they shared — do not ask generic questions.
95
+
96
+ Return JSON in this exact format:
97
+ {{
98
+ "questions": [
99
+ {{
100
+ "id": "q1",
101
+ "question": "...",
102
+ "type": "text",
103
+ "placeholder": "..."
104
+ }}
105
+ ]
106
+ }}
107
+
108
+ Question types:
109
+ - "text" → free-text answer (use for open questions)
110
+ - "select" → single choice, add "options": ["Option A", "Option B", ...]
111
+ - "multiselect" → multiple choice, add "options": [...]
112
+ - "boolean" → yes/no question
113
+ """
114
+
115
+ try:
116
+ data = _call_groq_json(system_prompt, user_prompt)
117
+ return data
118
+ except Exception as e:
119
+ raise HTTPException(status_code=500, detail=f"Follow-up generation failed: {str(e)}")
120
+
121
+
122
+ # ─── Route: Generate full business profile ────────────────────────────────────
123
+
124
+ @router.post("/generate-profile")
125
+ def generate_profile(
126
+ body: GenerateProfileRequest,
127
+ user: dict = Depends(verify_firebase_token),
128
+ ):
129
+ """
130
+ Reads all completed BFF sections from Firestore and generates all 4
131
+ profile outputs in parallel (Business Identity, Customer Profile,
132
+ Market Data, Business Model). Saves results back to Firestore and
133
+ marks bffCompleted = True on the business document.
134
+ """
135
+ # Only the document owner can trigger their own profile generation
136
+ if user.get("uid") != body.userId:
137
+ raise HTTPException(status_code=403, detail="Forbidden.")
138
+
139
+ bff = _get_bff_data(body.userId, body.businessId)
140
+ if not bff:
141
+ raise HTTPException(status_code=404, detail="BFF data not found.")
142
+
143
+ bff_summary = json.dumps(bff, indent=2)
144
+
145
+ # ── Retrieve relevant passages from "Built on Rock" via Pinecone ─────────
146
+ sector = bff.get("section1", {}).get("sectorId", "")
147
+ niche = bff.get("section1", {}).get("nicheId", "")
148
+ book_ctx = query_book_context(
149
+ f"Business Footing Framework WHAT WHY HOW {sector} {niche} Nigerian entrepreneur",
150
+ top_k=3,
151
+ )
152
+
153
+ # ── System prompt used for all 4 calls ───────────────────────────────────
154
+ base_system = (
155
+ "You are an expert business consultant specializing in Nigerian MSMEs, "
156
+ "trained in the Business Footing Framework (BFF) from 'Built on Rock' "
157
+ "by Iyanuoluwa Joseph Akinnusi. "
158
+ "Analyze the BFF data and produce professional, structured profile components. "
159
+ "Write in plain English that ANY entrepreneur can understand. "
160
+ "Ground all analysis in the Nigerian market context. "
161
+ "Return ONLY valid JSON."
162
+ + (f"\n\n{book_ctx}" if book_ctx else "")
163
+ )
164
+
165
+ # ── Prompt builders ───────────────────────────────────────────────────────
166
+ def prompt_identity():
167
+ return f"""
168
+ BFF Data:
169
+ {bff_summary}
170
+
171
+ Generate the Business Identity profile. Return JSON:
172
+ {{
173
+ "businessName": "string",
174
+ "sector": "string",
175
+ "subsector": "string",
176
+ "niche": "string",
177
+ "summary": "2-3 sentence plain-English description of what this business does",
178
+ "valueProposition": "one clear sentence: what the business promises customers",
179
+ "brandVoice": "2-3 adjectives describing how the brand should communicate (e.g. warm, direct, trustworthy)",
180
+ "tagline": "a short, memorable tagline for this business"
181
+ }}"""
182
+
183
+ def prompt_customer():
184
+ return f"""
185
+ BFF Data:
186
+ {bff_summary}
187
+
188
+ Generate the Customer Profile. Return JSON:
189
+ {{
190
+ "demographics": {{
191
+ "ageRange": "primary age range",
192
+ "gender": "primary gender",
193
+ "location": "primary location(s)",
194
+ "incomeLevel": "low / middle / high / mixed",
195
+ "educationLevel": "brief description",
196
+ "employmentStatus": "brief description"
197
+ }},
198
+ "psychographics": {{
199
+ "values": ["list of 3-5 values the customer likely holds"],
200
+ "lifestyle": "brief lifestyle description",
201
+ "painPoints": ["3-5 main pain points this business solves for them"],
202
+ "motivations": ["3-5 reasons they would buy from this business"]
203
+ }},
204
+ "buyerPersona": "A 3-4 sentence narrative describing the typical customer as a named person (e.g. Meet Amaka...). Make it specific and relatable for Nigerian context."
205
+ }}"""
206
+
207
+ def prompt_market():
208
+ return f"""
209
+ BFF Data:
210
+ {bff_summary}
211
+
212
+ Generate the Market Data analysis. Return JSON:
213
+ {{
214
+ "tam": "Total Addressable Market — brief estimate for Nigeria",
215
+ "sam": "Serviceable Addressable Market — realistic target market segment",
216
+ "som": "Serviceable Obtainable Market — what this business can realistically capture",
217
+ "swot": {{
218
+ "strengths": ["3-4 key strengths based on BFF data"],
219
+ "weaknesses": ["3-4 potential weaknesses to address"],
220
+ "opportunities": ["3-4 market opportunities in Nigeria right now"],
221
+ "threats": ["3-4 threats or risks to watch out for"]
222
+ }},
223
+ "pestle": {{
224
+ "political": "brief note on political factors relevant to this sector in Nigeria",
225
+ "economic": "brief note on economic factors",
226
+ "social": "brief note on social/cultural factors",
227
+ "technological": "brief note on tech trends",
228
+ "legal": "brief note on legal/regulatory considerations",
229
+ "environmental": "brief note if applicable"
230
+ }},
231
+ "competitiveLandscape": "2-3 sentences describing who else competes in this space in Nigeria and how this business can differentiate"
232
+ }}"""
233
+
234
+ def prompt_bmc():
235
+ return f"""
236
+ BFF Data:
237
+ {bff_summary}
238
+
239
+ Generate the Business Model Canvas. Return JSON with exactly these 9 keys,
240
+ each containing a list of 2-4 concise bullet points:
241
+ {{
242
+ "keyPartners": [...],
243
+ "keyActivities": [...],
244
+ "keyResources": [...],
245
+ "valuePropositions": [...],
246
+ "customerRelationships": [...],
247
+ "channels": [...],
248
+ "customerSegments": [...],
249
+ "costStructure": [...],
250
+ "revenueStreams": [...]
251
+ }}"""
252
+
253
+ # ── Build the call plan — skip outputs already in Firestore ─────────────
254
+ # On retry after a partial failure, this avoids regenerating completed
255
+ # outputs and wasting tokens on the Groq free-tier TPM limit.
256
+ import time
257
+ db_check = _db()
258
+ biz_ref_check = (
259
+ db_check.collection("user_data")
260
+ .document(body.userId)
261
+ .collection("businesses")
262
+ .document(body.businessId)
263
+ )
264
+
265
+ all_outputs = {
266
+ "businessIdentity": prompt_identity,
267
+ "customerProfile": prompt_customer,
268
+ "marketData": prompt_market,
269
+ "businessModel": prompt_bmc,
270
+ }
271
+
272
+ call_plan = []
273
+ for key, prompt_fn in all_outputs.items():
274
+ existing = biz_ref_check.collection("profile_output").document(key).get()
275
+ if existing.exists:
276
+ print(f"[bff] Skipping {key} — already exists in Firestore")
277
+ else:
278
+ call_plan.append((key, prompt_fn))
279
+
280
+ results: dict = {}
281
+ errors: list = []
282
+
283
+ # Sequential calls with 15s gap to stay within 12,000 TPM free-tier limit.
284
+ # With 1,500 max_tokens per call: ~2,500 tokens/call × 4 = ~10,000 total.
285
+ # 15s gap × 200 tokens/sec refill = 3,000 tokens recovered between calls.
286
+ for i, (key, prompt_fn) in enumerate(call_plan):
287
+ if i > 0:
288
+ time.sleep(15) # 15s gap: ~3,000 tokens recovered at 200 tok/s
289
+ try:
290
+ results[key] = _call_groq_json(base_system, prompt_fn())
291
+ except Exception as e:
292
+ errors.append(f"{key}: {str(e)}")
293
+ print(f"[bff] Failed to generate {key}: {e}")
294
+
295
+ # Fail only if we had work to do and ALL of it failed
296
+ if call_plan and len(errors) == len(call_plan):
297
+ raise HTTPException(
298
+ status_code=500,
299
+ detail=f"Profile generation failed: {'; '.join(errors)}",
300
+ )
301
+
302
+ # ── Save to Firestore ─────────────────────────────────────────────────────
303
+ db = _db()
304
+ biz_ref = (
305
+ db.collection("user_data")
306
+ .document(body.userId)
307
+ .collection("businesses")
308
+ .document(body.businessId)
309
+ )
310
+
311
+ from google.cloud.firestore_v1 import SERVER_TIMESTAMP
312
+ for key, data in results.items():
313
+ biz_ref.collection("profile_output").document(key).set(
314
+ {**data, "generatedAt": SERVER_TIMESTAMP}
315
+ )
316
+
317
+ # Mark BFF as complete
318
+ biz_ref.update({"bffCompleted": True})
319
+
320
+ return {
321
+ "success": True,
322
+ "outputs": list(results.keys()),
323
+ }
routes/chat.py ADDED
@@ -0,0 +1,127 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import json
2
+ from fastapi import APIRouter, HTTPException, Depends
3
+ from fastapi.responses import StreamingResponse
4
+ from pydantic import BaseModel
5
+ from firebase_admin import firestore
6
+ from auth import verify_firebase_token
7
+ from utils.groq_client import call_groq_stream, LIGHT_MODEL, HEAVY_MODEL
8
+ from utils.pinecone_client import query_book_context
9
+
10
+ router = APIRouter()
11
+
12
+
13
+ # ─── Schemas ──────────────────────────────────────────────────────────────────
14
+
15
+ class Message(BaseModel):
16
+ role: str # "user" or "assistant"
17
+ content: str
18
+
19
+
20
+ class ChatRequest(BaseModel):
21
+ businessId: str
22
+ sessionId: str
23
+ message: str
24
+ history: list[Message] = []
25
+ useHeavyModel: bool = False # True for AI Consultation sessions
26
+
27
+
28
+ # ─── Helpers ──────────────────────────────────────────────────────────────────
29
+
30
+ def _get_business_context(user_id: str, business_id: str) -> str:
31
+ """Builds a system context string from BFF + profile_output in Firestore."""
32
+ db = firestore.client()
33
+ biz_ref = (
34
+ db.collection("user_data")
35
+ .document(user_id)
36
+ .collection("businesses")
37
+ .document(business_id)
38
+ )
39
+
40
+ # Fetch BFF section 1 (WHAT) for quick context
41
+ s1 = biz_ref.collection("bff").document("section1").get()
42
+ # Fetch business identity if generated
43
+ identity = biz_ref.collection("profile_output").document("businessIdentity").get()
44
+
45
+ ctx_parts = []
46
+
47
+ if s1.exists:
48
+ d = s1.to_dict()
49
+ ctx_parts.append(
50
+ f"Business: {d.get('businessName', 'Unknown')}, "
51
+ f"Sector: {d.get('sector', '')}, "
52
+ f"Niche: {d.get('niche', '')}"
53
+ )
54
+
55
+ if identity.exists:
56
+ d = identity.to_dict()
57
+ ctx_parts.append(f"Summary: {d.get('summary', '')}")
58
+ ctx_parts.append(f"Value Proposition: {d.get('valueProposition', '')}")
59
+
60
+ return "\n".join(ctx_parts) if ctx_parts else "Business profile not yet generated."
61
+
62
+
63
+ # ─── Route: Chat message (streaming SSE) ─────────────────────────────────────
64
+
65
+ @router.post("/message")
66
+ def chat_message(
67
+ body: ChatRequest,
68
+ user: dict = Depends(verify_firebase_token),
69
+ ):
70
+ """
71
+ Accepts a user message and returns a streaming SSE response.
72
+ For passive AI Chat: uses light model (llama-3.1-8b-instant).
73
+ For AI Consultation: uses heavy model (llama-3.3-70b-versatile).
74
+
75
+ Stream format (SSE):
76
+ data: {"type": "thinking", "token": "..."} ← reasoning (heavy model only)
77
+ data: {"type": "response", "token": "..."} ← main response
78
+ data: {"type": "done"} ← stream end
79
+ """
80
+ user_id = user.get("uid")
81
+ model = HEAVY_MODEL if body.useHeavyModel else LIGHT_MODEL
82
+
83
+ business_context = _get_business_context(user_id, body.businessId)
84
+
85
+ # Retrieve relevant passages from "Built on Rock" based on user's message
86
+ book_ctx = query_book_context(body.message, top_k=2)
87
+
88
+ system_prompt = f"""You are the VenThrive Business Assistant — a business consultant trained in the Business Footing Framework (BFF) from "Built on Rock" by Iyanuoluwa Joseph Akinnusi. You help Nigerian entrepreneurs build successful businesses using the WHAT-WHY-HOW philosophy.
89
+
90
+ BUSINESS CONTEXT:
91
+ {business_context}
92
+ {f"{chr(10)}RELEVANT BFF GUIDANCE FROM 'BUILT ON ROCK':{chr(10)}{book_ctx}" if book_ctx else ""}
93
+
94
+ RULES:
95
+ 1. STRICTLY BUSINESS: Stay focused on the entrepreneur's business. If asked personal questions, redirect warmly.
96
+ 2. NIGERIAN CONTEXT: Reference real Nigerian examples — banks (GTBank, First Bank, Moniepoint), platforms (Jumia, Konga, Flutterwave), markets (Balogun, Alaba), cities and states.
97
+ 3. BFF PHILOSOPHY: When relevant, ground advice in the WHAT-WHY-HOW framework from "Built on Rock".
98
+ 4. PLAIN ENGLISH: No business jargon without explanation. Write like you're talking to a smart friend.
99
+ 5. CONCISE: 5-6 sentences max per response. Use bullet points for lists.
100
+ 5. ACTIONABLE: Every response should tell the entrepreneur what to DO next, not just what things mean.
101
+ """
102
+
103
+ messages = [{"role": "system", "content": system_prompt}]
104
+ for msg in body.history[-10:]: # last 10 messages for context window efficiency
105
+ messages.append({"role": msg.role, "content": msg.content})
106
+ messages.append({"role": "user", "content": body.message})
107
+
108
+ def stream():
109
+ try:
110
+ # call_groq_stream handles key rotation on 429
111
+ stream_response = call_groq_stream(
112
+ system_prompt=system_prompt,
113
+ user_prompt=body.message,
114
+ model=model,
115
+ max_tokens=512,
116
+ )
117
+ for chunk in stream_response:
118
+ token = chunk.choices[0].delta.content
119
+ if token:
120
+ yield f"data: {json.dumps({'type': 'response', 'token': token})}\n\n"
121
+
122
+ yield f"data: {json.dumps({'type': 'done'})}\n\n"
123
+
124
+ except Exception as e:
125
+ yield f"data: {json.dumps({'type': 'error', 'message': 'Something went wrong. Please try again.'})}\n\n"
126
+
127
+ return StreamingResponse(stream(), media_type="text/event-stream")
routes/consultation.py ADDED
@@ -0,0 +1,186 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ AI Consultation Session Routes
3
+ ──────────────────────────────────────────────────────────────────────────────
4
+ Manages proactive AI Consultation sessions distinct from passive AI Chat.
5
+
6
+ AI Chat: passive — waits for user questions
7
+ Consultation: proactive — AI opens with agenda, leads through structured phases
8
+ (Discovery → Analysis → Prescription) and produces a written report
9
+
10
+ Routes:
11
+ POST /api/consultation/start → AI reads profile, generates opening message
12
+ POST /api/consultation/report → AI generates structured report from session
13
+ """
14
+
15
+ import json
16
+ from datetime import datetime
17
+ from fastapi import APIRouter, HTTPException, Depends
18
+ from pydantic import BaseModel
19
+ from firebase_admin import firestore
20
+ from auth import verify_firebase_token
21
+ from utils.groq_client import call_groq_json, HEAVY_MODEL
22
+ from utils.pinecone_client import query_book_context
23
+
24
+ router = APIRouter()
25
+
26
+
27
+ class StartRequest(BaseModel):
28
+ userId: str
29
+ businessId: str
30
+
31
+
32
+ class ReportRequest(BaseModel):
33
+ userId: str
34
+ businessId: str
35
+ messages: list[dict] # [{"role": "user"|"assistant", "content": "..."}]
36
+
37
+
38
+ def _read_profile(user_id: str, business_id: str) -> dict:
39
+ db = firestore.client()
40
+ biz = (
41
+ db.collection("user_data").document(user_id)
42
+ .collection("businesses").document(business_id)
43
+ )
44
+ ctx: dict = {}
45
+ for key in ["businessIdentity", "customerProfile", "marketData"]:
46
+ doc = biz.collection("profile_output").document(key).get()
47
+ if doc.exists:
48
+ ctx[key] = doc.to_dict()
49
+ return ctx
50
+
51
+
52
+ @router.post("/start")
53
+ def start_consultation(
54
+ body: StartRequest,
55
+ user: dict = Depends(verify_firebase_token),
56
+ ):
57
+ """
58
+ Reads the entrepreneur's business profile and generates a warm, structured
59
+ opening message with 5 focus areas. The AI has already 'read the brief'
60
+ so the session starts with context rather than from scratch.
61
+ """
62
+ profile = _read_profile(body.userId, body.businessId)
63
+ identity = profile.get("businessIdentity", {})
64
+
65
+ biz_name = identity.get("businessName", "your business")
66
+ sector = identity.get("sector", "")
67
+ value_prop = identity.get("valueProposition", "")
68
+ summary = identity.get("summary", "")
69
+
70
+ book_ctx = query_book_context(
71
+ "business consultation strategy discovery analysis Nigerian entrepreneur",
72
+ top_k=2,
73
+ )
74
+
75
+ system = (
76
+ "You are a senior business consultant at VenThrive, trained in the "
77
+ "Business Footing Framework from 'Built on Rock' by Iyanuoluwa Joseph Akinnusi. "
78
+ "You are warm, direct, and speak in plain everyday English. "
79
+ "You do NOT use business jargon. You speak like a trusted advisor, not a textbook. "
80
+ "Return ONLY valid JSON."
81
+ + (f"\n\n{book_ctx}" if book_ctx else "")
82
+ )
83
+
84
+ prompt = f"""
85
+ You are starting a consultation session with the founder of "{biz_name}"
86
+ ({sector}). Their value proposition is: "{value_prop}".
87
+ Brief summary: {summary[:300]}
88
+
89
+ Generate a warm, professional opening message that:
90
+ 1. Greets them by referencing their specific business (not generic)
91
+ 2. Mentions you've reviewed their Business Footing Framework profile
92
+ 3. Presents exactly 5 focus areas as a numbered list
93
+ 4. Asks them to pick the one they want to focus on today
94
+
95
+ The 5 focus areas should be:
96
+ 1. Strategy & Market Position
97
+ 2. Customer Acquisition & Retention
98
+ 3. Financial Planning & Pricing
99
+ 4. Operations & Standard Processes
100
+ 5. Investor Readiness & Growth
101
+
102
+ Return JSON:
103
+ {{
104
+ "openingMessage": "<your full opening message here>",
105
+ "focusAreas": ["Strategy & Market Position", "Customer Acquisition & Retention", "Financial Planning & Pricing", "Operations & Standard Processes", "Investor Readiness & Growth"]
106
+ }}"""
107
+
108
+ try:
109
+ result = call_groq_json(system, prompt, model=HEAVY_MODEL, max_tokens=800)
110
+ return {
111
+ "openingMessage": result.get("openingMessage", ""),
112
+ "focusAreas": result.get("focusAreas", []),
113
+ }
114
+ except Exception as e:
115
+ raise HTTPException(status_code=500, detail=f"Could not start consultation: {str(e)}")
116
+
117
+
118
+ @router.post("/report")
119
+ def generate_report(
120
+ body: ReportRequest,
121
+ user: dict = Depends(verify_firebase_token),
122
+ ):
123
+ """
124
+ Takes the full session transcript and generates a structured consultation
125
+ report with findings, specific recommendations, and next steps.
126
+ """
127
+ if user.get("uid") != body.userId:
128
+ raise HTTPException(status_code=403, detail="Forbidden.")
129
+
130
+ if len(body.messages) < 2:
131
+ raise HTTPException(status_code=400, detail="Session too short to generate a report.")
132
+
133
+ transcript = "\n".join([
134
+ f"{'CONSULTANT' if m['role'] == 'assistant' else 'ENTREPRENEUR'}: {m['content']}"
135
+ for m in body.messages
136
+ ])
137
+
138
+ book_ctx = query_book_context(
139
+ "consultation findings recommendations action plan Nigerian business",
140
+ top_k=2,
141
+ )
142
+
143
+ system = (
144
+ "You are a business consultant writing a formal post-consultation report. "
145
+ "Analyze the conversation and produce a structured, actionable document. "
146
+ "Be specific to what was actually discussed — no generic advice. "
147
+ "Write in clear, plain English. "
148
+ "Return ONLY valid JSON."
149
+ + (f"\n\n{book_ctx}" if book_ctx else "")
150
+ )
151
+
152
+ prompt = f"""
153
+ Consultation transcript:
154
+ {transcript[:4000]}
155
+
156
+ Today's date: {datetime.now().strftime('%B %d, %Y')}
157
+
158
+ Write a formal consultation report. Return JSON:
159
+ {{
160
+ "date": "{datetime.now().strftime('%B %d, %Y')}",
161
+ "sessionSummary": "2-3 sentences summarising what was discussed and the main concern addressed",
162
+ "keyFindings": [
163
+ "Specific finding 1 from the conversation",
164
+ "Specific finding 2",
165
+ "Specific finding 3"
166
+ ],
167
+ "recommendations": [
168
+ {{
169
+ "action": "What to do",
170
+ "rationale": "Why this matters for their specific business",
171
+ "timeline": "When to do it (e.g. This week / Within 30 days)"
172
+ }}
173
+ ],
174
+ "nextSteps": [
175
+ "Concrete action step 1",
176
+ "Concrete action step 2",
177
+ "Concrete action step 3"
178
+ ],
179
+ "closingNote": "A personalised, encouraging closing sentence referencing their business."
180
+ }}"""
181
+
182
+ try:
183
+ result = call_groq_json(system, prompt, model=HEAVY_MODEL, max_tokens=2000)
184
+ return {"report": result}
185
+ except Exception as e:
186
+ raise HTTPException(status_code=500, detail=f"Report generation failed: {str(e)}")
routes/documents.py ADDED
@@ -0,0 +1,202 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Document Content Generation Route
3
+ ──────────────────────────────────────────────────────────────────────────────
4
+ Generates structured content for Business Plan and Pitch Deck using Groq
5
+ with full key rotation. The generated content is saved to Firestore and
6
+ returned to the frontend for client-side PDF rendering via jsPDF.
7
+
8
+ Profile-based documents (BMC, Identity, Customer, Market) do NOT use this
9
+ endpoint — they generate PDF directly from profile_output in the frontend.
10
+
11
+ This endpoint handles only the documents that require AI narrative generation:
12
+ - business_plan → 9-section written plan
13
+ - pitch_deck → 7-slide pitch outline
14
+ """
15
+
16
+ import json
17
+ from fastapi import APIRouter, HTTPException, Depends
18
+ from pydantic import BaseModel
19
+ from firebase_admin import firestore
20
+ from auth import verify_firebase_token
21
+ from utils.groq_client import call_groq_json, HEAVY_MODEL
22
+ from utils.pinecone_client import query_book_context
23
+
24
+ router = APIRouter()
25
+
26
+
27
+ class GenerateDocRequest(BaseModel):
28
+ userId: str
29
+ businessId: str
30
+ docType: str # "business_plan" | "pitch_deck"
31
+
32
+
33
+ def _get_bff_and_profile(user_id: str, business_id: str) -> dict:
34
+ """Reads BFF sections + profile_output from Firestore into one context dict."""
35
+ db = firestore.client()
36
+ biz = (
37
+ db.collection("user_data")
38
+ .document(user_id)
39
+ .collection("businesses")
40
+ .document(business_id)
41
+ )
42
+
43
+ context: dict = {}
44
+
45
+ # BFF sections
46
+ for section in ["section1", "section2", "section3"]:
47
+ doc = biz.collection("bff").document(section).get()
48
+ if doc.exists:
49
+ context[section] = doc.to_dict()
50
+
51
+ # Profile outputs
52
+ for key in ["businessIdentity", "customerProfile", "marketData", "businessModel"]:
53
+ doc = biz.collection("profile_output").document(key).get()
54
+ if doc.exists:
55
+ context[key] = doc.to_dict()
56
+
57
+ return context
58
+
59
+
60
+ # ─── Business Plan ────────────────────────────────────────────────────────────
61
+ def _generate_business_plan(ctx: dict, book_context: str) -> dict:
62
+ system = (
63
+ "You are a professional business consultant specializing in Nigerian MSMEs. "
64
+ "Write a complete, practical Business Plan for this entrepreneur. "
65
+ "Use plain English — no jargon. Every section must be actionable and specific "
66
+ "to the Nigerian market. Return ONLY valid JSON.\n\n"
67
+ + (book_context or "")
68
+ )
69
+ prompt = f"""
70
+ Business context:
71
+ {json.dumps(ctx, indent=2)[:3000]}
72
+
73
+ Generate a comprehensive Business Plan. Return JSON:
74
+ {{
75
+ "executiveSummary": "2-3 paragraphs summarizing the business, opportunity, and goals",
76
+ "businessOpportunity": "The problem being solved and why now",
77
+ "companyDescription": "What the business does, its mission, and legal structure",
78
+ "targetMarket": "Detailed description of the target market in Nigeria",
79
+ "productsServices": "What is being sold, key features, pricing approach",
80
+ "marketingStrategy": "How the business will reach and retain customers",
81
+ "operationsPlan": "Day-to-day operations, location, equipment, team",
82
+ "financialSummary": "Startup costs, expected revenue, break-even estimate",
83
+ "implementationTimeline": "Key milestones for the first 12 months"
84
+ }}"""
85
+ return call_groq_json(system, prompt, model=HEAVY_MODEL, max_tokens=2500)
86
+
87
+
88
+ # ─── Pitch Deck ────────────────────────────────────────────────────────────────
89
+ def _generate_pitch_deck(ctx: dict, book_context: str) -> dict:
90
+ system = (
91
+ "You are a venture capital pitch consultant. "
92
+ "Create a compelling 7-slide investor pitch deck for this Nigerian entrepreneur. "
93
+ "Be concise — each slide has 3-4 punchy bullet points. "
94
+ "Ground everything in real Nigerian market data where possible. "
95
+ "Return ONLY valid JSON.\n\n"
96
+ + (book_context or "")
97
+ )
98
+ prompt = f"""
99
+ Business context:
100
+ {json.dumps(ctx, indent=2)[:3000]}
101
+
102
+ Generate a 7-slide Pitch Deck. Return JSON where each key is a slide:
103
+ {{
104
+ "slide1_problem": {{
105
+ "title": "The Problem",
106
+ "bullets": ["...", "...", "..."]
107
+ }},
108
+ "slide2_solution": {{
109
+ "title": "Our Solution",
110
+ "bullets": ["...", "...", "..."]
111
+ }},
112
+ "slide3_market": {{
113
+ "title": "Market Opportunity",
114
+ "bullets": ["TAM...", "SAM...", "SOM..."]
115
+ }},
116
+ "slide4_business_model": {{
117
+ "title": "How We Make Money",
118
+ "bullets": ["...", "...", "..."]
119
+ }},
120
+ "slide5_traction": {{
121
+ "title": "Traction & Milestones",
122
+ "bullets": ["...", "...", "..."]
123
+ }},
124
+ "slide6_strategy": {{
125
+ "title": "Go-To-Market Strategy",
126
+ "bullets": ["...", "...", "..."]
127
+ }},
128
+ "slide7_ask": {{
129
+ "title": "The Ask",
130
+ "bullets": ["Seeking: ...", "Use of funds: ...", "Expected outcome: ..."]
131
+ }}
132
+ }}"""
133
+ return call_groq_json(system, prompt, model=HEAVY_MODEL, max_tokens=2000)
134
+
135
+
136
+ # ─── Route ────────────────────────────────────────────────────────────────────
137
+ @router.post("/generate-content")
138
+ def generate_document_content(
139
+ body: GenerateDocRequest,
140
+ user: dict = Depends(verify_firebase_token),
141
+ ):
142
+ """
143
+ Generates AI content for Business Plan or Pitch Deck.
144
+ Saves the result to Firestore and returns the document ID.
145
+ PDF rendering happens client-side in the browser.
146
+ """
147
+ if user.get("uid") != body.userId:
148
+ raise HTTPException(status_code=403, detail="Forbidden.")
149
+
150
+ if body.docType not in ("business_plan", "pitch_deck"):
151
+ raise HTTPException(
152
+ status_code=400,
153
+ detail="docType must be 'business_plan' or 'pitch_deck'",
154
+ )
155
+
156
+ # ── Read context ─────────────────────────────────────────────────────────
157
+ ctx = _get_bff_and_profile(body.userId, body.businessId)
158
+ if not ctx:
159
+ raise HTTPException(status_code=404, detail="Business profile not found.")
160
+
161
+ # ── BFF Philosophy context from Pinecone ──────────────────────────────────
162
+ query = (
163
+ f"business plan entrepreneurship Nigeria "
164
+ f"{ctx.get('businessIdentity', {}).get('sector', '')} "
165
+ f"{ctx.get('section1', {}).get('niche', '')}"
166
+ )
167
+ book_context = query_book_context(query, top_k=3)
168
+
169
+ # ── Generate ──────────────────────────────────────────────────────────────
170
+ try:
171
+ if body.docType == "business_plan":
172
+ content = _generate_business_plan(ctx, book_context)
173
+ doc_name = f"{ctx.get('businessIdentity', {}).get('businessName', 'Business')}_Plan"
174
+ else:
175
+ content = _generate_pitch_deck(ctx, book_context)
176
+ doc_name = f"{ctx.get('businessIdentity', {}).get('businessName', 'Business')}_PitchDeck"
177
+ except Exception as e:
178
+ raise HTTPException(status_code=500, detail=f"Generation failed: {str(e)}")
179
+
180
+ # ── Save to Firestore ─────────────────────────────────────────────────────
181
+ from google.cloud.firestore_v1 import SERVER_TIMESTAMP
182
+ db = firestore.client()
183
+ biz = (
184
+ db.collection("user_data")
185
+ .document(body.userId)
186
+ .collection("businesses")
187
+ .document(body.businessId)
188
+ )
189
+ doc_ref = biz.collection("documents").add({
190
+ "name": doc_name,
191
+ "type": body.docType,
192
+ "dataSnapshot": content,
193
+ "createdAt": SERVER_TIMESTAMP,
194
+ "status": "ready",
195
+ })
196
+ doc_id = doc_ref[1].id
197
+
198
+ return {
199
+ "success": True,
200
+ "docId": doc_id,
201
+ "dataSnapshot": content,
202
+ }
routes/planning.py ADDED
@@ -0,0 +1,139 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Planning AI Suggestions
3
+ ──────────────────────────────────────────────────────────────────────────────
4
+ Reads the business profile and generates actionable task / milestone suggestions
5
+ grounded in the BFF data and "Built on Rock" philosophy.
6
+
7
+ Routes:
8
+ POST /api/planning/suggest → task or milestone suggestions
9
+ """
10
+
11
+ import json
12
+ from datetime import datetime, timedelta
13
+ from fastapi import APIRouter, HTTPException, Depends
14
+ from pydantic import BaseModel
15
+ from firebase_admin import firestore
16
+ from auth import verify_firebase_token
17
+ from utils.groq_client import call_groq_json, HEAVY_MODEL
18
+ from utils.pinecone_client import query_book_context
19
+
20
+ router = APIRouter()
21
+
22
+
23
+ class SuggestRequest(BaseModel):
24
+ userId: str
25
+ businessId: str
26
+ type: str # "tasks" | "milestones"
27
+ existing: list[str] = [] # existing task/milestone titles (to avoid duplicates)
28
+
29
+
30
+ def _read_context(user_id: str, business_id: str) -> dict:
31
+ db = firestore.client()
32
+ biz = (
33
+ db.collection("user_data").document(user_id)
34
+ .collection("businesses").document(business_id)
35
+ )
36
+ ctx: dict = {}
37
+ for key in ["businessIdentity", "marketData", "businessModel"]:
38
+ doc = biz.collection("profile_output").document(key).get()
39
+ if doc.exists:
40
+ ctx[key] = doc.to_dict()
41
+ s1 = biz.collection("bff").document("section1").get()
42
+ if s1.exists:
43
+ ctx["section1"] = s1.to_dict()
44
+ return ctx
45
+
46
+
47
+ @router.post("/suggest")
48
+ def suggest_planning_items(
49
+ body: SuggestRequest,
50
+ user: dict = Depends(verify_firebase_token),
51
+ ):
52
+ if body.type not in ("tasks", "milestones"):
53
+ raise HTTPException(status_code=400, detail="type must be 'tasks' or 'milestones'")
54
+
55
+ ctx = _read_context(body.userId, body.businessId)
56
+ biz_name = ctx.get("businessIdentity", {}).get("businessName", "this business")
57
+ sector = ctx.get("businessIdentity", {}).get("sector", "")
58
+ value_prop = ctx.get("businessIdentity", {}).get("valueProposition", "")
59
+ summary = ctx.get("businessIdentity", {}).get("summary", "")
60
+
61
+ book_ctx = query_book_context(
62
+ f"action plan milestones tasks Nigerian entrepreneur {sector}", top_k=2
63
+ )
64
+
65
+ existing_str = (
66
+ f"\nDo NOT duplicate any of these existing items: {body.existing}"
67
+ if body.existing else ""
68
+ )
69
+
70
+ system = (
71
+ "You are a business consultant helping a Nigerian entrepreneur plan their next steps. "
72
+ "Suggest practical, specific, achievable actions grounded in the Business Footing Framework. "
73
+ "All suggestions must be relevant to their specific business — no generic advice. "
74
+ "Write in plain English. Return ONLY valid JSON."
75
+ + (f"\n\n{book_ctx}" if book_ctx else "")
76
+ )
77
+
78
+ if body.type == "tasks":
79
+ prompt = f"""
80
+ Business: {biz_name} ({sector})
81
+ Value proposition: {value_prop}
82
+ Summary: {summary[:400]}
83
+ {existing_str}
84
+
85
+ Generate exactly 5 actionable Kanban tasks for this business right now.
86
+ Tasks should be specific, achievable within 2 weeks, and move the business forward.
87
+ Spread them across different priorities: some for this week, some for next week.
88
+
89
+ Return JSON:
90
+ {{
91
+ "suggestions": [
92
+ {{
93
+ "title": "specific task title",
94
+ "description": "why this matters for {biz_name}",
95
+ "column": "todo"
96
+ }},
97
+ ...
98
+ ]
99
+ }}
100
+
101
+ column must be one of: "todo", "in_progress", "done"
102
+ Most tasks should be "todo" unless clearly already started.
103
+ """
104
+ else: # milestones
105
+ today = datetime.now()
106
+ dates = [(today + timedelta(days=d)).strftime("%Y-%m-%d") for d in [30, 60, 90, 180, 365]]
107
+ prompt = f"""
108
+ Business: {biz_name} ({sector})
109
+ Value proposition: {value_prop}
110
+ Summary: {summary[:400]}
111
+ {existing_str}
112
+
113
+ Generate exactly 5 business milestones for the next 12 months.
114
+ Milestones should be measurable achievements that mark significant progress.
115
+ Space them across different timeframes.
116
+
117
+ Available target dates to choose from: {dates}
118
+ (Choose the most appropriate date for each milestone)
119
+
120
+ Return JSON:
121
+ {{
122
+ "suggestions": [
123
+ {{
124
+ "title": "milestone title (specific and measurable)",
125
+ "targetDate": "YYYY-MM-DD"
126
+ }},
127
+ ...
128
+ ]
129
+ }}
130
+ """
131
+
132
+ try:
133
+ result = call_groq_json(system, prompt, model=HEAVY_MODEL, max_tokens=800)
134
+ suggestions = result.get("suggestions", [])
135
+ if not suggestions:
136
+ raise ValueError("No suggestions returned")
137
+ return {"suggestions": suggestions}
138
+ except Exception as e:
139
+ raise HTTPException(status_code=500, detail=f"Suggestion generation failed: {str(e)}")
routes/transcribe.py ADDED
@@ -0,0 +1,64 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from fastapi import APIRouter, UploadFile, File, Depends, HTTPException
2
+ from auth import verify_firebase_token
3
+ import os
4
+ from groq import Groq
5
+
6
+ router = APIRouter()
7
+ groq_client = Groq(api_key=os.environ.get("GROQ_API_KEY"))
8
+
9
+ SUPPORTED_AUDIO_TYPES = {
10
+ "audio/webm", "audio/mp4", "audio/mpeg", "audio/wav",
11
+ "audio/ogg", "audio/flac", "audio/x-m4a",
12
+ }
13
+
14
+ MAX_AUDIO_BYTES = 25 * 1024 * 1024 # 25 MB — Groq Whisper limit
15
+
16
+
17
+ @router.post("/transcribe")
18
+ async def transcribe_audio(
19
+ audio: UploadFile = File(...),
20
+ user: dict = Depends(verify_firebase_token),
21
+ ):
22
+ """
23
+ Accepts a browser-recorded audio blob, sends it to Groq Whisper,
24
+ and returns the transcript text.
25
+
26
+ Voice input is NEVER stored — the audio bytes are discarded after
27
+ transcription returns. Only the resulting text is saved by the caller.
28
+ See VUL-019 (no audio persistence).
29
+ """
30
+ # Basic content-type guard — not a hard security boundary since the
31
+ # browser may send 'application/octet-stream'. Real validation happens
32
+ # when Groq rejects unsupported formats.
33
+ content_type = audio.content_type or "audio/webm"
34
+ if content_type not in SUPPORTED_AUDIO_TYPES and content_type != "application/octet-stream":
35
+ raise HTTPException(
36
+ status_code=415,
37
+ detail=f"Unsupported audio format: {content_type}",
38
+ )
39
+
40
+ audio_bytes = await audio.read()
41
+
42
+ if len(audio_bytes) > MAX_AUDIO_BYTES:
43
+ raise HTTPException(
44
+ status_code=413,
45
+ detail="Audio file exceeds 25 MB limit.",
46
+ )
47
+
48
+ if len(audio_bytes) == 0:
49
+ raise HTTPException(status_code=400, detail="Audio file is empty.")
50
+
51
+ try:
52
+ transcription = groq_client.audio.transcriptions.create(
53
+ model="whisper-large-v3-turbo",
54
+ file=(audio.filename or "recording.webm", audio_bytes, content_type),
55
+ response_format="json",
56
+ language="en",
57
+ )
58
+ return {"transcript": transcription.text}
59
+
60
+ except Exception as e:
61
+ raise HTTPException(
62
+ status_code=500,
63
+ detail=f"Transcription failed: {str(e)}",
64
+ )
scripts/__init__.py ADDED
File without changes
scripts/ingest_book.py ADDED
@@ -0,0 +1,154 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ "Built on Rock" — Pinecone Ingestion Script
3
+ ──────────────────────────────────────────────────────────────────────────────
4
+ Run this ONCE after setting up your Pinecone index to populate it with
5
+ content from "Built on Rock" by Iyanuoluwa Joseph Akinnusi.
6
+
7
+ Prerequisites:
8
+ 1. Create a Pinecone index named 'venthrive-bff' with:
9
+ - Dimensions: 1024
10
+ - Metric: Cosine
11
+ - Cloud: AWS us-east-1 (Serverless, free tier)
12
+ 2. Set PINECONE_API_KEY in backend/.env
13
+ 3. Ensure the PDF is in the APEX folder (path configured below)
14
+
15
+ Usage (from the VenThrive/backend/ directory):
16
+ python scripts/ingest_book.py
17
+
18
+ No local ML library needed — embeddings are generated by Pinecone's
19
+ hosted multilingual-e5-large model (1024 dimensions).
20
+ """
21
+
22
+ import os
23
+ import sys
24
+ import time
25
+
26
+ # Load backend env
27
+ sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
28
+ from dotenv import load_dotenv
29
+ load_dotenv(os.path.join(os.path.dirname(os.path.dirname(__file__)), ".env"))
30
+
31
+ from pinecone import Pinecone
32
+ import pypdf
33
+
34
+ # ─── Config ───────────────────────────────────────────────────────────────────
35
+ # Absolute path — the PDF lives in the sibling APEX project folder
36
+ _BASE = os.path.dirname(os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))))
37
+ PDF_PATH = os.path.join(_BASE, "APEX", "Built On Rock by Iyanuoluwa Joseph Akinnusi-1.pdf")
38
+ CHUNK_WORDS = 400
39
+ OVERLAP_WORDS = 50
40
+ BATCH_SIZE = 90 # Pinecone Inference allows up to 96 inputs per request
41
+ EMBED_MODEL = "multilingual-e5-large"
42
+
43
+
44
+ def extract_pdf_text(path: str) -> str:
45
+ if not os.path.exists(path):
46
+ raise FileNotFoundError(
47
+ f"\n[ERR] PDF not found: {path}\n"
48
+ "Ensure 'Built On Rock by Iyanuoluwa Joseph Akinnusi-1.pdf' "
49
+ "is in the APEX folder."
50
+ )
51
+ reader = pypdf.PdfReader(path)
52
+ return "\n".join(page.extract_text() or "" for page in reader.pages)
53
+
54
+
55
+ def chunk_text(text: str, chunk_words: int, overlap_words: int) -> list[dict]:
56
+ words = text.split()
57
+ chunks = []
58
+ i = 0
59
+ while i < len(words):
60
+ chunk_text_str = " ".join(words[i : i + chunk_words])
61
+ chunks.append({
62
+ "id": f"chunk_{len(chunks):04d}",
63
+ "text": chunk_text_str,
64
+ "meta": {
65
+ "source": "built_on_rock",
66
+ "chunk_id": len(chunks),
67
+ "word_start": i,
68
+ "word_end": min(i + chunk_words, len(words)),
69
+ },
70
+ })
71
+ i += chunk_words - overlap_words
72
+ return chunks
73
+
74
+
75
+ def main():
76
+ api_key = os.environ.get("PINECONE_API_KEY", "")
77
+ index_name = os.environ.get("PINECONE_INDEX_NAME", "venthrive-bff")
78
+
79
+ if not api_key:
80
+ print("[ERR] PINECONE_API_KEY not set in backend/.env")
81
+ return
82
+
83
+ # ── Extract text ──────────────────────────────────────────────────────────
84
+ print(f"[PDF] Reading: {os.path.basename(PDF_PATH)}")
85
+ text = extract_pdf_text(PDF_PATH)
86
+ words = len(text.split())
87
+ print(f" {words:,} words extracted from PDF")
88
+
89
+ # ── Chunk ─────────────────────────────────────────────────────────────────
90
+ chunks = chunk_text(text, CHUNK_WORDS, OVERLAP_WORDS)
91
+ print(f" {len(chunks)} chunks ({CHUNK_WORDS} words each, {OVERLAP_WORDS} overlap)")
92
+
93
+ # ── Connect to Pinecone ───────────────────────────────────────────────────
94
+ print(f"\n[PIN] Connecting to Pinecone index '{index_name}'...")
95
+ pc = Pinecone(api_key=api_key)
96
+ index = pc.Index(index_name)
97
+
98
+ stats = index.describe_index_stats()
99
+ existing = stats.get("total_vector_count", 0)
100
+ print(f" Existing vectors: {existing}")
101
+
102
+ if existing > 0:
103
+ print(f" Index already has {existing} vectors.")
104
+ ans = input(" Clear and re-ingest? (y/N): ").strip().lower()
105
+ if ans == 'y':
106
+ index.delete(delete_all=True)
107
+ print(" ✓ Cleared")
108
+ else:
109
+ print(" Skipping ingestion.")
110
+ return
111
+
112
+ # ── Embed + upsert in batches ─────────────────────────────────────────────
113
+ print(f"\n[AI] Embedding + uploading {len(chunks)} chunks via Pinecone Inference...")
114
+ print(" (Model: multilingual-e5-large, 1024 dims — no local ML needed)")
115
+
116
+ for i in range(0, len(chunks), BATCH_SIZE):
117
+ batch = chunks[i : i + BATCH_SIZE]
118
+ texts = [c["text"] for c in batch]
119
+
120
+ # Generate embeddings via Pinecone Inference API
121
+ embedding_result = pc.inference.embed(
122
+ model=EMBED_MODEL,
123
+ inputs=texts,
124
+ parameters={"input_type": "passage", "truncate": "END"},
125
+ )
126
+ embeddings = [e.values for e in embedding_result]
127
+
128
+ # Upsert vectors
129
+ vectors = [
130
+ {
131
+ "id": chunk["id"],
132
+ "values": embeddings[j],
133
+ "metadata": {**chunk["meta"], "text": chunk["text"]},
134
+ }
135
+ for j, chunk in enumerate(batch)
136
+ ]
137
+ index.upsert(vectors=vectors)
138
+
139
+ done = min(i + BATCH_SIZE, len(chunks))
140
+ print(f" {done}/{len(chunks)} chunks uploaded")
141
+
142
+ # Small pause to stay within Pinecone rate limits
143
+ if done < len(chunks):
144
+ time.sleep(0.5)
145
+
146
+ # ── Done ──────────────────────────────────────────────────────────────────
147
+ final = index.describe_index_stats()
148
+ total = final.get("total_vector_count", "?")
149
+ print(f"\n[OK] Ingestion complete! Index '{index_name}' now has {total} vectors.")
150
+ print(" VenThrive AI will now draw from 'Built on Rock' for every relevant query.")
151
+
152
+
153
+ if __name__ == "__main__":
154
+ main()
utils/__init__.py ADDED
File without changes
utils/groq_client.py ADDED
@@ -0,0 +1,167 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Groq API Key Rotation Utility
3
+ ──────────────────────────────────────────────────────────────────────────────
4
+ Loads ALL env vars named GROQ_API_KEY or GROQ_API_KEY_* at startup.
5
+ When any route hits a 429 rate-limit error, calling rotate() silently
6
+ advances to the next key. Users never see a rate-limit error as long as
7
+ at least one key still has remaining quota.
8
+
9
+ Rotation is round-robin and thread-safe (uses a threading.Lock so concurrent
10
+ FastAPI workers don't race on the index).
11
+
12
+ Usage:
13
+ from utils.groq_client import call_groq_json, HEAVY_MODEL, LIGHT_MODEL
14
+
15
+ data = call_groq_json(system_prompt, user_prompt, json_mode=True)
16
+ """
17
+
18
+ import os
19
+ import json
20
+ import time
21
+ import threading
22
+ from groq import Groq
23
+
24
+ # ─── Load all keys ────────────────────────────────────────────────────────────
25
+ # Accepts: GROQ_API_KEY, GROQ_API_KEY_1 ... GROQ_API_KEY_N (any suffix)
26
+ _KEYS: list[str] = []
27
+ for k in sorted(os.environ.keys()):
28
+ if k == 'GROQ_API_KEY' or k.startswith('GROQ_API_KEY_'):
29
+ v = os.environ[k].strip()
30
+ if v:
31
+ _KEYS.append(v)
32
+
33
+ if not _KEYS:
34
+ raise RuntimeError(
35
+ "No Groq API keys found. "
36
+ "Set GROQ_API_KEY (and optionally GROQ_API_KEY_7, etc.) in your .env file."
37
+ )
38
+
39
+ print(f"[groq] {len(_KEYS)} API key(s) loaded for rotation")
40
+
41
+ # ─── Model names ──────────────────────────────────────────────────────────────
42
+ HEAVY_MODEL = "llama-3.3-70b-versatile" # reasoning, profile generation, consultation
43
+ LIGHT_MODEL = "llama-3.1-8b-instant" # chat, quick responses
44
+
45
+ # ─── Round-robin state ────────────────────────────────────────────────────────
46
+ _idx = 0
47
+ _lock = threading.Lock()
48
+
49
+
50
+ def _current_key() -> str:
51
+ return _KEYS[_idx % len(_KEYS)]
52
+
53
+
54
+ def _rotate() -> str:
55
+ """Advance to the next key and return it. Thread-safe."""
56
+ global _idx
57
+ with _lock:
58
+ _idx = (_idx + 1) % len(_KEYS)
59
+ new_key = _KEYS[_idx]
60
+ print(f"[groq] Rotated to key index {_idx % len(_KEYS)} of {len(_KEYS)}")
61
+ return new_key
62
+
63
+
64
+ # ─── Core call ────────────────────────────────────────────────────────────────
65
+ def call_groq(
66
+ system_prompt: str,
67
+ user_prompt: str,
68
+ model: str = HEAVY_MODEL,
69
+ json_mode: bool = False,
70
+ max_tokens: int = 1500,
71
+ temperature: float = 0.7,
72
+ ) -> str:
73
+ """
74
+ Calls Groq with automatic key rotation on 429 errors.
75
+ Tries every available key before giving up.
76
+ Raises the last error if all keys are exhausted.
77
+
78
+ Returns the raw string content of the first choice message.
79
+ """
80
+ last_error: Exception | None = None
81
+
82
+ # Try each key in the rotation at most once, plus one extra attempt
83
+ # in case the first key has recovered by the time we loop around.
84
+ for attempt in range(len(_KEYS) + 1):
85
+ try:
86
+ client = Groq(api_key=_current_key())
87
+ kwargs: dict = dict(
88
+ model=model,
89
+ messages=[
90
+ {"role": "system", "content": system_prompt},
91
+ {"role": "user", "content": user_prompt},
92
+ ],
93
+ temperature=temperature,
94
+ max_tokens=max_tokens,
95
+ )
96
+ if json_mode:
97
+ kwargs["response_format"] = {"type": "json_object"}
98
+
99
+ response = client.chat.completions.create(**kwargs)
100
+ return response.choices[0].message.content # type: ignore
101
+
102
+ except Exception as e:
103
+ last_error = e
104
+ err_str = str(e)
105
+ is_rate_limit = (
106
+ '429' in err_str
107
+ or 'rate_limit' in err_str.lower()
108
+ or 'rate limit' in err_str.lower()
109
+ )
110
+
111
+ if is_rate_limit and attempt < len(_KEYS):
112
+ # Rotate and pause briefly before retrying
113
+ _rotate()
114
+ time.sleep(1)
115
+ continue
116
+
117
+ # Non-rate-limit errors bubble up immediately
118
+ raise
119
+
120
+ # All keys exhausted
121
+ raise last_error or RuntimeError("All Groq API keys are rate-limited")
122
+
123
+
124
+ def call_groq_json(
125
+ system_prompt: str,
126
+ user_prompt: str,
127
+ model: str = HEAVY_MODEL,
128
+ max_tokens: int = 1500,
129
+ ) -> dict:
130
+ """Convenience wrapper: calls Groq in JSON mode and returns parsed dict."""
131
+ raw = call_groq(system_prompt, user_prompt, model=model,
132
+ json_mode=True, max_tokens=max_tokens)
133
+ return json.loads(raw)
134
+
135
+
136
+ def call_groq_stream(
137
+ system_prompt: str,
138
+ user_prompt: str,
139
+ model: str = LIGHT_MODEL,
140
+ max_tokens: int = 512,
141
+ ):
142
+ """
143
+ Returns a Groq streaming completion object.
144
+ Rotates key on 429 like all other calls.
145
+ """
146
+ last_error: Exception | None = None
147
+ for attempt in range(len(_KEYS) + 1):
148
+ try:
149
+ client = Groq(api_key=_current_key())
150
+ return client.chat.completions.create(
151
+ model=model,
152
+ messages=[
153
+ {"role": "system", "content": system_prompt},
154
+ {"role": "user", "content": user_prompt},
155
+ ],
156
+ temperature=0.7,
157
+ max_tokens=max_tokens,
158
+ stream=True,
159
+ )
160
+ except Exception as e:
161
+ last_error = e
162
+ if ('429' in str(e) or 'rate_limit' in str(e).lower()) and attempt < len(_KEYS):
163
+ _rotate()
164
+ time.sleep(1)
165
+ continue
166
+ raise
167
+ raise last_error or RuntimeError("All Groq API keys are rate-limited")
utils/pinecone_client.py ADDED
@@ -0,0 +1,74 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Pinecone Vector Search — "Built on Rock" context retrieval
3
+ ──────────────────────────────────────────────────────────────────────────────
4
+ Uses Pinecone's hosted Inference API (multilingual-e5-large, 1024 dims) to
5
+ embed queries and retrieve relevant passages from "Built on Rock".
6
+
7
+ No local ML library required — embeddings are generated by Pinecone's servers.
8
+
9
+ If PINECONE_API_KEY is not set or the call fails for any reason, returns ''
10
+ so all AI calls work normally without book context (graceful degradation).
11
+ """
12
+
13
+ import os
14
+ from functools import lru_cache
15
+
16
+ PINECONE_API_KEY = os.environ.get("PINECONE_API_KEY", "")
17
+ PINECONE_INDEX = os.environ.get("PINECONE_INDEX_NAME", "venthrive-bff")
18
+ EMBED_MODEL = "multilingual-e5-large" # 1024 dims, free on Pinecone
19
+ MIN_SCORE = 0.40
20
+ DEFAULT_TOP_K = 3
21
+
22
+
23
+ @lru_cache(maxsize=1)
24
+ def _get_pinecone():
25
+ from pinecone import Pinecone
26
+ return Pinecone(api_key=PINECONE_API_KEY)
27
+
28
+
29
+ def query_book_context(query: str, top_k: int = DEFAULT_TOP_K) -> str:
30
+ """
31
+ Returns the most relevant passages from 'Built on Rock' as a formatted
32
+ context string for injection into AI system prompts.
33
+
34
+ Returns '' if Pinecone is not configured or the call fails.
35
+ """
36
+ if not PINECONE_API_KEY:
37
+ return ""
38
+
39
+ try:
40
+ pc = _get_pinecone()
41
+ index = pc.Index(PINECONE_INDEX)
42
+
43
+ # Generate query embedding via Pinecone Inference (no local model needed)
44
+ embedding_result = pc.inference.embed(
45
+ model=EMBED_MODEL,
46
+ inputs=[query],
47
+ parameters={"input_type": "query", "truncate": "END"},
48
+ )
49
+ query_vector = embedding_result[0].values
50
+
51
+ results = index.query(
52
+ vector=query_vector,
53
+ top_k=top_k,
54
+ include_metadata=True,
55
+ )
56
+
57
+ passages = [
58
+ match["metadata"]["text"]
59
+ for match in results.get("matches", [])
60
+ if match.get("score", 0) >= MIN_SCORE
61
+ ]
62
+
63
+ if not passages:
64
+ return ""
65
+
66
+ return (
67
+ "RELEVANT CONTEXT FROM 'BUILT ON ROCK' (Business Footing Framework):\n\n"
68
+ + "\n\n---\n\n".join(passages)
69
+ + "\n\nApply the BFF philosophy above to ground your advice."
70
+ )
71
+
72
+ except Exception as e:
73
+ print(f"[pinecone] Context retrieval skipped: {e}")
74
+ return "" # graceful degradation