File size: 6,395 Bytes
3338b6d
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
"""
Flask application entrypoint.

Routes
  GET   /                     β€” main UI (index.html)
  GET   /healthz              β€” liveness probe
  POST  /api/scrape           β€” scrape a product URL
  POST  /api/predict          β€” run BERT QA on (question, context)
  GET   /api/history          β€” list stored Q&A entries
  DELETE /api/history/<id>    β€” remove a single entry
  DELETE /api/history         β€” clear all entries
"""
import logging
import os
from pathlib import Path

from flask import Flask, jsonify, render_template, request

from . import config, db
from .model import init_model, predict_qa
from .scraper import scrape_url

FORMAT = "%(asctime)s [%(levelname)s] %(name)s β€” %(message)s"
logging.basicConfig(format=FORMAT, level=logging.INFO)
logger = logging.getLogger(__name__)

_PROJECT_ROOT = Path(__file__).resolve().parent.parent


def create_app() -> Flask:
    app = Flask(
        __name__,
        template_folder=str(_PROJECT_ROOT / "templates"),
        static_folder=str(_PROJECT_ROOT / "static"),
    )

    # ── Rate limiting (optional) ────────────────────────────────
    limiter = None
    if config.RATE_LIMIT_ENABLED:
        try:
            from flask_limiter import Limiter
            from flask_limiter.util import get_remote_address

            limiter = Limiter(
                get_remote_address,
                app=app,
                default_limits=[config.RATE_LIMIT_DEFAULT],
                storage_uri="memory://",
                strategy="fixed-window",
            )
            logger.info("Rate limiting enabled")
        except ImportError:
            logger.warning("flask-limiter not installed; rate limiting disabled")

    def _limit(rule: str):
        """Decorator that applies a limit only if the limiter is active."""
        if limiter is None:
            def noop(fn):
                return fn
            return noop
        return limiter.limit(rule)

    # ── Init DB early so any failure surfaces at boot, not at first write ──
    db.init_db()

    # ── Routes ──────────────────────────────────────────────────

    @app.get("/")
    def index():
        return render_template("index.html")

    @app.get("/healthz")
    def healthz():
        return jsonify({"status": "ok", "model": config.MODEL_NAME}), 200

    @app.post("/api/scrape")
    @_limit(config.RATE_LIMIT_SCRAPE)
    def api_scrape():
        payload = request.get_json(silent=True) or {}
        url = (payload.get("url") or "").strip()
        if not url:
            return jsonify({"error": "URL is required."}), 400
        try:
            result = scrape_url(url)
            if result.get("error"):
                return jsonify(result), 400
            return jsonify(result)
        except Exception as e:  # noqa: BLE001 β€” top-level safety net
            logger.exception("Scraping failed")
            return jsonify({"error": f"Unexpected error: {e}"}), 500

    @app.post("/api/predict")
    @_limit(config.RATE_LIMIT_PREDICT)
    def api_predict():
        payload = request.get_json(silent=True) or {}
        question = (payload.get("question") or "").strip()
        context = (payload.get("context") or "").strip()
        source_url = (payload.get("source_url") or "").strip() or None
        source_type = (payload.get("source_type") or "").strip() or None
        product_title = (payload.get("product_title") or "").strip() or None

        if not question or not context:
            return jsonify({"error": "Both question and context are required."}), 400
        if len(context) < 20:
            return jsonify({"error": "Context is too short (minimum 20 characters)."}), 400
        if len(question) > 500:
            return jsonify({"error": "Question is too long (max 500 characters)."}), 400

        try:
            result = predict_qa(question, context)
        except ValueError as e:
            return jsonify({"error": str(e)}), 400
        except Exception as e:  # noqa: BLE001
            logger.exception("Prediction failed")
            return jsonify({"error": f"Inference error: {e}"}), 500

        # Persist β€” failure here must NOT break the user's response
        try:
            entry_id = db.save_qa(
                question=question,
                answer=result["answer"],
                confidence=result["confidence"],
                confidence_level=result["confidence_level"],
                inference_ms=result["inference_time_ms"],
                source_url=source_url,
                source_type=source_type,
                product_title=product_title,
            )
            result["history_id"] = entry_id
        except Exception:
            logger.exception("Failed to persist Q&A β€” continuing")

        return jsonify(result)

    @app.get("/api/history")
    def api_history():
        limit = request.args.get("limit", type=int) or config.HISTORY_LIMIT
        limit = max(1, min(limit, 500))
        try:
            return jsonify({"items": db.list_history(limit=limit)})
        except Exception as e:  # noqa: BLE001
            logger.exception("History listing failed")
            return jsonify({"error": str(e)}), 500

    @app.delete("/api/history/<int:entry_id>")
    def api_history_delete(entry_id: int):
        try:
            ok = db.delete_entry(entry_id)
            return jsonify({"deleted": ok, "id": entry_id}), (200 if ok else 404)
        except Exception as e:  # noqa: BLE001
            logger.exception("History delete failed")
            return jsonify({"error": str(e)}), 500

    @app.delete("/api/history")
    def api_history_clear():
        try:
            n = db.clear_history()
            return jsonify({"cleared": n})
        except Exception as e:  # noqa: BLE001
            logger.exception("History clear failed")
            return jsonify({"error": str(e)}), 500

    # ── Model load at import-time so gunicorn workers are warm ─────
    logger.info("Initializing BERT QA model…")
    init_model()
    logger.info("Model ready.")

    return app


# Gunicorn entry: `gunicorn src.app:app`
app = create_app()


if __name__ == "__main__":
    app.run(host="0.0.0.0", port=config.PORT, debug=config.DEBUG)