murtaza-2007 commited on
Commit
0a1e79f
·
1 Parent(s): 2438b27

Google auth + chat-quality + mobile

Browse files
RAG_Products/api.py CHANGED
@@ -12,7 +12,7 @@ import json
12
  import uuid
13
  from pathlib import Path
14
 
15
- from fastapi import FastAPI, HTTPException, UploadFile, File, Form
16
  from fastapi.middleware.cors import CORSMiddleware
17
  from fastapi.responses import FileResponse
18
  from fastapi.staticfiles import StaticFiles
@@ -114,8 +114,18 @@ def health():
114
  }
115
 
116
 
 
 
 
 
 
 
 
 
 
 
117
  @app.post("/chat")
118
- def chat(req: ChatRequest):
119
  """Single-box product assistant: deterministic answer + recommendations.
120
 
121
  Returns a fixed-shape JSON object (answer, match, results, recommendations,
@@ -126,7 +136,7 @@ def chat(req: ChatRequest):
126
  raise HTTPException(400, "message must not be empty")
127
 
128
  chat_id = req.chat_id or req.session_id or uuid.uuid4().hex
129
- user_id = req.user_id or "default"
130
 
131
  existing = storage.get_chat(chat_id)
132
  context = existing.get("context") if existing else None
@@ -134,13 +144,12 @@ def chat(req: ChatRequest):
134
  result = chat_handle(msg, context=context)
135
 
136
  # carry kit memory forward across turns (kit edits update it; others keep it)
 
137
  if result.get("kit_categories"):
138
- new_context = {
139
- "kit_categories": result.get("kit_categories"),
140
- "kit_budget": result.get("kit_budget"),
141
- }
142
- else:
143
- new_context = context or {}
144
 
145
  storage.append_messages(
146
  chat_id, user_id,
@@ -156,8 +165,9 @@ def chat(req: ChatRequest):
156
  # Chat history (Firestore-backed, local-JSON fallback)
157
  # ---------------------------------------------------------------------------
158
  @app.get("/chats")
159
- def list_chats(user_id: str = "default"):
160
- return {"backend": storage.backend(), "chats": storage.list_chats(user_id)}
 
161
 
162
 
163
  @app.get("/chats/{chat_id}")
 
12
  import uuid
13
  from pathlib import Path
14
 
15
+ from fastapi import FastAPI, HTTPException, UploadFile, File, Form, Header
16
  from fastapi.middleware.cors import CORSMiddleware
17
  from fastapi.responses import FileResponse
18
  from fastapi.staticfiles import StaticFiles
 
114
  }
115
 
116
 
117
+ def _resolve_user(authorization, fallback):
118
+ """Prefer the verified Firebase uid from the Bearer token; else the
119
+ client-supplied id (used in local/no-auth mode)."""
120
+ if authorization and authorization.lower().startswith("bearer "):
121
+ uid = storage.verify_token(authorization[7:].strip())
122
+ if uid:
123
+ return uid
124
+ return fallback or "default"
125
+
126
+
127
  @app.post("/chat")
128
+ def chat(req: ChatRequest, authorization: str | None = Header(None)):
129
  """Single-box product assistant: deterministic answer + recommendations.
130
 
131
  Returns a fixed-shape JSON object (answer, match, results, recommendations,
 
136
  raise HTTPException(400, "message must not be empty")
137
 
138
  chat_id = req.chat_id or req.session_id or uuid.uuid4().hex
139
+ user_id = _resolve_user(authorization, req.user_id)
140
 
141
  existing = storage.get_chat(chat_id)
142
  context = existing.get("context") if existing else None
 
144
  result = chat_handle(msg, context=context)
145
 
146
  # carry kit memory forward across turns (kit edits update it; others keep it)
147
+ new_context = dict(context or {})
148
  if result.get("kit_categories"):
149
+ new_context["kit_categories"] = result.get("kit_categories")
150
+ new_context["kit_budget"] = result.get("kit_budget")
151
+ if result.get("last_category"):
152
+ new_context["last_category"] = result.get("last_category")
 
 
153
 
154
  storage.append_messages(
155
  chat_id, user_id,
 
165
  # Chat history (Firestore-backed, local-JSON fallback)
166
  # ---------------------------------------------------------------------------
167
  @app.get("/chats")
168
+ def list_chats(user_id: str = "default", authorization: str | None = Header(None)):
169
+ uid = _resolve_user(authorization, user_id)
170
+ return {"backend": storage.backend(), "chats": storage.list_chats(uid)}
171
 
172
 
173
  @app.get("/chats/{chat_id}")
RAG_Products/chat.py CHANGED
@@ -128,29 +128,49 @@ def _parse_budget(query):
128
  return None
129
 
130
 
 
 
 
 
 
 
 
 
 
 
 
131
  def detect_intent(query):
132
  q = f" {query.lower()} "
133
  budget = _parse_budget(query)
134
  category = detect_category_in_query(query)
135
 
136
- # 1. Kit / bundle ("vlogging setup under 50k") — needs an explicit kit word
137
- # or a budget, so "best mic for an interview" isn't read as a kit.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
138
  kit = detect_kit(query, budget)
139
  if kit:
140
  return "kit", {"budget": budget, "kit_name": kit[0], "categories": kit[1]}
141
 
142
- # 2. Compatibility ("which batteries fit the Sony A7", "charger for V1")
143
  if is_compatibility_query(query):
144
  return "compatibility", {"budget": budget, "category": category}
145
 
146
- # 3. Use-case ("best mic for outdoor interviews") — only when we can resolve
147
- # a product type to a category AND a use-case word is present.
148
- uc = detect_use_case(query)
149
- if uc:
150
- x_text, keys = uc
151
- uc_cat = detect_category_in_query(x_text) or category
152
- if uc_cat and keys:
153
- return "use_case", {"budget": budget, "category": uc_cat, "use_cases": keys}
154
 
155
  if re.search(r"\b(vs|versus)\b|\bcompare\b", q):
156
  return "compare", {"budget": budget, "category": category}
@@ -262,6 +282,17 @@ def handle(query, context=None):
262
  if fu:
263
  return _handle_kit_followup(query, fu, context)
264
 
 
 
 
 
 
 
 
 
 
 
 
265
  intent, ent = detect_intent(query)
266
  budget, category = ent.get("budget"), ent.get("category")
267
 
@@ -597,14 +628,19 @@ def _handle_kit_followup(query, fu, context):
597
  def _handle_use_case(query, ent):
598
  category = ent["category"]
599
  keys = ent["use_cases"]
600
- ranked = rank_for_use_case(category, keys, get_catalog(), k=SIMILAR_K)
 
 
 
 
601
  if not ranked:
 
602
  return _empty(query, "use_case",
603
- f"I couldn't find {category.lower()} for that use case.")
604
  cards = [product_card(d) for d in ranked]
605
  uc_txt = " / ".join(keys)
606
- answer = (f"Best {category.lower()} for {uc_txt}, "
607
- f"ranked for that use case.")
608
  return _finalize(query, "use_case", answer, cards[0], cards[1:], [],
609
  confidence=1.0)
610
 
@@ -632,6 +668,8 @@ def _finalize(query, intent, answer, match, results, recos, confidence,
632
  for kb in (kits or []):
633
  cards += kb.get("items", [])
634
  ok, phantom = verify_prices(answer, cards, extra_allowed=extra_allowed)
 
 
635
  return {
636
  "query": query,
637
  "intent": intent,
@@ -644,6 +682,7 @@ def _finalize(query, intent, answer, match, results, recos, confidence,
644
  "verified": ok,
645
  "phantom_numbers": phantom,
646
  "note": note,
 
647
  }
648
 
649
 
 
128
  return None
129
 
130
 
131
+ def _is_bare_budget(query):
132
+ """True if the query is essentially only a price refinement ("under 10k",
133
+ "below 5000", "cheaper ones") with no product/category of its own."""
134
+ q = query.lower()
135
+ stripped = re.sub(
136
+ r"\b(under|below|less than|upto|up to|within|around|over|above|cheaper|"
137
+ r"cheapest|budget|only|just|show|me|the|a|an|ones?|options?|something|"
138
+ r"for|rs|rupees|price)\b|\d[\d,]*\s*k?|₹|,", " ", q)
139
+ return stripped.strip() == ""
140
+
141
+
142
  def detect_intent(query):
143
  q = f" {query.lower()} "
144
  budget = _parse_budget(query)
145
  category = detect_category_in_query(query)
146
 
147
+ has_kit_word = bool(re.search(
148
+ r"\b(kit|set ?up|bundle|package|gear|rig|build me|everything)\b", q))
149
+
150
+ # 1. Explicit "best <product> for <use-case>" beats kit detection, so
151
+ # "best mic for indoor interviews under 10k" is a budgeted MIC search,
152
+ # not an interview kit. Requires a resolvable product category, the
153
+ # "for" structure, and NO explicit kit word.
154
+ uc = detect_use_case(query)
155
+ uc_cat = None
156
+ if uc:
157
+ x_text, uc_keys = uc
158
+ uc_cat = detect_category_in_query(x_text) or category
159
+ if uc and uc_cat and uc_keys and not has_kit_word and re.search(r"\bfor\b|\bbest\b", q):
160
+ return "use_case", {"budget": budget, "category": uc_cat, "use_cases": uc_keys}
161
+
162
+ # 2. Kit / bundle ("vlogging setup under 50k") — explicit kit word or budget.
163
  kit = detect_kit(query, budget)
164
  if kit:
165
  return "kit", {"budget": budget, "kit_name": kit[0], "categories": kit[1]}
166
 
167
+ # 3. Compatibility ("which batteries fit the Sony A7", "charger for V1")
168
  if is_compatibility_query(query):
169
  return "compatibility", {"budget": budget, "category": category}
170
 
171
+ # 4. Looser use-case fallback (e.g. "studio lighting") when category resolves.
172
+ if uc and uc_cat and uc_keys:
173
+ return "use_case", {"budget": budget, "category": uc_cat, "use_cases": uc_keys}
 
 
 
 
 
174
 
175
  if re.search(r"\b(vs|versus)\b|\bcompare\b", q):
176
  return "compare", {"budget": budget, "category": category}
 
282
  if fu:
283
  return _handle_kit_followup(query, fu, context)
284
 
285
+ # ---- bare budget refinement: "under 10k" after "best mic ..." keeps mics ----
286
+ if context and context.get("last_category") and _is_bare_budget(query):
287
+ b = _parse_budget(query)
288
+ if b:
289
+ cat = context["last_category"]
290
+ results = _browse(cat, b)
291
+ if results:
292
+ answer = f"Top {len(results)} {cat.lower()} under {_money(b)}, best value first."
293
+ return _finalize(query, "budget_browse", answer, None, results,
294
+ results[:SIMILAR_K], confidence=1.0)
295
+
296
  intent, ent = detect_intent(query)
297
  budget, category = ent.get("budget"), ent.get("category")
298
 
 
628
  def _handle_use_case(query, ent):
629
  category = ent["category"]
630
  keys = ent["use_cases"]
631
+ budget = ent.get("budget")
632
+ ranked = rank_for_use_case(category, keys, get_catalog(), k=SIMILAR_K * 3)
633
+ if budget: # honor "best mic for interviews UNDER 10k"
634
+ ranked = [d for d in ranked if (d.metadata.get("price") or 1e12) <= budget]
635
+ ranked = ranked[:SIMILAR_K]
636
  if not ranked:
637
+ within = f" under {_money(budget)}" if budget else ""
638
  return _empty(query, "use_case",
639
+ f"I couldn't find {category.lower()} for that use case{within}.")
640
  cards = [product_card(d) for d in ranked]
641
  uc_txt = " / ".join(keys)
642
+ within = f" under {_money(budget)}" if budget else ""
643
+ answer = f"Best {category.lower()} for {uc_txt}{within}, ranked for that use case."
644
  return _finalize(query, "use_case", answer, cards[0], cards[1:], [],
645
  confidence=1.0)
646
 
 
668
  for kb in (kits or []):
669
  cards += kb.get("items", [])
670
  ok, phantom = verify_prices(answer, cards, extra_allowed=extra_allowed)
671
+ # remember the topic category so a bare "under 10k" follow-up stays on topic
672
+ topic = match or (results[0] if results else (recos[0] if recos else None))
673
  return {
674
  "query": query,
675
  "intent": intent,
 
682
  "verified": ok,
683
  "phantom_numbers": phantom,
684
  "note": note,
685
+ "last_category": topic.get("category") if topic else None,
686
  }
687
 
688
 
RAG_Products/config.py CHANGED
@@ -79,4 +79,5 @@ CONFIDENCE_THRESHOLD = 0.45
79
  W_SEMANTIC = 0.60 # weight of vector similarity
80
  W_BRAND = 0.20 # bonus if same brand
81
  W_PRICE = 0.20 # bonus for price proximity
82
- PRICE_BAND = 0.40 # +/- 40% price window counts as "close"
 
 
79
  W_SEMANTIC = 0.60 # weight of vector similarity
80
  W_BRAND = 0.20 # bonus if same brand
81
  W_PRICE = 0.20 # bonus for price proximity
82
+ PRICE_BAND = 0.40 # +/- 40% price window counts as "close
83
+
RAG_Products/static/.gitignore ADDED
@@ -0,0 +1 @@
 
 
1
+ .vercel
RAG_Products/static/config.js CHANGED
@@ -1,6 +1,15 @@
1
- // API base URL for the AHV Assistant frontend.
2
- //
3
  // • Leave EMPTY ("") when the backend serves this page (same origin / local).
4
- // • On Vercel, set this to your Hugging Face Space URL, e.g.
5
- // window.AHV_API = "https://your-name-ahv-assistant.hf.space";
6
- window.AHV_API = "";
 
 
 
 
 
 
 
 
 
 
 
1
+ // ---- Backend API base ----
 
2
  // • Leave EMPTY ("") when the backend serves this page (same origin / local).
3
+ // • On Vercel, set this to your Hugging Face Space URL.
4
+ window.AHV_API = "https://mvali77-ahv.hf.space";
5
+
6
+ // ---- Firebase web config (for Google sign-in) ----
7
+ // Get this from Firebase Console → Project settings → General →
8
+ // "Your apps" → Web app → SDK setup and configuration → "Config".
9
+ // Leave apiKey empty to disable sign-in (anonymous per-browser mode).
10
+ window.FIREBASE_CONFIG = {
11
+ apiKey: "",
12
+ authDomain: "ahv-assistant.firebaseapp.com",
13
+ projectId: "ahv-assistant",
14
+ appId: "1:201860800907:web:26114586af1b4a42704f8e"
15
+ };
RAG_Products/static/index.html CHANGED
@@ -14,7 +14,7 @@
14
  html,body { height:100%; margin:0; }
15
  body { font-family:"Inter",-apple-system,"Segoe UI",Roboto,sans-serif;
16
  color:var(--ink); background:var(--bg); font-size:15px; line-height:1.55; }
17
- .app { display:flex; height:100vh; }
18
 
19
  /* sidebar */
20
  .sidebar { width:264px; flex-shrink:0; background:var(--side); border-right:1px solid var(--line);
@@ -38,6 +38,18 @@
38
  .side-foot { border-top:1px solid var(--line); padding:12px 14px; display:flex; flex-direction:column; gap:8px; }
39
  .side-foot a { font-size:13px; color:var(--muted); text-decoration:none; cursor:pointer; }
40
  .side-foot a:hover { color:var(--ink); }
 
 
 
 
 
 
 
 
 
 
 
 
41
  .status { font-size:11.5px; color:var(--muted); display:flex; align-items:center; gap:6px; margin-top:2px; }
42
  .dot { width:7px; height:7px; border-radius:50%; background:#d4d4d8; }
43
  .dot.up { background:var(--good); } .dot.down { background:#dc2626; }
@@ -136,17 +148,42 @@
136
  .toast.ok { background:#f0fdf4; color:#166534; border:1px solid #bbf7d0; }
137
  .toast.err { background:#fef2f2; color:#991b1b; border:1px solid #fecaca; }
138
  .hidden { display:none; }
139
- @media (max-width:640px){ .sidebar{display:none;} .rec-grid,.kit-items{grid-template-columns:1fr;} }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
140
  </style>
141
  </head>
142
 
143
  <body>
144
  <div class="app">
 
145
  <aside class="sidebar">
146
  <div class="side-head"><div class="logo">A</div>AHV <span>Assistant</span></div>
147
  <button class="new-btn" id="newChat">+ New chat</button>
148
  <div class="chat-list" id="chatList"></div>
149
  <div class="side-foot">
 
150
  <a id="addProducts">+ Add products</a>
151
  <a href="dashboard.html">Owner dashboard →</a>
152
  <div class="status" id="status"><span class="dot"></span>connecting…</div>
@@ -154,6 +191,10 @@
154
  </aside>
155
 
156
  <main class="main">
 
 
 
 
157
  <div class="scroll" id="scroll">
158
  <div class="thread" id="thread"></div>
159
  </div>
@@ -208,6 +249,8 @@
208
  </div>
209
  </div>
210
 
 
 
211
  <script src="config.js"></script>
212
  <script>
213
  const $ = s => document.querySelector(s);
@@ -218,11 +261,16 @@
218
  const api = p => API_BASE + p;
219
 
220
  // identity + active chat (persist across reloads)
221
- const USER_ID = localStorage.getItem('ahv_user') || (() => {
222
  const u = (crypto.randomUUID ? crypto.randomUUID() : 'u' + Date.now()); localStorage.setItem('ahv_user', u); return u;
223
  })();
224
  let activeChat = localStorage.getItem('ahv_active') || null;
225
 
 
 
 
 
 
226
  /* ---------- rendering ---------- */
227
  function priceBlock(c) {
228
  let h = `<div class="price"><div class="buy">${peso(c.best_buy)}</div>`;
@@ -292,7 +340,7 @@
292
  /* ---------- chat history ---------- */
293
  async function loadChats() {
294
  try {
295
- const d = await (await fetch(api('/chats?user_id=' + encodeURIComponent(USER_ID)))).json();
296
  const list = $('#chatList'); list.innerHTML = '';
297
  d.chats.forEach(c => {
298
  const el = document.createElement('div');
@@ -306,17 +354,24 @@
306
  }
307
  async function openChat(id) {
308
  activeChat = id; localStorage.setItem('ahv_active', id);
309
- const chat = await (await fetch(api('/chats/' + id))).json();
310
  thread.innerHTML = '';
311
  (chat.messages || []).forEach(m => { if (m.role === 'user') addUser(m.text); else if (m.data) addBot(m.data); });
312
- loadChats(); scroll.scrollTop = scroll.scrollHeight; box.focus();
313
  }
314
  async function deleteChat(id) {
315
- await fetch(api('/chats/' + id), { method: 'DELETE' });
316
  if (id === activeChat) { activeChat = null; localStorage.removeItem('ahv_active'); showIntro(); }
317
  loadChats();
318
  }
319
- $('#newChat').onclick = () => { activeChat = null; localStorage.removeItem('ahv_active'); showIntro(); loadChats(); box.focus(); };
 
 
 
 
 
 
 
320
 
321
  /* ---------- sending ---------- */
322
  async function ask(text) {
@@ -327,8 +382,9 @@
327
  loading.innerHTML = `<div class="answer" style="color:var(--muted)">Looking…</div>`;
328
  thread.appendChild(loading); scroll.scrollTop = scroll.scrollHeight;
329
  try {
 
330
  const res = await fetch(api('/chat'), {
331
- method: 'POST', headers: { 'Content-Type': 'application/json' },
332
  body: JSON.stringify({ message: text, chat_id: activeChat, user_id: USER_ID })
333
  });
334
  const data = await res.json(); loading.remove();
@@ -426,10 +482,54 @@
426
  $('#ingestBtn').disabled = false;
427
  };
428
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
429
  /* ---------- boot ---------- */
430
- health(); loadChats();
431
- if (activeChat) openChat(activeChat).catch(showIntro); else showIntro();
432
- box.focus();
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
433
  </script>
434
  </body>
435
 
 
14
  html,body { height:100%; margin:0; }
15
  body { font-family:"Inter",-apple-system,"Segoe UI",Roboto,sans-serif;
16
  color:var(--ink); background:var(--bg); font-size:15px; line-height:1.55; }
17
+ .app { display:flex; height:100vh; height:100dvh; }
18
 
19
  /* sidebar */
20
  .sidebar { width:264px; flex-shrink:0; background:var(--side); border-right:1px solid var(--line);
 
38
  .side-foot { border-top:1px solid var(--line); padding:12px 14px; display:flex; flex-direction:column; gap:8px; }
39
  .side-foot a { font-size:13px; color:var(--muted); text-decoration:none; cursor:pointer; }
40
  .side-foot a:hover { color:var(--ink); }
41
+ .signin-btn { display:flex; align-items:center; justify-content:center; gap:8px; width:100%;
42
+ padding:9px 12px; border:1px solid var(--line); background:#fff; border-radius:9px;
43
+ cursor:pointer; font-size:13.5px; font-family:inherit; color:var(--ink); }
44
+ .signin-btn:hover { border-color:var(--ink); }
45
+ .signin-btn svg { width:16px; height:16px; }
46
+ .user-row { display:flex; align-items:center; gap:8px; font-size:13px; }
47
+ .user-row img { width:26px; height:26px; border-radius:50%; }
48
+ .user-row .uname { flex:1; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
49
+ .user-row .signout { font-size:12px; color:var(--muted); cursor:pointer; background:none; border:none; }
50
+ .user-row .signout:hover { color:#dc2626; }
51
+ .gate { text-align:center; color:var(--muted); margin-top:10vh; }
52
+ .gate h2 { color:var(--ink); font-weight:600; font-size:20px; margin:0 0 8px; }
53
  .status { font-size:11.5px; color:var(--muted); display:flex; align-items:center; gap:6px; margin-top:2px; }
54
  .dot { width:7px; height:7px; border-radius:50%; background:#d4d4d8; }
55
  .dot.up { background:var(--good); } .dot.down { background:#dc2626; }
 
148
  .toast.ok { background:#f0fdf4; color:#166534; border:1px solid #bbf7d0; }
149
  .toast.err { background:#fef2f2; color:#991b1b; border:1px solid #fecaca; }
150
  .hidden { display:none; }
151
+ /* mobile top bar (hamburger) + drawer overlay */
152
+ .mobile-bar { display:none; align-items:center; gap:12px; padding:11px 16px; border-bottom:1px solid var(--line); }
153
+ .mobile-bar button { background:none; border:none; font-size:22px; cursor:pointer; line-height:1; color:var(--ink); padding:0; }
154
+ .mobile-bar .mb-title { font-weight:600; font-size:15px; }
155
+ .side-overlay { display:none; position:fixed; inset:0; background:rgba(0,0,0,.35); z-index:40; }
156
+ @media (max-width:640px){
157
+ .sidebar { position:fixed; left:0; top:0; bottom:0; z-index:50; transform:translateX(-100%); transition:transform .2s ease; box-shadow:2px 0 16px rgba(0,0,0,.12); }
158
+ .sidebar.open { transform:translateX(0); }
159
+ .side-overlay.open { display:block; }
160
+ .mobile-bar { display:flex; }
161
+ .rec-grid, .kit-items { grid-template-columns:1fr; }
162
+ .thread { padding:18px 14px 32px; gap:18px; }
163
+ .composer { padding:10px 12px 4px; }
164
+ .composer input { font-size:16px; padding:12px 14px; } /* 16px stops iOS zoom-on-focus */
165
+ .composer button { padding:0 16px; }
166
+ .hint { padding:4px 12px 10px; font-size:11px; }
167
+ .msg-user { max-width:88%; }
168
+ .card { padding:14px 14px; }
169
+ .card .price .buy { font-size:18px; }
170
+ .kit { padding:12px 13px; }
171
+ .modal { width:94vw; padding:18px 16px; max-height:90dvh; }
172
+ .field input, .field select { font-size:16px; }
173
+ .mb-title { flex:1; }
174
+ }
175
  </style>
176
  </head>
177
 
178
  <body>
179
  <div class="app">
180
+ <div class="side-overlay" id="sideOverlay"></div>
181
  <aside class="sidebar">
182
  <div class="side-head"><div class="logo">A</div>AHV <span>Assistant</span></div>
183
  <button class="new-btn" id="newChat">+ New chat</button>
184
  <div class="chat-list" id="chatList"></div>
185
  <div class="side-foot">
186
+ <div id="authBox"></div>
187
  <a id="addProducts">+ Add products</a>
188
  <a href="dashboard.html">Owner dashboard →</a>
189
  <div class="status" id="status"><span class="dot"></span>connecting…</div>
 
191
  </aside>
192
 
193
  <main class="main">
194
+ <div class="mobile-bar">
195
+ <button id="menuBtn" aria-label="Menu">☰</button>
196
+ <span class="mb-title">AHV Assistant</span>
197
+ </div>
198
  <div class="scroll" id="scroll">
199
  <div class="thread" id="thread"></div>
200
  </div>
 
249
  </div>
250
  </div>
251
 
252
+ <script src="https://www.gstatic.com/firebasejs/10.12.2/firebase-app-compat.js"></script>
253
+ <script src="https://www.gstatic.com/firebasejs/10.12.2/firebase-auth-compat.js"></script>
254
  <script src="config.js"></script>
255
  <script>
256
  const $ = s => document.querySelector(s);
 
261
  const api = p => API_BASE + p;
262
 
263
  // identity + active chat (persist across reloads)
264
+ let USER_ID = localStorage.getItem('ahv_user') || (() => {
265
  const u = (crypto.randomUUID ? crypto.randomUUID() : 'u' + Date.now()); localStorage.setItem('ahv_user', u); return u;
266
  })();
267
  let activeChat = localStorage.getItem('ahv_active') || null;
268
 
269
+ // Firebase auth (Google sign-in). Enabled only when FIREBASE_CONFIG.apiKey is set.
270
+ const AUTH_ENABLED = !!(window.FIREBASE_CONFIG && window.FIREBASE_CONFIG.apiKey);
271
+ let fbAuth = null, authUser = null, idToken = null;
272
+ function authHeaders(base) { const h = base || {}; if (idToken) h['Authorization'] = 'Bearer ' + idToken; return h; }
273
+
274
  /* ---------- rendering ---------- */
275
  function priceBlock(c) {
276
  let h = `<div class="price"><div class="buy">${peso(c.best_buy)}</div>`;
 
340
  /* ---------- chat history ---------- */
341
  async function loadChats() {
342
  try {
343
+ const d = await (await fetch(api('/chats?user_id=' + encodeURIComponent(USER_ID)), { headers: authHeaders() })).json();
344
  const list = $('#chatList'); list.innerHTML = '';
345
  d.chats.forEach(c => {
346
  const el = document.createElement('div');
 
354
  }
355
  async function openChat(id) {
356
  activeChat = id; localStorage.setItem('ahv_active', id);
357
+ const chat = await (await fetch(api('/chats/' + id), { headers: authHeaders() })).json();
358
  thread.innerHTML = '';
359
  (chat.messages || []).forEach(m => { if (m.role === 'user') addUser(m.text); else if (m.data) addBot(m.data); });
360
+ loadChats(); closeDrawer(); scroll.scrollTop = scroll.scrollHeight; box.focus();
361
  }
362
  async function deleteChat(id) {
363
+ await fetch(api('/chats/' + id), { method: 'DELETE', headers: authHeaders() });
364
  if (id === activeChat) { activeChat = null; localStorage.removeItem('ahv_active'); showIntro(); }
365
  loadChats();
366
  }
367
+ $('#newChat').onclick = () => { activeChat = null; localStorage.removeItem('ahv_active'); showIntro(); loadChats(); closeDrawer(); box.focus(); };
368
+
369
+ /* ---------- mobile drawer ---------- */
370
+ const sidebarEl = document.querySelector('.sidebar'), sideOverlay = $('#sideOverlay');
371
+ function setDrawer(open) { sidebarEl.classList.toggle('open', open); sideOverlay.classList.toggle('open', open); }
372
+ function closeDrawer() { setDrawer(false); }
373
+ $('#menuBtn').onclick = () => setDrawer(!sidebarEl.classList.contains('open'));
374
+ sideOverlay.onclick = closeDrawer;
375
 
376
  /* ---------- sending ---------- */
377
  async function ask(text) {
 
382
  loading.innerHTML = `<div class="answer" style="color:var(--muted)">Looking…</div>`;
383
  thread.appendChild(loading); scroll.scrollTop = scroll.scrollHeight;
384
  try {
385
+ if (authUser) { try { idToken = await authUser.getIdToken(); } catch (e) {} }
386
  const res = await fetch(api('/chat'), {
387
+ method: 'POST', headers: authHeaders({ 'Content-Type': 'application/json' }),
388
  body: JSON.stringify({ message: text, chat_id: activeChat, user_id: USER_ID })
389
  });
390
  const data = await res.json(); loading.remove();
 
482
  $('#ingestBtn').disabled = false;
483
  };
484
 
485
+ /* ---------- auth ---------- */
486
+ const GOOGLE_SVG = '<svg viewBox="0 0 48 48"><path fill="#EA4335" d="M24 9.5c3.5 0 6.6 1.2 9 3.6l6.7-6.7C35.6 2.6 30.2 0 24 0 14.6 0 6.5 5.4 2.6 13.2l7.8 6.1C12.2 13.2 17.6 9.5 24 9.5z"/><path fill="#4285F4" d="M46.1 24.5c0-1.6-.1-3.1-.4-4.5H24v9h12.4c-.5 2.9-2.1 5.3-4.5 7l7 5.4c4.1-3.8 6.5-9.4 6.5-16.9z"/><path fill="#FBBC05" d="M10.4 28.3c-.5-1.4-.8-2.9-.8-4.3s.3-3 .8-4.3l-7.8-6.1C.9 16.5 0 20.1 0 24s.9 7.5 2.6 10.4l7.8-6.1z"/><path fill="#34A853" d="M24 48c6.2 0 11.5-2 15.3-5.5l-7-5.4c-2 1.4-4.6 2.2-8.3 2.2-6.4 0-11.8-3.7-13.6-9l-7.8 6.1C6.5 42.6 14.6 48 24 48z"/></svg>';
487
+ function gateSign() {
488
+ thread.innerHTML = `<div class="gate"><h2>Welcome to AHV Assistant</h2>
489
+ <div>Sign in to start chatting and keep your history on any device.</div></div>`;
490
+ box.disabled = true; send.disabled = true; box.placeholder = 'Sign in to chat…';
491
+ }
492
+ function renderAuth(user) {
493
+ const el = $('#authBox');
494
+ if (user) {
495
+ el.innerHTML = `<div class="user-row">
496
+ ${user.photoURL ? `<img src="${user.photoURL}" alt="">` : ''}
497
+ <span class="uname">${user.displayName || user.email}</span>
498
+ <button class="signout" id="signOut">Sign out</button></div>`;
499
+ $('#signOut').onclick = () => fbAuth.signOut();
500
+ box.disabled = false; send.disabled = false; box.placeholder = 'Message AHV Assistant…';
501
+ } else if (AUTH_ENABLED) {
502
+ el.innerHTML = `<button class="signin-btn" id="signIn">${GOOGLE_SVG} Sign in with Google</button>`;
503
+ $('#signIn').onclick = () => fbAuth.signInWithPopup(new firebase.auth.GoogleAuthProvider());
504
+ } else {
505
+ el.innerHTML = ''; // anonymous mode: no auth UI
506
+ }
507
+ }
508
+
509
  /* ---------- boot ---------- */
510
+ health();
511
+ if (AUTH_ENABLED) {
512
+ firebase.initializeApp(window.FIREBASE_CONFIG);
513
+ fbAuth = firebase.auth();
514
+ gateSign(); renderAuth(null);
515
+ fbAuth.onAuthStateChanged(async (user) => {
516
+ authUser = user;
517
+ if (user) {
518
+ USER_ID = user.uid;
519
+ try { idToken = await user.getIdToken(); } catch (e) {}
520
+ renderAuth(user); loadChats();
521
+ if (activeChat) openChat(activeChat).catch(showIntro); else showIntro();
522
+ } else {
523
+ idToken = null; activeChat = null; localStorage.removeItem('ahv_active');
524
+ $('#chatList').innerHTML = ''; renderAuth(null); gateSign();
525
+ }
526
+ });
527
+ } else {
528
+ // anonymous per-browser mode (local / no Firebase config)
529
+ renderAuth(null); loadChats();
530
+ if (activeChat) openChat(activeChat).catch(showIntro); else showIntro();
531
+ box.focus();
532
+ }
533
  </script>
534
  </body>
535
 
RAG_Products/storage.py CHANGED
@@ -66,6 +66,23 @@ def backend() -> str:
66
  return _backend
67
 
68
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
69
  # ---------------------------------------------------------------------------
70
  # Local-file helpers
71
  # ---------------------------------------------------------------------------
 
66
  return _backend
67
 
68
 
69
+ def verify_token(id_token):
70
+ """Verify a Firebase ID token and return its uid, or None if invalid.
71
+
72
+ Returns None when Firestore/Auth isn't configured (local mode) so callers
73
+ can fall back to a client-supplied user_id.
74
+ """
75
+ _init()
76
+ if _backend != "firestore" or not id_token:
77
+ return None
78
+ try:
79
+ from firebase_admin import auth
80
+
81
+ return auth.verify_id_token(id_token).get("uid")
82
+ except Exception:
83
+ return None
84
+
85
+
86
  # ---------------------------------------------------------------------------
87
  # Local-file helpers
88
  # ---------------------------------------------------------------------------