murtaza-2007 commited on
Commit ·
0a1e79f
1
Parent(s): 2438b27
Google auth + chat-quality + mobile
Browse files- RAG_Products/api.py +21 -11
- RAG_Products/chat.py +54 -15
- RAG_Products/config.py +2 -1
- RAG_Products/static/.gitignore +1 -0
- RAG_Products/static/config.js +14 -5
- RAG_Products/static/index.html +112 -12
- RAG_Products/storage.py +17 -0
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
|
| 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 |
-
|
| 140 |
-
|
| 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 |
-
|
|
|
|
| 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 |
-
|
| 137 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 138 |
kit = detect_kit(query, budget)
|
| 139 |
if kit:
|
| 140 |
return "kit", {"budget": budget, "kit_name": kit[0], "categories": kit[1]}
|
| 141 |
|
| 142 |
-
#
|
| 143 |
if is_compatibility_query(query):
|
| 144 |
return "compatibility", {"budget": budget, "category": category}
|
| 145 |
|
| 146 |
-
#
|
| 147 |
-
|
| 148 |
-
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 607 |
-
|
| 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
|
| 2 |
-
//
|
| 3 |
// • Leave EMPTY ("") when the backend serves this page (same origin / local).
|
| 4 |
-
// • On Vercel, set this to your Hugging Face Space URL
|
| 5 |
-
|
| 6 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 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();
|
| 431 |
-
if (
|
| 432 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
# ---------------------------------------------------------------------------
|