Spaces:
Sleeping
Sleeping
| 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 --------------------------------------------------------------- | |
| 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 ------------------------- | |
| 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) | |