Spaces:
Running
Running
GitHub Actions commited on
Commit ·
e561008
1
Parent(s): 4c1c07f
Sync from GitHub · 2026-06-25 17:24 UTC
Browse files- .env.example +12 -0
- .gitattributes +0 -35
- Dockerfile +17 -0
- README.md +10 -4
- auth.py +31 -0
- main.py +108 -0
- requirements.txt +13 -0
- routes/__init__.py +0 -0
- routes/admin.py +136 -0
- routes/bff.py +323 -0
- routes/chat.py +127 -0
- routes/consultation.py +186 -0
- routes/documents.py +202 -0
- routes/planning.py +139 -0
- routes/transcribe.py +64 -0
- scripts/__init__.py +0 -0
- scripts/ingest_book.py +154 -0
- utils/__init__.py +0 -0
- utils/groq_client.py +167 -0
- utils/pinecone_client.py +74 -0
.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:
|
| 3 |
-
emoji:
|
| 4 |
-
colorFrom:
|
| 5 |
colorTo: yellow
|
| 6 |
sdk: docker
|
|
|
|
| 7 |
pinned: false
|
|
|
|
| 8 |
---
|
| 9 |
|
| 10 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|