import requests, csv, os, json from datetime import datetime from typing import Dict, Any, List import time from fastapi import FastAPI, Form, Request from fastapi.responses import PlainTextResponse import asyncio from notion_client import Client from datetime import date from google.oauth2 import service_account from googleapiclient.discovery import build from googleapiclient.http import MediaFileUpload from docx import Document PROCESSED_MESSAGE_IDS = set() GEMINI_API_KEY = GEMINI_API_KEY = os.getenv("GEMINI_API_KEY") API_URL = "https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash:generateContent" SERVICE_ACCOUNT_FILE = "service_account.json" SCOPES = ["https://www.googleapis.com/auth/drive"] FOLDER_ID = "0ANln0H9x4-UrUk9PVA" WASENDER_API_KEY = "8e061ca01496dfaef2c00a0a021c2bf1fc6ded3fa72d648066afdc7067eb48c7" WASENDER_SEND_URL = "https://www.wasenderapi.com/api/send-message" NOTION_TOKEN = "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" # set in env DATABASE_ID = "1f7549bae1278158b067e07a7f5a969e" # set in env BRIEF_FOLDER_ID = "0AAVh0catJZlDUk9PVA" notion = Client(auth=NOTION_TOKEN) INTERNAL_CONSULTANT_PROMPT = """ What is missing (and MUST NOT be asked to the client) The missing pieces are internal consultant inputs, not client inputs. Think of the proposal generator as: Final Proposal = Client Brief (≈85%) + Accurate ME Consulting Intelligence (≈15%) This 15% should be auto-injected by the AI, not asked. ENRICHER 1 — Engagement Classification (internal) The AI should internally tag the project as: Complexity: Low / Medium / High Risk: Low / Medium / High Strategic importance: Tactical / Strategic / Board-level This controls: Tone Depth Number of phases Justification strength 📌 Example (internal): “B2B industrial market entry + regulation + board decision → High complexity, board-level.” ENRICHER 2 — Methodology Authority Layer (internal) The AI must decide: Desk vs primary mix Conservative assumptions vs directional Whether to recommend add-ons 📌 Example: “Given the decision-critical nature and lack of internal data, we recommend combining desk research with targeted expert interviews.” ENRICHER 3 — Accurate ME Positioning Injector (internal) Are strategy-led, not marketing-led Are conservative, not hype-driven Emphasize decision-making, not insights 📌 Example: “The analysis will prioritize realistic adoption scenarios, avoiding inflated market assumptions.” ENRICHER 4 — Pricing Logic Guardrail (internal) Translate scope → effort → price band NEVER show price reasoning to the client Only output a clean Investment table INTERNAL CONSULTANT LOGIC (NOT SHOWN TO CLIENT): - Assess complexity - Decide methodology mix - Recommend optional add-ons - Apply Accurate ME tone & positioning - Control scope & pricing defensibly """ CSV_FILE = "responses.csv" FIELDS = [ "Company", "Base", "Activity", "Services", "PriorityMarkets", "ProductDesc", "TargetCustomer", "Stage", "Goals", "Timeline", "FullName", "Email", "Phone", "AnythingElse", "Consent", "CallNeeded" # <-- NEW FIELD ] ACCURATE_INFO = """ Accurate Middle East is a boutique consulting firm based in Dubai, with active operations in Saudi Arabia. We specialise in market research, entry strategy, feasibility studies, and ESG advisory across the GCC and wider MENA region. Our team includes senior consultants with 15–20 years of regional experience and a track record of 100+ successful projects. We help businesses enter, scale, and succeed with practical, data-driven strategies tailored to the Middle East market. """.strip() # ========================= # WhatsApp Wasender Rate Limiter # ========================= CONSULTANT_ALERT_NUMBER = "971585902171" def send_consultant_alert(client_number: str, reason: str): msg = ( "🔔 Consultant Alert\n\n" f"Client WhatsApp: {client_number}\n" f"Reason: {reason}\n\n" "Please contact the client directly." ) send_whatsapp_message_safe(CONSULTANT_ALERT_NUMBER, msg) def send_whatsapp_message_safe(to: str, body: str) -> None: payload = { "to": to, "text": body } headers = { "Authorization": f"Bearer {WASENDER_API_KEY}", "Content-Type": "application/json" } try: requests.post( WASENDER_SEND_URL, json=payload, headers=headers, timeout=5 ) except Exception as e: print("❌ Wasender send error:", e) def is_gibberish_with_gemini(msg: str) -> bool: prompt = f""" Classify the following message strictly as GIBBERISH or NOT_GIBBERISH. A message is GIBBERISH if: - It contains random characters - Nonsense typing - Emoji spam - Completely irrelevant noise - No meaningful business or conversational content Message: "{msg}" Respond with exactly one word: GIBBERISH or NOT_GIBBERISH. """ result = gemini_reply(prompt).strip().upper() return result == "GIBBERISH" def generate_internal_consultant_logic(data: dict) -> str: prompt = f""" {INTERNAL_CONSULTANT_PROMPT} CLIENT DATA (FOR CONTEXT ONLY): {data} RULES: - INTERNAL ONLY - DO NOT ask questions - DO NOT explain reasoning - DO NOT format for client """ return gemini_reply(prompt) def generate_client_brief(data: dict) -> str: prompt = f""" Generate a professional consulting PROJECT BRIEF using ONLY client-provided data. STRICT RULES: - No assumptions - No pricing - No methodology justification - No Accurate ME positioning CLIENT DATA: {data} """ return gemini_reply(prompt) def upload_brief_to_drive(file_path: str): creds = service_account.Credentials.from_service_account_file( SERVICE_ACCOUNT_FILE, scopes=SCOPES ) service = build("drive", "v3", credentials=creds) media = MediaFileUpload( file_path, mimetype="application/vnd.openxmlformats-officedocument.wordprocessingml.document" ) file_metadata = { "name": os.path.basename(file_path), "parents": [BRIEF_FOLDER_ID] } return service.files().create( body=file_metadata, media_body=media, fields="id,name", supportsAllDrives=True ).execute() def create_internal_logic_file(text: str, company: str) -> str: filename = f"{company.replace(' ', '_')}_Internal_Consultant_Notes.txt" with open(filename, "w", encoding="utf-8") as f: f.write(text) return filename def upload_internal_logic_to_drive(file_path: str): creds = service_account.Credentials.from_service_account_file( SERVICE_ACCOUNT_FILE, scopes=SCOPES ) service = build("drive", "v3", credentials=creds) media = MediaFileUpload(file_path, mimetype="text/plain") file_metadata = { "name": os.path.basename(file_path), "parents": [BRIEF_FOLDER_ID] # SAME AS PROPOSAL } return service.files().create( body=file_metadata, media_body=media, fields="id,name", supportsAllDrives=True ).execute() def is_company_info_question_gemini(msg: str) -> bool: prompt = f""" You are a classifier that detects whether the user is ASKING about the consulting firm "Accurate Middle East". IMPORTANT RULES: 1. ONLY respond YES if the user is ASKING a question about the company. 2. If the user is NOT asking a question (e.g., they are answering something, sharing their own location, describing their own company, or giving information), respond NO. 3. Statements like: "It is in the UAE" "We are based in Riyadh" "My company is in India" "Our HQ is in London" "We operate in Saudi" MUST be classified as NO. 4. Respond YES ONLY if the user asks: - what Accurate Middle East is - who Accurate Middle East is - what your company does - who you work for - where your company is based - what services your company provides - details about your consulting firm - any version of "what do you do?", "tell me about yourself", "tell me about your firm", "what company is this?" Here is the official, accurate company information for reference (do NOT output this text here; this is only context for understanding what a company-related question looks like): "Accurate Middle East is a boutique consulting firm based in Dubai, with active operations in Saudi Arabia. We specialise in market research, entry strategy, feasibility studies, and ESG advisory across the GCC and wider MENA region. Our team includes senior consultants with 15–20 years of regional experience and a track record of 100+ successful projects. We help businesses enter, scale, and succeed with practical, data-driven strategies tailored to the Middle East market." User message: "{msg}" Respond with EXACTLY one word: YES or NO. """ result = gemini_reply(prompt).strip().lower() return result == "yes" def is_call_intent_with_gemini(message: str) -> bool: """ Uses Gemini to strictly detect if the user wants to schedule a call. It avoids accidental triggers like 'market', 'recall', etc. """ intent_prompt = f""" Determine if the following message expresses a clear intent to schedule a phone call, voice call, video call, meeting, or demo with a consultant. Message: "{message}" Rules: - ONLY return "YES" if the user is explicitly asking to schedule, arrange, book, or plan a call/meeting/demo. - Return "NO" if the message is simply mentioning the word 'call' or related words without actually wanting to schedule something. - Return "NO" for messages about markets, Riyadh, Saudi Arabia, locations, products, services. Respond with exactly one word: YES or NO. """ result = gemini_reply(intent_prompt).strip().lower() return "yes" in result def generate_proposal_docx(proposal_text: str, company: str) -> str: filename = f"{company.replace(' ', '_')}_Proposal.docx" doc = Document() doc.add_heading(f"{company} – Consulting Proposal", level=1) for line in proposal_text.split("\n"): doc.add_paragraph(line) doc.save(filename) return filename def upload_docx_to_drive(file_path: str): creds = service_account.Credentials.from_service_account_file( SERVICE_ACCOUNT_FILE, scopes=SCOPES ) service = build("drive", "v3", credentials=creds) media = MediaFileUpload( file_path, mimetype="application/vnd.openxmlformats-officedocument.wordprocessingml.document" ) file_metadata = { "name": os.path.basename(file_path), "parents": [FOLDER_ID] } file = service.files().create( body=file_metadata, media_body=media, fields="id,name", supportsAllDrives=True ).execute() return file def beautify_summary_with_gemini(data: dict) -> str: prompt = f""" You are a senior consultant at Accurate Middle East. Your task: Clean and rewrite the intake data into a polished, professional summary. Rules: - STANDARDIZE labels EXACTLY to: • Company • Base • Activity • Services • Priority Markets • Product Description • Target Customer • Stage • Goals • Timeline • Full Name • Email • Phone • Anything Else • Consent • Call Needed - Fix grammar, typos, casing. - Convert messy text into clean business English. - Correct obvious mismapped fields (e.g., email in name field). - Keep bullet-point structure. - No extra fields. - No explanation, ONLY the final bullet list. DATA: {data} OUTPUT FORMAT: • Company: • Base: • Activity: • Services: • Priority Markets: • Product Description: • Target Customer: • Stage: • Goals: • Timeline: • Full Name: • Email: • Phone: • Anything Else: • Consent: • Call Needed: """ return gemini_reply(prompt) def send_to_google_doc(proposal_text, data): url = "https://script.google.com/macros/s/AKfycbywg-eSTozugQY2nbz8RGk38TjN8_qBDO0rZDVF6y7-ulU2qPzS7b0Runxf5OFczrsO/exec" payload = { "proposal_text": proposal_text, "company": data.get("Company", "Unknown") } try: r = requests.post(url, json=payload) print("Google Doc Updated:", r.text) except Exception as e: print("Error updating Google Doc:", e) def send_whatsapp_message(to: str, body: str) -> None: payload = { "to": to, # plain phone number, no "whatsapp:" "text": body } headers = { "Authorization": f"Bearer {WASENDER_API_KEY}", "Content-Type": "application/json" } r = requests.post(WASENDER_SEND_URL, json=payload, headers=headers) if r.status_code != 200: print("Wasender send error:", r.status_code, r.text) def gemini_reply(prompt: str) -> str: payload = {"contents": [{"parts": [{"text": prompt}]}]} headers = {"Content-Type": "application/json", "X-goog-api-key": GEMINI_API_KEY} r = requests.post(API_URL, headers=headers, json=payload) if r.status_code != 200: return f"[❌ Error {r.status_code}] {r.text}" try: return r.json()["candidates"][0]["content"]["parts"][0]["text"] except Exception as e: return f"[❌ Parse error] {str(e)}" system_prompt = """ IMPORTANT FORMAT RULE: - Never format your reply as a list. - Do NOT use numbering like 1), 2), 3) or bullets such as "-" or "•". - Always respond as natural conversational text, even if there are multiple parts (acknowledgement, update, question). You are a friendly intake assistant for "Accurate Middle East". Your job is to collect information using the following questions: 1. Company name and website 2. Where is your company currently based or operating? 3. What is your current business activity or main product/service? 4. What kind of services are you currently looking for in MENA or GCC region? (Market Opportunity, Competitor Benchmarking, Go-to-Market, Customer Segmentation, Feasibility Study, Business Model Adaptation, Business Plan, Search for Partners, Other) 5. Which market(s) do you consider as your priority? (UAE, Saudi Arabia, Other GCC, Wider MENA, Other) 6. What product or service are you planning to offer in this market? (short description) 7. Who is your target customer? (B2C, B2B, B2G, Distributors, Other) 8. What is the current stage of your business in GCC/MENA? (Exploring, Ready-not-launched, Launched-needs-traction, Operating) 9. What are your goals for the next 6–12 months? (Launch, Validate demand, Increase sales, Find partners, Raise funding, Other) 10. What is your expected timeline for this project? (Urgent, Soon, Medium-term, Long-term) 11. Your full name & role 12. Your email 13. Your phone number / WhatsApp 14. Anything else we should know? (deadlines, constraints, requirements) 15. Do you consent to us using your data to follow up? (YES/NO) Tone & flow: - Sound warm, natural, and human. - Acknowledge each answer (e.g. "Great, thanks!") and then ask the next question. - Ask only ONE question at a time. STRICT NO-REPEAT PROTOCOL (answer caching): - Before you speak, read "Collected so far". Anything present there is FINAL unless the user explicitly corrects it. - Determine next_required = the FIRST item in "Still missing". - Your message MUST ONLY ask about next_required. Do NOT ask about anything already present in "Collected so far". - If the user’s new message looks like a correction to a previously filled field, confirm the update briefly, then immediately return to asking about next_required. - If next_required is somehow already present in "Collected so far", SKIP it and ask the next item in "Still missing". - NEVER re-ask a question that is already answered and stored in "Collected so far". CLARIFICATION GUARD (for ambiguous inputs): - If the user’s message is vague/short/unclear (e.g., "maybe", "not sure", "ok", "idk", "later", "soon", "—"), or it lacks the specific info required for next_required, then DO NOT treat it as a valid answer. Politely ask a focused clarification question for the SAME next_required field. - Keep the clarification concise and specific. Offer 2–4 concrete examples or choices if helpful. - After asking for clarification, do NOT advance to the next field. POLITE REPAIR STRATEGY (confusing/mismatched answers): - If the user's message seems confusing, contradictory to "Collected so far", or you are unsure how it maps to next_required, start with: "Sorry, I may have misunderstood that. Could you please rephrase?" - Then immediately ask a focused follow-up for the SAME next_required field (do NOT progress to another question). Call intent: - If a call/demo scheduling intent appears, stop the question flow temporarily, confirm details (time/date, contact method), and set "CallNeeded" = YES in the data. - After handling the call intent, resume from the next_required field. PRICING QUESTIONS (transparent placeholder, do NOT be evasive, and do NOT treat it as an answer to next_required): - If the user asks about price/cost/pricing/quote/budget at any time, first give this friendly placeholder reply verbatim: "We’ll share accurate pricing after reviewing your project details 📊. Usually our consultants review these first to give exact figures." - After that one sentence, immediately continue by asking exactly ONE question for next_required (per the single-question rule). - Do NOT mark a pricing question as an answer to next_required; explicitly state "Not saving this yet—pricing will follow after details." if needed. - Maintain the no-repeat behavior: do not re-ask any field already present in "Collected so far". RESPONSE TEMPLATE: - Write in 1–3 short sentences of natural text. - First sentence: friendly acknowledgement of what the user just said. - If the user corrected something, just say "Got it" without showing any 'Updated ' line. Never echo the field name in an update sentence. - Final sentence: ask exactly one clear question for the next_required field, starting with: "Q: ..." - Do NOT use lists, bullets, or numbering. Just plain text with line breaks if needed. Examples: - Ambiguous -> clarify with polite repair ACK: Thanks for the update! Q [Services]: Sorry, I may have misunderstood that. Could you rephrase? Not saving this yet—could you clarify which of these you need? (e.g., Go-to-Market, Competitor Benchmarking, Feasibility Study, Customer Segmentation) - Clear -> proceed ACK: Great, noted your base location. Q [Activity]: What’s your current business activity or main product/service? - Pricing asked mid-flow ACK: Thanks for asking about pricing. We’ll share accurate pricing after reviewing your project details 📊. Usually our consultants review these first to give exact figures. Q []: Arabic support: - Do NOT show Arabic versions of the questions. - Do NOT generate bilingual questions. - The bot should ask all questions ONLY in English. - However, the bot MUST still accept and correctly understand Arabic answers. - If the user replies in Arabic, treat it normally and store the field values as usual. """ sessions: Dict[str, Dict[str, Any]] = {} def create_word_from_proposal(proposal_text: str, company: str) -> str: filename = f"{company.replace(' ', '_')}_Proposal.docx" doc = Document() doc.add_heading(f"{company} – Consulting Proposal", level=1) for line in proposal_text.split("\n"): doc.add_paragraph(line) doc.save(filename) return filename def upload_word_to_drive(file_path: str): creds = service_account.Credentials.from_service_account_file( SERVICE_ACCOUNT_FILE, scopes=SCOPES ) service = build("drive", "v3", credentials=creds) # (optional but recommended) sanity check folder access service.files().get( fileId=FOLDER_ID, fields="id,name,driveId", supportsAllDrives=True ).execute() media = MediaFileUpload( file_path, mimetype="application/vnd.openxmlformats-officedocument.wordprocessingml.document" ) file_metadata = { "name": os.path.basename(file_path), "parents": [FOLDER_ID] } file = service.files().create( body=file_metadata, media_body=media, fields="id,name", supportsAllDrives=True ).execute() return file def extract_possible_fields_with_gemini(message: str) -> dict: """ Detect if the user has implicitly given answers to ANY intake fields (location, activity, markets, product, services, etc.) even before the bot asked that specific question. Returns: {field_name: extracted_value} Only returns fields with HIGH confidence. """ prompt = f""" You are assisting a structured WhatsApp intake flow. The user message may contain early contextual information. Your task is to extract ONLY the fields the message clearly provides. If unsure, leave the field out. FIELDS AND WHAT THEY MEAN: - Company: actual company name or website. - Base: where the company is located or operating (e.g., Dubai, Riyadh, London). - Activity: what the company does. - Services: what consulting support they want. - PriorityMarkets: which countries they want to expand into (e.g., UAE, KSA). - ProductDesc: the product/service they plan to offer in the region. - TargetCustomer: audience (B2B, B2C, etc.) - Stage: stage of GCC business (Exploring, Ready-not-launched, etc.) - Goals: their 6–12 month goals. - Timeline: urgency or timeline. - AnythingElse: only if they explicitly add extra notes. MESSAGE: "{message}" Return ONLY a valid JSON object: {{ "Company": "", "Base": "", "Activity": "", "Services": "", "PriorityMarkets": "", "ProductDesc": "", "TargetCustomer": "", "Stage": "", "Goals": "", "Timeline": "", "AnythingElse": "" }} Only include keys YOU ARE SURE ABOUT. If a field is not present, DO NOT include it. """ try: result = gemini_reply(prompt) return json.loads(result) except: return {} def init_session() -> Dict[str, Any]: return { "data": {}, "missing": FIELDS.copy(), "escalated": False, "started": False, "stage": "collecting", # collecting, awaiting_call_time, awaiting_call_continue, confirming, editing_select, editing_value, editing_suggestion, done "field_to_correct": None, "editing_suggestion": None, "editing_new_val_raw": None, "gibberish_count": 0, "last_two_msgs": [] } def save_to_csv(data: Dict[str, Any]) -> None: row = [datetime.now().isoformat()] + [data.get(f, "") for f in FIELDS] with open(CSV_FILE, "a", newline="") as f: csv.writer(f).writerow(row) def save_to_notion(data: Dict[str, Any]) -> None: properties = { # Company → TITLE "Company": { "title": [{"text": {"content": data.get("Company", "")}}] }, # Point Person → rich text (use FullName from intake) "Point Person": { "rich_text": [{"text": {"content": data.get("FullName", "")}}] }, # Email "Email": { "email": data.get("Email", "") }, # Status (keep safe default that exists in your Notion DB, like your working example) "Status": { "select": {"name": "Lead"} }, # Tags (optional: store Services as a single tag if present) "Tags": { "multi_select": ( [{"name": data.get("Services", "").strip()}] if data.get("Services", "").strip() else [] ) }, # Last Contact "Last Contact": { "date": {"start": date.today().isoformat()} } } if data.get("Proposal Draft"): properties["Proposal Draft"] = { "rich_text": [{"text": {"content": data["Proposal Draft"]}}] } if data.get("Brief Notes"): properties["Brief Notes"] = { "rich_text": [{"text": {"content": data["Brief Notes"]}}] } if data.get("Notes"): properties["Notes"] = { "rich_text": [{"text": {"content": data["Notes"]}}] } phone_raw = data.get("Phone", "") if phone_raw: phone_as_number = int("".join(filter(str.isdigit, phone_raw))) properties["Phone Number"] = {"number": phone_as_number} notion.pages.create( parent={"database_id": DATABASE_ID}, properties=properties ) def start_confirmation(session: Dict[str, Any]) -> str: data = session["data"] # Polished & normalized summary via Gemini polished_summary = beautify_summary_with_gemini(data) session["stage"] = "confirming" return ( "📋 Bot: Here’s what I’ve collected:\n\n" + polished_summary + "\n\n🤖 Bot: Does everything look correct? (yes/no)" ) HUMAN_KEYWORDS = ["consultant", "talk to a human", "human agent", "human support", "live agent"] CALL_KEYWORDS = ["call", "meeting", "demo", "schedule"] def handle_collecting(user: str, session: Dict[str, Any]) -> str: data = session["data"] missing = session["missing"] extracted = extract_possible_fields_with_gemini(user) if extracted: for field, value in extracted.items(): if field in missing: # Only auto-fill if not already answered data[field] = value missing.remove(field) session["last_two_msgs"].append(user) if len(session["last_two_msgs"]) > 2: session["last_two_msgs"] = session["last_two_msgs"][-2:] if len(session["last_two_msgs"]) == 2: m1, m2 = session["last_two_msgs"] if is_gibberish_with_gemini(m1) and is_gibberish_with_gemini(m2): session["gibberish_count"] += 1 if is_company_info_question_gemini(user): return ACCURATE_INFO if not missing: return start_confirmation(session) current_field = missing[0] # --- EARLY SAVE FIX --- # if user: # data[current_field] = user # Human escalation # if any(kw in user.lower() for kw in HUMAN_KEYWORDS): # session["escalated"] = True # data["CallNeeded"] = "CONSULTANT – user requested human handoff" # save_to_csv(data) # session["stage"] = "done" # return "\n🤖 Bot: Sure — connecting you to a consultant now 🤝. I’ve saved your details and flagged this for a consultant to contact you shortly." if any(kw in user.lower() for kw in HUMAN_KEYWORDS): session["escalated"] = True data["CallNeeded"] = "CONSULTANT – user requested human handoff" save_to_csv(data) send_consultant_alert(user, "User explicitly asked for a consultant") session["stage"] = "done" return "🤖 Bot: Sure — connecting you to a consultant now 🤝." if is_call_intent_with_gemini(user): session["stage"] = "awaiting_call_time" return "\n🤖 Bot: Got it, you’d like to schedule a call. When would be a good time for you?" context = f""" Collected so far: {data}. Still missing: {missing}. Currently asking (next_required): {current_field}. User just said: "{user}". Formatting constraints: - Do NOT output a list. - Do NOT number lines (no 1), 2), 3)). - Write everything as continuous natural text: acknowledgement, optional UPDATE sentence, then the question for next_required. Hard constraints: - If a field appears in "Collected so far", DO NOT ask it again. - Ask exactly ONE question, and it MUST be for the FIRST item in "Still missing" (shown above as next_required). - If the user corrected an earlier field, acknowledge the update briefly, then immediately ask about next_required. Ambiguity & polite repair rule: - Evaluate the user's message for next_required. If it is vague/short/unclear/contradictory or you are unsure how it maps to next_required, first say: "Sorry, I may have misunderstood that. Could you please rephrase?" Then ask a concise clarification for the SAME field and explicitly say "Not saving this yet—..." Do NOT move on. Pricing rule: - If the user's message is about price/cost/pricing/quote/budget, respond with the exact placeholder: "We’ll share accurate pricing after reviewing your project details 📊. Usually our consultants review these first to give exact figures." - Then still ask exactly one question for next_required. - Treat pricing questions as NOT an answer to next_required (say "Not saving this yet—pricing will follow after details." if helpful). Use the exact response template from the system prompt (ACK / optional UPDATE / Q []: ...). """ enhanced_prompt = f""" {system_prompt} Remember: - Do NOT repeat or re-ask the same question if "{current_field}" already exists in Collected so far. - If the user has already answered "{current_field}", acknowledge briefly and continue to the next item in Still missing. - Only re-ask if the user's message was empty or unclear. - Your next question MUST be for the next_required field that is NOT yet answered. {context} """ bot_text = gemini_reply(enhanced_prompt) # Move to next field if current_field in missing: missing.remove(current_field) # Consent → CallNeeded skip logic if current_field == "Consent": if "CallNeeded" in missing: missing.remove("CallNeeded") if "CallNeeded" not in data: data["CallNeeded"] = "No" if not missing: return bot_text + start_confirmation(session) return bot_text def drive_file_link(file_id: str) -> str: return f"https://drive.google.com/file/d/{file_id}/view" def handle_call_time(user: str, session: Dict[str, Any]) -> str: data = session["data"] call_time = user.strip() data["CallNeeded"] = f"YES - Requested at {call_time}" session["stage"] = "awaiting_call_continue" return ( f"\n📅 Bot: Noted your request for a call at {call_time}. We’ll follow up shortly." "\n\n🤖 Bot: Would you like me to continue with the intake questions from where we left off? (yes/no)" ) def handle_call_continue(user: str, session: Dict[str, Any]) -> str: ans = user.strip().lower() if ans != "yes": return start_confirmation(session) session["stage"] = "collecting" data = session["data"] missing = session["missing"] if not missing: return start_confirmation(session) current_field = missing[0] context = f""" Collected so far: {data}. Still missing: {missing}. Currently asking (next_required): {current_field}. User just said: "User confirmed they want to continue; this is NOT an answer to any intake question.". Hard constraints: - If a field appears in "Collected so far", DO NOT ask it again. - Ask exactly ONE question, and it MUST be for the FIRST item in "Still missing" (shown above as next_required). """ enhanced_prompt = f""" {system_prompt} Remember: - The last user message was only to confirm continuing, not an answer. - Do NOT treat it as an answer to any field. - Proceed by asking exactly one clear question for the next_required field. {context} """ bot_text = gemini_reply(enhanced_prompt) if current_field in missing: missing.remove(current_field) if current_field == "Consent": if "CallNeeded" in missing: missing.remove("CallNeeded") if "CallNeeded" not in data: data["CallNeeded"] = "No" if not missing: return bot_text + start_confirmation(session) return bot_text def handle_confirming(user: str, session: Dict[str, Any], user_id: str) -> str: ans = user.strip().lower() if ans == "yes": # Save CSV save_to_csv(session["data"]) try: save_to_notion(session["data"]) except Exception as e: print("Notion insert error:", e) data = session["data"] session["stage"] = "done" internal_logic = generate_internal_consultant_logic(data) logic_file = create_internal_logic_file( text=internal_logic, company=data.get("Company", "Client") ) # upload_internal_logic_to_drive(logic_file) # os.remove(logic_file) internal_upload = upload_internal_logic_to_drive(logic_file) session["data"]["Notes"] = drive_file_link(internal_upload["id"]) os.remove(logic_file) brief_text = generate_client_brief(data) brief_doc = create_word_from_proposal( proposal_text=brief_text, company=data.get("Company", "Client") + "_Brief" ) # upload_brief_to_drive(brief_doc) # os.remove(brief_doc) brief_upload = upload_brief_to_drive(brief_doc) session["data"]["Brief Notes"] = drive_file_link(brief_upload["id"]) os.remove(brief_doc) proposal_prompt = f""" ROLE & VOICE You are a senior consultant with 20+ years of experience in market research, strategy, and market entry across the GCC. Your job is to produce tailored, high-impact consulting proposals that combine deep market insight with board-level structure. STRUCTURE: 1. Executive Summary 2. Objectives of the Engagement 3. Scope of Work (Phased Breakdown) 4. Research Methodology Table 5. Workflow & Timeline (Week-by-Week) 6. Optional Add-Ons 7. Investment & Payment Schedule 8. Why Accurate Middle East 9. Relevant Case Studies 10. Next Steps STYLE RULES: • Clean headings • Bullet points everywhere • Tables when needed • Sharp, consultant tone • Tailored to the client CLIENT DATA: {data} FORMAT RULES: - Output using Markdown headings - Bullet points for all lists - No meta explanations - Ready to paste into Google Docs """ proposal_text = gemini_reply(proposal_prompt) # ---- 2. Generate Word doc + upload to Drive ---- doc_path = create_word_from_proposal( proposal_text=proposal_text, company=data.get("Company", "Client") ) # uploaded = upload_word_to_drive(doc_path) # os.remove(doc_path) uploaded = upload_word_to_drive(doc_path) session["data"]["Proposal Draft"] = drive_file_link(uploaded["id"]) os.remove(doc_path) print("✅ Word proposal uploaded:", uploaded["name"], uploaded["id"]) try: return "\n🤖 Bot: Thanks! Your responses have been saved and your proposal has been generated." # send_whatsapp_message( # to=user_id, # body="📄 Your tailored consulting proposal has been generated and added to our system. Our team will review it and contact you shortly." # ) except Exception as e: print("Twilio error:", e) return "\n🤖 Bot: Thanks! Your responses have been saved and your proposal has been generated." if ans == "no": session["stage"] = "editing_select" return "\n🤖 Bot: No problem! Which field would you like to correct? (Type field name or 'done' to finish)" return "\n🤖 Bot: Please reply with 'yes' or 'no'." def handle_editing_select(user: str, session: Dict[str, Any]) -> str: txt = user.strip() if txt.lower() == "done": save_to_csv(session["data"]) session["stage"] = "done" return "\n🤖 Bot: Got it. I’ve saved your updated responses. 🎉" matches = [f for f in FIELDS if f.lower() == txt.lower()] if not matches: return "\n⚠️ Bot: That field doesn’t exist. Please type a valid field name or 'done'." field = matches[0] session["field_to_correct"] = field session["stage"] = "editing_value" return f"\n🤖 Bot: Okay, what should I update '{field}' to?" def handle_editing_value(user: str, session: Dict[str, Any]) -> str: field = session.get("field_to_correct") if not field: session["stage"] = "editing_select" return "\n⚠️ Bot: I lost track of the field. Please type the field name again." new_val = user.strip() session["editing_new_val_raw"] = new_val if (not new_val) or len(new_val.split()) <= 1: ai_suggestion = gemini_reply( f"User wants to update '{field}' but only gave '{new_val}'. " f"Suggest a clearer version for CSV storage." ) session["editing_suggestion"] = ai_suggestion session["stage"] = "editing_suggestion" return ( f"\n⚡ Bot AI Suggestion for {field}: {ai_suggestion}\n" "Use this suggestion? (yes/no)" ) session["data"][field] = new_val session["stage"] = "editing_select" return f"\n✅ Bot: Updated {field}. You can type another field name to correct or 'done' to finish." def handle_editing_suggestion(user: str, session: Dict[str, Any]) -> str: field = session.get("field_to_correct") suggestion = session.get("editing_suggestion") raw = session.get("editing_new_val_raw") ans = user.strip().lower() if not field: session["stage"] = "editing_select" return "\n⚠️ Bot: I lost track of the field. Please type the field name again." if ans == "yes": session["data"][field] = suggestion msg = f"\n✅ Bot: Updated {field} with the suggested value." elif ans == "no": session["data"][field] = raw msg = f"\n✅ Bot: Updated {field} with your original input." else: return "\n🤖 Bot: Please reply with 'yes' or 'no'." session["editing_suggestion"] = None session["editing_new_val_raw"] = None session["field_to_correct"] = None session["stage"] = "editing_select" return msg + " You can type another field name to correct or 'done' to finish." app = FastAPI() @app.post("/webhook/wasender") async def wasender_webhook(request: Request): payload = await request.json() msg = payload.get("data", {}).get("messages", {}) key = msg.get("key", {}) # 1️⃣ IGNORE messages sent by the bot itself # This stops API echoes / self-replies if key.get("fromMe") is True: return {"status": "ignored-from-me"} # 2️⃣ DEDUPLICATE webhook retries msg_id = key.get("id") if not msg_id: return {"status": "no-message-id"} if msg_id in PROCESSED_MESSAGE_IDS: return {"status": "duplicate-ignored"} PROCESSED_MESSAGE_IDS.add(msg_id) user_id = key.get("cleanedSenderPn") user_msg = (msg.get("messageBody") or "").strip() if not user_id: return {"status": "no sender"} # Get / create session session = sessions.get(user_id) if session is None: session = init_session() sessions[user_id] = session # Restart command if user_msg.lower() in ["restart", "reset", "start over"]: sessions[user_id] = init_session() greeting = gemini_reply(system_prompt + "\nPlease greet the user and start with Question 1.") send_whatsapp_message_safe(user_id, greeting) return {"status": "restarted"} # 3️⃣ FIRST MESSAGE SAFETY # Send intro and DO NOT process this message as input if not session["started"]: session["started"] = True greeting = gemini_reply(system_prompt + "\nPlease greet the user and start with Question 1.") send_whatsapp_message_safe(user_id, greeting) return {"status": "started"} # Normal conversation flow stage = session["stage"] if stage == "collecting": reply = handle_collecting(user_msg, session) elif stage == "awaiting_call_time": reply = handle_call_time(user_msg, session) elif stage == "awaiting_call_continue": reply = handle_call_continue(user_msg, session) elif stage == "confirming": reply = handle_confirming(user_msg, session, user_id) elif stage == "editing_select": reply = handle_editing_select(user_msg, session) elif stage == "editing_value": reply = handle_editing_value(user_msg, session) elif stage == "editing_suggestion": reply = handle_editing_suggestion(user_msg, session) elif stage == "done": reply = "🤖 Bot: Your responses have already been saved. Type 'restart' to begin again." else: session["stage"] = "collecting" reply = handle_collecting(user_msg, session) if reply and reply.strip(): send_whatsapp_message_safe(user_id, reply.strip()) return {"status": "ok"} def chatbot(): data = {} missing = FIELDS.copy() escalated = False # NEW: track human escalation print("🤖 Bot:", gemini_reply(system_prompt + "\nPlease greet the user and start with Question 1.")) while missing: current_field = missing[0] # <-- Track which field is being asked user = input("\n🧑 You: ").strip() # --- EARLY SAVE FIX --- # if user: # data[current_field] = user # Save answer before calling Gemini # === NEW: Human Escalation Shortcut === if any(kw in user.lower() for kw in HUMAN_KEYWORDS): print("\n🤖 Bot: Sure — connecting you to a consultant now 🤝.") data["CallNeeded"] = "CONSULTANT – user requested human handoff" escalated = True break # stop intake immediately # === Intent override for scheduling === if any(word in user.lower() for word in CALL_KEYWORDS): print("\n🤖 Bot: Got it, you’d like to schedule a call. When would be a good time for you?") call_time = input("\n🧑 You: ").strip() # Save call request details data["CallNeeded"] = f"YES - Requested at {call_time}" print(f"📅 Bot: Noted your request for a call at {call_time}. We’ll follow up shortly.") # Ask if they want to continue intake print("\n🤖 Bot: Would you like me to continue with the intake questions from where we left off? (yes/no)") cont = input("\n🧑 You: ").strip().lower() if cont != "yes": break else: continue # Build context for Gemini (same structure) context = f""" Collected so far: {data}. Still missing: {missing}. Currently asking (next_required): {current_field}. User just said: "{user}". Hard constraints: - If a field appears in "Collected so far", DO NOT ask it again. - Ask exactly ONE question, and it MUST be for the FIRST item in "Still missing" (shown above as next_required). - If the user corrected an earlier field, acknowledge the update briefly, then immediately ask about next_required. Ambiguity & polite repair rule: - Evaluate the user's message for next_required. If it is vague/short/unclear/contradictory or you are unsure how it maps to next_required, first say: "Sorry, I may have misunderstood that. Could you please rephrase?" Then ask a concise clarification for the SAME field and explicitly say "Not saving this yet—..." Do NOT move on. Pricing rule: - If the user's message is about price/cost/pricing/quote/budget, respond with the exact placeholder: "We’ll share accurate pricing after reviewing your project details 📊. Usually our consultants review these first to give exact figures." - Then still ask exactly one question for next_required. - Treat pricing questions as NOT an answer to next_required (say "Not saving this yet—pricing will follow after details." if helpful). Use the exact response template from the system prompt (ACK / optional UPDATE / Q []: ...). """ enhanced_prompt = f""" {system_prompt} Remember: - Do NOT repeat or re-ask the same question if "{current_field}" already exists in Collected so far. - If the user has already answered "{current_field}", acknowledge briefly and continue to the next item in Still missing. - Only re-ask if the user's message was empty or unclear. - Your next question MUST be for the next_required field that is NOT yet answered. {context} """ bot_text = gemini_reply(enhanced_prompt) print("\n🤖 Bot:", bot_text) if current_field in missing: missing.remove(current_field) if current_field == "Consent": if "CallNeeded" in missing: missing.remove("CallNeeded") if "CallNeeded" not in data: data["CallNeeded"] = "No" if escalated: print("\n📋 Bot: I’ve saved your details and flagged this for a consultant to contact you shortly.") row = [datetime.now().isoformat()] + [data.get(f, "") for f in FIELDS] with open(CSV_FILE, "a", newline="") as f: csv.writer(f).writerow(row) return data print("\n📋 Bot: Here’s what I’ve collected:\n") summary = "\n".join([f" • {f}: {data.get(f, '')}" for f in FIELDS]) print(summary) print("\n🤖 Bot: Does everything look correct? (yes/no)") confirm = input("\n🧑 You: ").strip().lower() if confirm != "yes": print("\n🤖 Bot: No problem! Which field would you like to correct? (Type field name or 'done' to finish)") while True: field_input = input("\n🧑 You: ").strip() if field_input.lower() == "done": break matches = [f for f in FIELDS if f.lower() == field_input.lower()] if matches: field = matches[0] print(f"🤖 Bot: Okay, what should I update '{field}' to?") new_val = input("\n🧑 You: ").strip() if not new_val or len(new_val.split()) == 1: ai_suggestion = gemini_reply( f"User wants to update '{field}' but only gave '{new_val}'. " f"Suggest a clearer version for CSV storage." ) print(f"⚡ Bot AI Suggestion for {field}: {ai_suggestion}") confirm_val = input("Use this suggestion? (yes/no): ").strip().lower() if confirm_val == "yes": new_val = ai_suggestion data[field] = new_val print(f"✅ Bot: Updated {field}.") else: print("⚠️ Bot: That field doesn’t exist. Please type a valid field name or 'done'.") row = [datetime.now().isoformat()] + [data.get(f, "") for f in FIELDS] with open(CSV_FILE, "a", newline="") as f: csv.writer(f).writerow(row) print("\n🤖 Bot: Thanks a lot for your time! 🎉 Your responses have been saved successfully. Our team will review them and get back to you soon.") return data if __name__ == "__main__": print("This file is intended to be run with:") print(" uvicorn main3:app --host 0.0.0.0 --port 8001") print("Twilio webhook URL: https:///whatsapp")