File size: 10,597 Bytes
0b10b26
ad2bd73
 
d2d8063
 
ad2bd73
0b10b26
 
b643904
d2d8063
ad2bd73
1ce5fa1
ac1b7f1
4c67bdd
0b10b26
 
b643904
0b10b26
 
 
d2d8063
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1ce5fa1
a97fbfc
 
4c67bdd
 
ac1b7f1
4c67bdd
d2d8063
a97fbfc
d2d8063
 
4c67bdd
 
 
1ce5fa1
4c67bdd
 
d2d8063
4c67bdd
 
 
 
d2d8063
4c67bdd
a97fbfc
 
 
d2d8063
a97fbfc
 
 
4c67bdd
 
1ce5fa1
d2d8063
1ce5fa1
0b10b26
 
 
 
 
b643904
ac1b7f1
cb3ff8c
c50af6f
 
cb3ff8c
d2d8063
ac1b7f1
 
 
 
 
 
b643904
ac1b7f1
 
 
 
 
 
ad2bd73
 
b643904
d2d8063
ac1b7f1
1ce5fa1
ac1b7f1
 
0b10b26
d2d8063
b643904
d2d8063
 
 
b643904
 
ac1b7f1
d2d8063
 
ac1b7f1
0b10b26
ac1b7f1
d2d8063
b643904
ac1b7f1
0b10b26
47405f2
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1ce5fa1
0b10b26
 
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
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)