Spaces:
Sleeping
Sleeping
| """ | |
| src/app.py — Main Flask Application | |
| Fixes applied: #9 (auth + history), #10 (comparison), all routes | |
| """ | |
| import os | |
| import io | |
| import csv | |
| import secrets | |
| from flask import Flask, render_template, request, jsonify, redirect, url_for, session, send_file | |
| from flask_login import LoginManager, login_required, login_user, logout_user, current_user | |
| from flask_cors import CORS | |
| from src.database.db import Database | |
| from src.auth.auth import User | |
| app = Flask(__name__, template_folder="../templates", static_folder="../static") | |
| app.secret_key = os.environ.get("SECRET_KEY", secrets.token_hex(32)) | |
| # ── CORS for the Chrome extension ──────────────────────────────────────────── | |
| # Allows chrome-extension:// origins to call /api/extension/* endpoints | |
| CORS(app, resources={r"/api/extension/*": {"origins": "*"}}) | |
| # ── Flask-Login ────────────────────────────────────────────────────────────── | |
| login_manager = LoginManager() | |
| login_manager.init_app(app) | |
| login_manager.login_view = "login" | |
| # ── Lazy singletons (avoids loading 2GB of models at import time) ──────────── | |
| _db = _qa = _gen = _sentiment = _router = _rag = _scraper = _compare = None | |
| def get_db(): | |
| global _db | |
| if _db is None: | |
| _db = Database() | |
| _db.init_tables() | |
| return _db | |
| def get_models(): | |
| global _qa, _gen, _sentiment, _router, _rag | |
| if _qa is None: | |
| from src.models.qa_model import QAModel | |
| from src.models.generative_model import GenerativeModel | |
| from src.models.sentiment_model import SentimentModel | |
| from src.models.intent_router import IntentRouter | |
| from src.rag.rag_pipeline import RAGPipeline | |
| _qa = QAModel() | |
| _gen = GenerativeModel() | |
| _sentiment = SentimentModel() | |
| _router = IntentRouter() | |
| _rag = RAGPipeline() | |
| return _qa, _gen, _sentiment, _router, _rag | |
| def get_scraper(): | |
| global _scraper | |
| if _scraper is None: | |
| from src.scraper.scraper import Scraper | |
| _scraper = Scraper(db=get_db()) | |
| return _scraper | |
| def get_compare(): | |
| global _compare | |
| if _compare is None: | |
| from src.comparison.compare import ComparisonEngine | |
| qa, gen, sentiment, router, rag = get_models() | |
| _compare = ComparisonEngine( | |
| qa_model=qa, gen_model=gen, sentiment_model=sentiment, | |
| intent_router=router, rag_pipeline=rag, scraper=get_scraper() | |
| ) | |
| return _compare | |
| def load_user(user_id): | |
| return User.get(user_id, get_db()) | |
| # ── Smart hybrid pipeline (Upgrades #1, #2, #5) + answer cache ─────────────── | |
| import hashlib | |
| from collections import OrderedDict | |
| _answer_cache: "OrderedDict[str, dict]" = OrderedDict() | |
| ANSWER_CACHE_SIZE = 256 | |
| def _cache_key(question: str, context: str) -> str: | |
| """SHA-256 of (question + first 2k chars of context). Same Q on same product → cache hit.""" | |
| blob = (question.strip().lower() + "||" + context[:2000]).encode("utf-8", "ignore") | |
| return hashlib.sha256(blob).hexdigest()[:32] | |
| def run_smart_qa(question: str, context: str, qa, gen, rag) -> dict: | |
| """ | |
| Always-on hybrid pipeline: | |
| 1. Retrieve top-K RAG chunks | |
| 2. Run BERT for the exact extractive span (with confidence) | |
| 3. Run Flan-T5 in either 'enrich' mode (BERT confident) or 'answer' mode (BERT unsure) | |
| 4. Return both, plus a 'best_answer' chosen intelligently | |
| Cached by (question + context-prefix) so repeats are instant. | |
| """ | |
| key = _cache_key(question, context) | |
| if key in _answer_cache: | |
| # LRU bump | |
| _answer_cache.move_to_end(key) | |
| return _answer_cache[key] | |
| rag_ctx = rag.get_relevant_context(question, context) | |
| qa_result = qa.answer(question, rag_ctx) | |
| score = qa_result.get("confidence_score", 0.0) | |
| span = qa_result.get("answer_span", "") or qa_result.get("answer", "") | |
| if score >= 0.40 and span and span.lower() != "the answer could not be found in the provided text.": | |
| # BERT confident → enrich with generative explanation | |
| try: | |
| generative_text = gen.answer(question, rag_ctx, mode="enrich") | |
| except Exception: | |
| generative_text = "" | |
| # Prefer the generative if it's substantially longer and non-empty; else keep extractive | |
| if generative_text and len(generative_text) > len(qa_result["answer"]) * 1.3: | |
| qa_result["answer"] = generative_text | |
| qa_result["source"] = "hybrid" # BERT-anchored, Flan-T5-explained | |
| else: | |
| qa_result["source"] = "extractive" | |
| qa_result["generative_text"] = generative_text | |
| else: | |
| # BERT unsure → use Flan-T5 as primary | |
| try: | |
| generative_text = gen.answer(question, rag_ctx, mode="answer") | |
| except Exception: | |
| generative_text = "" | |
| qa_result.update({ | |
| "answer": generative_text or qa_result["answer"], | |
| "source": "generative", | |
| "confidence_label": "Generated (BERT confidence low)", | |
| "generative_text": generative_text, | |
| }) | |
| # Save to cache | |
| _answer_cache[key] = qa_result | |
| if len(_answer_cache) > ANSWER_CACHE_SIZE: | |
| _answer_cache.popitem(last=False) | |
| return qa_result | |
| # ── Pages ──────────────────────────────────────────────────────────────────── | |
| def index(): | |
| session.setdefault("session_id", secrets.token_hex(16)) | |
| return render_template("index.html") | |
| def compare_page(): | |
| return render_template("compare.html") | |
| def history(): | |
| queries = get_db().get_user_history(current_user.id) | |
| return render_template("history.html", queries=queries) | |
| # ── API ────────────────────────────────────────────────────────────────────── | |
| def scrape(): | |
| data = request.get_json(force=True) | |
| url = data.get("url", "").strip() | |
| if not url: | |
| return jsonify({"error": "URL is required"}), 400 | |
| try: | |
| text, source = get_scraper().scrape(url) | |
| if not text: | |
| return jsonify({"error": "Could not extract text from this URL. Try pasting the text manually."}), 400 | |
| return jsonify({"text": text, "source": source, "char_count": len(text)}) | |
| except Exception as e: | |
| return jsonify({"error": str(e)}), 500 | |
| def ask(): | |
| data = request.get_json(force=True) | |
| question = data.get("question", "").strip() | |
| context = data.get("context", "").strip() | |
| if not question or not context: | |
| return jsonify({"error": "Both question and context are required"}), 400 | |
| try: | |
| qa, gen, sentiment, router, rag = get_models() | |
| intent = router.classify(question) | |
| result = {"intent": intent, "question": question} | |
| if intent in ("factual", "hybrid"): | |
| qa_result = run_smart_qa(question, context, qa, gen, rag) | |
| result["qa"] = qa_result | |
| if intent in ("subjective", "hybrid"): | |
| result["sentiment"] = sentiment.analyze(context, question) | |
| # Persist query — Fix #9 | |
| user_id = current_user.id if current_user.is_authenticated else None | |
| answer_str = (result.get("qa") or {}).get("answer") or \ | |
| (result.get("sentiment") or {}).get("summary", "") | |
| get_db().save_query( | |
| user_id=user_id, | |
| session_id=session.get("session_id"), | |
| question=question, | |
| answer=answer_str, | |
| context_preview=context[:300], | |
| confidence=result.get("qa", {}).get("confidence_score"), | |
| intent=intent, | |
| ) | |
| return jsonify(result) | |
| except Exception as e: | |
| return jsonify({"error": str(e)}), 500 | |
| # ── Extension API ───────────────────────────────────────────────────────────── | |
| # Same as /api/ask but anonymous, CORS-enabled, and skips history saving. | |
| def extension_ask(): | |
| if request.method == "OPTIONS": | |
| return "", 204 | |
| data = request.get_json(force=True) | |
| question = data.get("question", "").strip() | |
| context = data.get("context", "").strip() | |
| if not question or not context: | |
| return jsonify({"error": "Both question and context are required"}), 400 | |
| try: | |
| qa, gen, sentiment, router, rag = get_models() | |
| intent = router.classify(question) | |
| result = {"intent": intent, "question": question} | |
| if intent in ("factual", "hybrid"): | |
| result["qa"] = run_smart_qa(question, context, qa, gen, rag) | |
| if intent in ("subjective", "hybrid"): | |
| result["sentiment"] = sentiment.analyze(context, question) | |
| return jsonify(result) | |
| except Exception as e: | |
| return jsonify({"error": str(e)}), 500 | |
| def extension_compare(): | |
| """ | |
| Compare multiple products using pre-extracted DOM text (no scraping). | |
| Body: { products: [{name, url, text}, ...], question: "..." } | |
| Returns side-by-side QA + sentiment + winner. | |
| """ | |
| if request.method == "OPTIONS": | |
| return "", 204 | |
| data = request.get_json(force=True) | |
| products = data.get("products", []) | |
| question = data.get("question", "").strip() | |
| if len(products) < 2: | |
| return jsonify({"error": "Provide at least 2 products"}), 400 | |
| if not question: | |
| return jsonify({"error": "Question is required"}), 400 | |
| try: | |
| qa, gen, sentiment, router, rag = get_models() | |
| intent = router.classify(question) | |
| out = [] | |
| for p in products: | |
| text = (p.get("text") or "").strip() | |
| if not text: | |
| out.append({"name": p.get("name", ""), "url": p.get("url", ""), | |
| "error": "No text provided"}) | |
| continue | |
| row = {"name": p.get("name", ""), "url": p.get("url", "")} | |
| if intent in ("factual", "hybrid"): | |
| row["qa"] = run_smart_qa(question, text, qa, gen, rag) | |
| if intent in ("subjective", "hybrid"): | |
| row["sentiment"] = sentiment.analyze(text, question) | |
| out.append(row) | |
| # Winner scoring (same heuristic as web app) | |
| scored = [] | |
| for p in out: | |
| if p.get("error"): | |
| scored.append((p, -1)); continue | |
| s = 0.0 | |
| if intent in ("factual","hybrid") and p.get("qa"): | |
| s += p["qa"].get("confidence_score", 0) * 0.6 | |
| if intent in ("subjective","hybrid") and p.get("sentiment"): | |
| s += (p["sentiment"].get("average_stars", 3) / 5.0) * 0.4 | |
| scored.append((p, s)) | |
| winner = max(scored, key=lambda x: x[1]) | |
| winner_obj = ({"url": winner[0]["url"], "score": round(winner[1], 3)} | |
| if winner[1] >= 0 else {}) | |
| return jsonify({"question": question, "products": out, | |
| "winner": winner_obj, "intent": intent}) | |
| except Exception as e: | |
| return jsonify({"error": str(e)}), 500 | |
| def compare_api(): | |
| data = request.get_json(force=True) | |
| urls = data.get("urls", []) | |
| question = data.get("question", "").strip() | |
| if len(urls) < 2: | |
| return jsonify({"error": "Provide at least 2 product URLs"}), 400 | |
| if not question: | |
| return jsonify({"error": "Question is required"}), 400 | |
| try: | |
| results = get_compare().compare(urls, question) | |
| return jsonify(results) | |
| except Exception as e: | |
| return jsonify({"error": str(e)}), 500 | |
| def export_history(): | |
| queries = get_db().get_user_history(current_user.id) | |
| buf = io.StringIO() | |
| writer = csv.DictWriter(buf, fieldnames=["timestamp", "question", "answer", "confidence", "intent"]) | |
| writer.writeheader() | |
| writer.writerows(queries) | |
| buf.seek(0) | |
| return send_file( | |
| io.BytesIO(buf.getvalue().encode()), | |
| mimetype="text/csv", | |
| as_attachment=True, | |
| download_name="query_history.csv", | |
| ) | |
| # ── Auth ───────────────────────────────────────────────────────────────────── | |
| def login(): | |
| if request.method == "POST": | |
| d = request.get_json(silent=True) or request.form | |
| user = User.authenticate(d.get("username"), d.get("password"), get_db()) | |
| if user: | |
| login_user(user, remember=True) | |
| next_url = request.args.get("next", url_for("index")) | |
| return jsonify({"success": True, "redirect": next_url}) if request.is_json else redirect(next_url) | |
| msg = "Invalid username or password" | |
| return (jsonify({"error": msg}), 401) if request.is_json else render_template("auth/login.html", error=msg) | |
| return render_template("auth/login.html") | |
| def register(): | |
| if request.method == "POST": | |
| d = request.get_json(silent=True) or request.form | |
| try: | |
| User.create(d.get("username"), d.get("password"), d.get("email", ""), get_db()) | |
| return (jsonify({"success": True})) if request.is_json else redirect(url_for("login")) | |
| except ValueError as e: | |
| return (jsonify({"error": str(e)}), 400) if request.is_json else render_template("auth/register.html", error=str(e)) | |
| return render_template("auth/register.html") | |
| def logout(): | |
| logout_user() | |
| return redirect(url_for("index")) | |
| # ── Entrypoint ──────────────────────────────────────────────────────────────── | |
| if __name__ == "__main__": | |
| import logging | |
| logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s") | |
| log = logging.getLogger("startup") | |
| log.info("Initialising database…") | |
| get_db() | |
| log.info("Pre-loading models (one-time, ~30-60 seconds)…") | |
| qa, gen, sentiment, router, rag = get_models() | |
| # Warm them up so first user request is instant | |
| qa.answer("test", "This is a test product description for warmup.") | |
| rag.get_relevant_context("test", "This is a test product. " * 50) | |
| sentiment.analyze("This is great. Really like it.") | |
| router.classify("Is it good?") | |
| log.info("✓ All models ready. Server starting on http://localhost:5000") | |
| app.run(debug=os.environ.get("FLASK_DEBUG", "0") == "1", | |
| host="0.0.0.0", port=5000, threaded=True) | |