from flask import Flask, request, jsonify from flask_cors import CORS import json from datetime import datetime from typing import Optional, Dict, Any from firestore_client import get_firestore_client from openai_client import ask_gpt from prompt_instructions import build_system_message from role_access import get_allowed_collections # (currently unused but kept) from data_fetcher import fetch_data_from_firestore from data_planner import determine_data_requirements # 🧠 Gemini planner from resolver import resolve_user_context from schema_utils import has_field, resolve_field app = Flask(__name__) CORS(app) db = get_firestore_client() # -- helpers -------------------------------------------------------------- def to_jsonable(obj): """Recursively convert Firestore types (e.g., DatetimeWithNanoseconds) to JSON-safe values.""" # Firestore timestamp types come through as DatetimeWithNanoseconds (duck-type datetime) if isinstance(obj, datetime): return obj.isoformat() # Some Firestore SDKs expose Timestamp-like objects without subclassing datetime. # Fallback: detect presence of isoformat() if hasattr(obj, "isoformat") and callable(getattr(obj, "isoformat")): try: return obj.isoformat() except Exception: pass if isinstance(obj, dict): return {k: to_jsonable(v) for k, v in obj.items()} if isinstance(obj, list): return [to_jsonable(v) for v in obj] return obj # 🔧 Normalize Gemini plan into proper Firestore fetch format def normalize_plan(plan: dict, token_map: Optional[Dict[str, Any]] = None) -> dict: token_map = token_map or {} filters = plan.get("filters", {}) or {} planned_cols = plan.get("collections", []) or [] def canonical_value(key, val): # replace tokens like {{participantId}} if isinstance(val, str) and val in token_map: val = token_map[val] # special-case status normalization if key == "status" and val == "running": return "active" return val collections_out = [] for col in planned_cols: # Allow both strings ("participants") and objects ({"name":"participants","fields":[...]}). name = col["name"] if isinstance(col, dict) else col col_filters = [] for k, v in filters.items(): canon_key = resolve_field(name, k) # only add filters that exist for this collection (schema-aware) if has_field(name, canon_key): col_filters.append({ "field": canon_key, "op": "==", "value": canonical_value(k, v), }) # else: skip invalid field for this collection collections_out.append({"name": name, "filters": col_filters, "limit": 50}) return {"collections": collections_out} # -- route --------------------------------------------------------------- @app.route('/chat', methods=['POST']) def chat(): data = request.json role = data.get('role') user_input = data.get('message') company_code = data.get('companyCode') user_id = data.get('userId') if not role or not user_input or not company_code or not user_id: return jsonify({"error": "Missing role, message, companyCode, or userId"}), 400 # 🔎 Resolve current user's email + participantId (participants has no companyCode; resolve via applications) ctx = resolve_user_context(user_id, company_code) token_map = { "{{participantId}}": ctx.get("participantId"), "{{userEmail}}": ctx.get("email"), "{{userId}}": ctx.get("uid"), } # 🧠 Plan planning_result = determine_data_requirements( user_input, company_code, user_id, participant_email=ctx.get("email"), participant_id=ctx.get("participantId"), ) if "error" in planning_result: return jsonify({"reply": f"⚠️ Planning error: {planning_result['error']}"}) # 🛠️ Normalize & replace tokens (schema-aware) normalized_plan = normalize_plan(planning_result, token_map) # 📥 Fetch firestore_data = fetch_data_from_firestore(normalized_plan) # 🧩 Build messages — convert Firestore payload to JSON-safe types system_msg = build_system_message(company_code) safe_ctx = to_jsonable(ctx) safe_data = to_jsonable(firestore_data) data_msg = { "role": "system", "content": ( f"CurrentUserContext: {json.dumps(safe_ctx)}\n" f"Here is the data from Firestore:\n{json.dumps(safe_data)}" ) } user_msg = {"role": "user", "content": user_input} final_response = ask_gpt([system_msg, data_msg, user_msg]) return jsonify({"reply": final_response}) # --- NEW: lightweight Help/Assistant intent router ------------------------- @app.route('/assist', methods=['POST']) def assist(): """ Classifies a short help question and, if appropriate, returns a suggested navigation target + tour key for the frontend to action. Request JSON: { "role": "incubatee" | ..., "message": "Where do I apply?", "companyCode": "ACME", "userId": "abc123", "path": "/current/location" # optional, helps decide replace vs push if you want } Response JSON: { "reply": "bot text to show", "navigate": { "to": "/incubatee/sme", "tour": "apply", # optional "ts": 1712345678901, # force re-run effects "delayMs": 900 # UI should wait before navigating (to show the bot msg) }, "handoff": { "open": false, # whether to auto-open your drawer after nav "botText": "You're on Programs. Tap an 'Apply' button to begin.", "followups": ["track_application","profile_setup","inquiries"] } } """ data = request.json or {} role = data.get('role') user_input = (data.get('message') or '').strip().lower() company_code = data.get('companyCode') user_id = data.get('userId') if not role or not user_input or not company_code or not user_id: return jsonify({"error": "Missing role, message, companyCode, or userId"}), 400 # Simple keyword intent router (deterministic; fast) def nav_payload(to: str, tour: Optional[str], reply: str, handoff: Optional[Dict[str, Any]] = None): return jsonify({ "reply": reply, "navigate": { "to": to, "tour": tour, "ts": int(datetime.now().timestamp() * 1000), "delayMs": 900 }, "handoff": handoff or {} }) tokens = set(user_input.replace('?', '').split()) contains = lambda *words: any(w in tokens or w in user_input for w in words) # 1) Where do I apply? if contains('apply', 'application', 'register', 'program', 'submit'): return nav_payload( to="/incubatee/sme", tour="apply", reply="Taking you to Programs and starting the tour…", handoff={ "open": False, "botText": "You're on Programs. Tap an **Apply** button to begin.", "followups": ["track_application", "profile_setup", "inquiries"] } ) # 2) Track my application if contains('track', 'status', 'progress'): return nav_payload( to="/incubatee/tracker", tour="track", reply="Opening your application tracker…", handoff={ "open": False, "botText": "This is your tracker. You can view statuses and details here.", "followups": ["where_apply", "inquiries"] } ) # 3) Inquiries / helpdesk if contains('inquiry', 'inquiries', 'question', 'helpdesk', 'support', 'ticket'): return nav_payload( to="/incubatee/sme/inquiries", tour="inquiries", reply="Taking you to Inquiries…", handoff={ "open": True, "botText": "Here you can log questions or check replies.", "followups": ["where_apply", "track_application"] } ) # 4) Profile/setup questions if contains('profile', 'setup', 'account', 'redirect'): return jsonify({ "reply": ( "You need to complete your profile before applying. " "If something’s missing (e.g., name/company), we’ll redirect you to finish it first." ), "handoff": { "open": True, "botText": "Would you like to go to **My Profile** now?", "followups": ["where_apply"] } }) # 5) Contact support if contains('contact', 'email', 'help', 'assist'): return jsonify({ "reply": "You can email support at **support@smartincubation.example** or log an inquiry from the Inquiries page.", "handoff": { "open": True, "botText": "Try the **Inquiries** option if you want to log a ticket.", "followups": ["inquiries"] } }) # 6) Fallback → optionally leverage GPT to draft a helpful answer (no redirect) # Keep this light; we're not doing Firestore fetches here. try: sys = { "role": "system", "content": ( "You are Q-Bot, a concise, friendly assistant. " "Clarify the request in one or two sentences and suggest a next step. " "Do NOT mention databases or internal logic." ) } user = {"role": "user", "content": data.get('message', '')} answer = ask_gpt([sys, user]) or "I’m here to help! Could you tell me a bit more about what you need?" return jsonify({ "reply": answer, "handoff": { "open": True, "followups": ["where_apply", "track_application", "inquiries"] } }) except Exception: return jsonify({ "reply": "I’m here to help! Could you tell me a bit more about what you need?", "handoff": { "open": True, "followups": ["where_apply", "track_application", "inquiries"] } }) if __name__ == "__main__": app.run(host="0.0.0.0", port=7860)