File size: 20,033 Bytes
e561008
 
89a70e2
e561008
 
889e86b
e561008
 
89a70e2
e561008
 
 
 
 
 
 
 
 
 
 
 
 
 
 
5026675
 
 
 
 
e561008
 
89a70e2
 
 
 
 
 
e561008
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
5026675
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
020705e
23455d9
 
 
 
 
 
 
 
020705e
e561008
 
 
 
 
 
 
889e86b
e561008
 
 
 
 
 
 
 
 
 
 
 
 
 
 
5026675
 
 
 
e561008
 
 
 
 
 
5026675
e561008
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
020705e
e561008
 
 
 
 
 
 
 
 
 
889e86b
e561008
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
89a70e2
 
 
e561008
 
 
 
 
 
 
 
 
 
 
 
 
 
5026675
e561008
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
5026675
 
 
 
 
 
e561008
23455d9
e561008
5026675
 
e561008
23455d9
e561008
 
 
 
 
 
23455d9
 
 
 
 
e561008
 
 
 
020705e
e561008
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
23455d9
 
 
 
 
 
5026675
 
 
 
 
 
 
23455d9
 
 
5026675
23455d9
 
 
e561008
 
23455d9
e561008
23455d9
e561008
5026675
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
89a70e2
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
import json
from fastapi import APIRouter, HTTPException, Depends
from pydantic import BaseModel, Field
from firebase_admin import firestore
from auth import verify_firebase_token
from utils.rate_limiter import with_rate_limit
from utils.groq_client import call_groq_json, HEAVY_MODEL
from utils.pinecone_client import query_book_context
from utils import niche_requests

router = APIRouter()


# ─── Request schemas ──────────────────────────────────────────────────────────

class GenerateFollowupsRequest(BaseModel):
    businessId: str
    section: int          # 1, 2, or 3
    sectionData: dict


class GenerateProfileRequest(BaseModel):
    userId: str
    businessId: str
    force: bool = False   # regenerate outputs even if they already exist (Settings edits)


class ReopenFollowupsRequest(BaseModel):
    businessId: str


class NicheRequestIn(BaseModel):
    text: str = Field(min_length=2, max_length=80)
    sectorId: str = Field(default="", max_length=60)
    subsectorId: str = Field(default="", max_length=60)


# ─── Helpers ──────────────────────────────────────────────────────────────────

def _db():
    return firestore.client()


def _get_bff_data(user_id: str, business_id: str) -> dict:
    """Reads all BFF sections from Firestore for a given business."""
    db = _db()
    biz_ref = (
        db.collection("user_data")
        .document(user_id)
        .collection("businesses")
        .document(business_id)
    )
    sections = {}
    for section_id in [
        "section1", "section1_followups",
        "section2", "section2_followups",
        "section3", "section3_followups",
    ]:
        doc = biz_ref.collection("bff").document(section_id).get()
        if doc.exists:
            sections[section_id] = doc.to_dict()
    return sections


# ─── Business stage ───────────────────────────────────────────────────────────
# Keep ids in sync with data/business-stages.ts on the frontend.

BUSINESS_STAGES = {
    "idea":        "Just an idea β€” has not started anything yet (Ideation stage)",
    "validation":  "Checking if the idea will work β€” researching and talking to possible customers (Validation / Planning stage)",
    "prelaunch":   "Getting ready to launch β€” registering, raising money, setting up (Pre-launch stage)",
    "launched":    "Just started β€” selling for less than 1 year (Launch / Startup stage)",
    "early":       "Running for 1–2 years (Early stage)",
    "growth":      "Running for 3–5 years (Growth stage)",
    "established": "Running for 6–10 years (Established / Expansion stage)",
    "mature":      "Running for over 10 years (Mature stage)",
    "restarting":  "Restarting or changing direction β€” has run a business before and is starting again or pivoting",
}

PRE_LAUNCH_STAGES = {"idea", "validation", "prelaunch"}


def _get_business_stage(user_id: str, business_id: str) -> str:
    """Reads the stage saved on the WHAT step. Returns '' if not set."""
    snap = (
        _db().collection("user_data").document(user_id)
        .collection("businesses").document(business_id)
        .collection("bff").document("section1").get()
    )
    return (snap.to_dict() or {}).get("businessStage", "") if snap.exists else ""


def _stage_guidance(stage: str) -> str:
    """Prompt instructions that keep the AI's tense and assumptions matched to the stage."""
    if stage not in BUSINESS_STAGES:
        return ""
    label = BUSINESS_STAGES[stage]
    if stage in PRE_LAUNCH_STAGES:
        rule = (
            "This business has NOT launched yet. Never assume it has customers, sales, staff, "
            "revenue or a track record. Phrase everything around plans, expectations and "
            "assumptions (e.g. 'Who do you expect to buy this?' not 'Who buys from you?')."
        )
    elif stage == "restarting":
        rule = (
            "The entrepreneur has run a business before and is starting again or changing direction. "
            "Treat the new direction as not yet proven: phrase it around plans, but draw on their "
            "past experience (what worked, what didn't, what they are changing and why)."
        )
    else:
        rule = (
            "This business is already operating. Ask about real experience so far β€” actual "
            "customers, what sells, what has worked and what hasn't β€” at a depth that suits "
            "how long it has been running."
        )
    return f"\n\nBUSINESS STAGE: {label}\n{rule}"


def _call_groq_json(system_prompt: str, user_prompt: str, fixture: str | None = None) -> dict:
    """
    Thin wrapper β€” delegates to the key-rotating Groq client utility.

    max_tokens is generous (3000) because HEAVY_MODEL is a reasoning model
    (openai/gpt-oss-120b) that spends part of its budget on internal
    reasoning before writing the final JSON β€” a tight budget here truncates
    the JSON output and fails to parse.
    """
    return call_groq_json(system_prompt, user_prompt, model=HEAVY_MODEL, max_tokens=3000, fixture=fixture)


# ─── Route: Generate follow-up questions ─────────────────────────────────────

@router.post("/generate-followups")
def generate_followups(
    body: GenerateFollowupsRequest,
    user: dict = Depends(with_rate_limit("generation")),
):
    """
    After each BFF section is submitted, generates 3–5 personalized
    follow-up questions specific to the user's sector and answers.
    Questions use plain English β€” no business jargon.
    """
    if body.section not in (1, 2, 3):
        raise HTTPException(status_code=400, detail="section must be 1, 2, or 3.")

    section_names = {1: "WHAT (identity)", 2: "WHY (purpose)", 3: "HOW (operations)"}

    # Fetch relevant BFF philosophy passages from "Built on Rock"
    bff_query = f"Business Footing Framework section {body.section} {json.dumps(body.sectionData)[:200]}"
    book_ctx   = query_book_context(bff_query, top_k=2)

    # Stage is read server-side so WHY/HOW follow-ups get it too, not just WHAT
    stage = body.sectionData.get("businessStage") if body.section == 1 else None
    stage = stage or _get_business_stage(user["uid"], body.businessId)

    system_prompt = (
        "You are a business consultant helping a Nigerian entrepreneur understand their business better. "
        "Generate follow-up questions based on what they have already shared. "
        "Questions MUST use simple, everyday English β€” no business jargon. "
        "Think: how would a friendly, experienced mentor ask? "
        "Return ONLY valid JSON."
        + _stage_guidance(stage)
        + (f"\n\n{book_ctx}" if book_ctx else "")
    )

    user_prompt = f"""
The entrepreneur just completed Section {body.section} ({section_names[body.section]}) of their Business Footing Framework.

Their answers:
{json.dumps(body.sectionData, indent=2)}

Generate 3-5 follow-up questions to deepen understanding of their business.
Make each question specific to what they shared β€” do not ask generic questions.

Return JSON in this exact format:
{{
  "questions": [
    {{
      "id": "q1",
      "question": "...",
      "type": "text",
      "placeholder": "..."
    }}
  ]
}}

Question types:
- "text"        β†’ free-text answer (use for open questions)
- "select"      β†’ single choice, add "options": ["Option A", "Option B", ...]
- "multiselect" β†’ multiple choice, add "options": [...]
- "boolean"     β†’ yes/no question
"""

    try:
        data = _call_groq_json(system_prompt, user_prompt, fixture="followups")
        return data
    except Exception as e:
        raise HTTPException(status_code=500, detail=f"Follow-up generation failed: {str(e)}")


# ─── Route: Generate full business profile ────────────────────────────────────

@router.post("/generate-profile")
def generate_profile(
    body: GenerateProfileRequest,
    user: dict = Depends(with_rate_limit("generation")),
):
    """
    Reads all completed BFF sections from Firestore and generates all 4
    profile outputs in parallel (Business Identity, Customer Profile,
    Market Data, Business Model). Saves results back to Firestore and
    marks bffCompleted = True on the business document.
    """
    # Only the document owner can trigger their own profile generation
    if user.get("uid") != body.userId:
        raise HTTPException(status_code=403, detail="Forbidden.")

    bff = _get_bff_data(body.userId, body.businessId)
    if not bff:
        raise HTTPException(status_code=404, detail="BFF data not found.")

    bff_summary = json.dumps(bff, indent=2)

    # ── Retrieve relevant passages from "Built on Rock" via Pinecone ─────────
    sector = bff.get("section1", {}).get("sectorId", "")
    s1     = bff.get("section1", {})
    # nicheId "other" means the person described their own niche in customNiche.
    niche  = s1.get("customNiche") if s1.get("nicheId") == "other" else s1.get("nicheId", "")
    book_ctx = query_book_context(
        f"Business Footing Framework WHAT WHY HOW {sector} {niche} Nigerian entrepreneur",
        top_k=3,
    )

    # ── System prompt used for all 4 calls ───────────────────────────────────
    base_system = (
        "You are an expert business consultant specializing in Nigerian MSMEs, "
        "trained in the Business Footing Framework (BFF) from 'Built on Rock' "
        "by Iyanuoluwa Joseph Akinnusi. "
        "Analyze the BFF data and produce professional, structured profile components. "
        "Write in plain English that ANY entrepreneur can understand. "
        "Ground all analysis in the Nigerian market context. "
        "Return ONLY valid JSON."
        + _stage_guidance(bff.get("section1", {}).get("businessStage", ""))
        + (f"\n\n{book_ctx}" if book_ctx else "")
    )

    # ── Prompt builders ───────────────────────────────────────────────────────
    def prompt_identity():
        return f"""
BFF Data:
{bff_summary}

Generate the Business Identity profile. Return JSON:
{{
  "businessName": "string",
  "sector": "string",
  "subsector": "string",
  "niche": "string",
  "summary": "2-3 sentence plain-English description of what this business does",
  "valueProposition": "one clear sentence: what the business promises customers",
  "brandVoice": "2-3 adjectives describing how the brand should communicate (e.g. warm, direct, trustworthy)",
  "tagline": "a short, memorable tagline for this business"
}}"""

    def prompt_customer():
        return f"""
BFF Data:
{bff_summary}

Generate the Customer Profile. Return JSON:
{{
  "demographics": {{
    "ageRange": "primary age range",
    "gender": "primary gender",
    "location": "primary location(s)",
    "incomeLevel": "low / middle / high / mixed",
    "educationLevel": "brief description",
    "employmentStatus": "brief description"
  }},
  "psychographics": {{
    "values": ["list of 3-5 values the customer likely holds"],
    "lifestyle": "brief lifestyle description",
    "painPoints": ["3-5 main pain points this business solves for them"],
    "motivations": ["3-5 reasons they would buy from this business"]
  }},
  "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."
}}"""

    def prompt_market():
        return f"""
BFF Data:
{bff_summary}

Generate the Market Data analysis. Return JSON:
{{
  "tam": "Total Addressable Market β€” brief estimate for Nigeria",
  "sam": "Serviceable Addressable Market β€” realistic target market segment",
  "som": "Serviceable Obtainable Market β€” what this business can realistically capture",
  "swot": {{
    "strengths": ["3-4 key strengths based on BFF data"],
    "weaknesses": ["3-4 potential weaknesses to address"],
    "opportunities": ["3-4 market opportunities in Nigeria right now"],
    "threats": ["3-4 threats or risks to watch out for"]
  }},
  "pestle": {{
    "political": "brief note on political factors relevant to this sector in Nigeria",
    "economic": "brief note on economic factors",
    "social": "brief note on social/cultural factors",
    "technological": "brief note on tech trends",
    "legal": "brief note on legal/regulatory considerations",
    "environmental": "brief note if applicable"
  }},
  "competitiveLandscape": "2-3 sentences describing who else competes in this space in Nigeria and how this business can differentiate"
}}"""

    def prompt_bmc():
        return f"""
BFF Data:
{bff_summary}

Generate the Business Model Canvas. Return JSON with exactly these 9 keys,
each containing a list of 2-4 concise bullet points:
{{
  "keyPartners": [...],
  "keyActivities": [...],
  "keyResources": [...],
  "valuePropositions": [...],
  "customerRelationships": [...],
  "channels": [...],
  "customerSegments": [...],
  "costStructure": [...],
  "revenueStreams": [...]
}}"""

    # ── Build the call plan β€” skip outputs already in Firestore ─────────────
    # On retry after a partial failure, this avoids regenerating completed
    # outputs and wasting tokens on the Groq free-tier TPM limit.
    import time
    db_check = _db()
    biz_ref_check = (
        db_check.collection("user_data")
        .document(body.userId)
        .collection("businesses")
        .document(body.businessId)
    )

    all_outputs = {
        "businessIdentity": prompt_identity,
        "customerProfile":  prompt_customer,
        "marketData":       prompt_market,
        "businessModel":    prompt_bmc,
    }

    # profileStale is set by /reopen-followups: the old profile was built without
    # follow-up answers, so it must be rebuilt even if the outputs already exist.
    # Read server-side so it survives the user leaving and resuming later.
    biz_snap = biz_ref_check.get()
    force = body.force or bool((biz_snap.to_dict() or {}).get("profileStale"))

    call_plan = []
    skipped_keys: set = set()
    for key, prompt_fn in all_outputs.items():
        existing = None if force else biz_ref_check.collection("profile_output").document(key).get()
        if existing is not None and existing.exists:
            print(f"[bff] Skipping {key} β€” already exists in Firestore")
            skipped_keys.add(key)
        else:
            call_plan.append((key, prompt_fn))

    results: dict = {}
    errors: list = []

    # Sequential calls with 15s gap to stay within the free-tier TPM limit.
    # max_tokens=3000 per call leaves headroom for HEAVY_MODEL's internal
    # reasoning tokens on top of the JSON output (see _call_groq_json).
    # 15s gap Γ— 200 tokens/sec refill recovers ~3,000 tokens between calls β€”
    # if that's not enough, the 5-key rotation pool absorbs the rest via 429s.
    for i, (key, prompt_fn) in enumerate(call_plan):
        if i > 0:
            time.sleep(15)  # 15s gap: ~3,000 tokens recovered at 200 tok/s
        try:
            results[key] = _call_groq_json(base_system, prompt_fn(), fixture=key)
        except Exception as e:
            errors.append(f"{key}: {str(e)}")
            print(f"[bff] Failed to generate {key}: {e}")

    # Fail only if we had work to do and ALL of it failed
    if call_plan and len(errors) == len(call_plan):
        raise HTTPException(
            status_code=500,
            detail=f"Profile generation failed: {'; '.join(errors)}",
        )

    # ── Save to Firestore ─────────────────────────────────────────────────────
    db = _db()
    biz_ref = (
        db.collection("user_data")
        .document(body.userId)
        .collection("businesses")
        .document(body.businessId)
    )

    from google.cloud.firestore_v1 import SERVER_TIMESTAMP
    for key, data in results.items():
        biz_ref.collection("profile_output").document(key).set(
            {**data, "generatedAt": SERVER_TIMESTAMP}
        )

    # Only mark BFF as complete once EVERY output actually exists β€” either
    # generated just now or already present from a prior partial run. Marking
    # this true when an output is still missing is what previously let a
    # business look "complete" with an empty/partial profile (see
    # project_multibusiness_gate memory β€” the manual-Console-flip incident
    # had the same shape of bug).
    # On a forced run, outputs that failed to regenerate still exist from before,
    # so count them as present rather than blocking completion.
    if force:
        skipped_keys = {
            k for k in all_outputs
            if k not in results and biz_ref.collection("profile_output").document(k).get().exists
        }
    present_keys = skipped_keys | set(results.keys())
    all_present  = present_keys == set(all_outputs.keys())
    if all_present:
        biz_ref.update({"bffCompleted": True, "profileStale": False})
    else:
        missing = set(all_outputs.keys()) - present_keys
        print(f"[bff] NOT marking bffCompleted β€” missing outputs: {missing}")

    return {
        "success": all_present,
        "outputs": list(results.keys()),
        "missing": sorted(set(all_outputs.keys()) - present_keys),
    }


# ─── Route: Reopen onboarding for missing follow-ups ─────────────────────────

def _missing_followup_sections(user_id: str, business_id: str) -> list[int]:
    """Sections (1-3) whose follow-up questions were never generated/saved."""
    bff_ref = (
        _db().collection("user_data").document(user_id)
        .collection("businesses").document(business_id)
        .collection("bff")
    )
    missing = []
    for section in (1, 2, 3):
        snap = bff_ref.document(f"section{section}_followups").get()
        if not snap.exists or not (snap.to_dict() or {}).get("questions"):
            missing.append(section)
    return missing


@router.post("/reopen-followups")
def reopen_followups(
    body: ReopenFollowupsRequest,
    user: dict = Depends(verify_firebase_token),
):
    """
    A profile was generated while some follow-up steps failed or were skipped.
    Marks the business incomplete (bffCompleted is backend-only per Firestore
    rules) and flags the profile stale so the next generate-profile rebuilds it
    with the follow-up answers included.
    """
    biz_ref = (
        _db().collection("user_data").document(user["uid"])
        .collection("businesses").document(body.businessId)
    )
    if not biz_ref.get().exists:
        raise HTTPException(status_code=404, detail="Business not found.")

    missing = _missing_followup_sections(user["uid"], body.businessId)
    if missing:
        biz_ref.update({"bffCompleted": False, "profileStale": True})
    return {"missing": missing}


# ─── Route: Niche not in our list ────────────────────────────────────────────

@router.post("/niche-request")
def niche_request(body: NicheRequestIn, user: dict = Depends(verify_firebase_token)):
    """Records a niche the person couldn't find, for admins to review and add."""
    try:
        niche_requests.record(user["uid"], body.text, body.sectorId, body.subsectorId)
    except ValueError as e:
        raise HTTPException(status_code=400, detail=str(e))
    return {"success": True}