diff --git a/app/backend/app.py b/app/backend/app.py index 08411d1..5f24077 100644 --- a/app/backend/app.py +++ b/app/backend/app.py @@ -1,7 +1,9 @@ +import io import os import sys import time +import pandas as pd from flask import Flask, jsonify, render_template, request # Add project root to import path for direct script execution. @@ -11,13 +13,17 @@ PHASE1_DIR = os.path.abspath(os.path.join(os.path.dirname(__file__), "..")) -from app.ml.predict import predict_single, predict_single_v3 +from app.ml.predict import ( + predict_batch as ml_predict_batch, + predict_batch_v3, + predict_single, + predict_single_v3, +) MAX_TEXT_LENGTH = 10000 def first_existing_path(paths): - # Return the first existing path from a list, else the first candidate. for path in paths: if os.path.exists(path): return path @@ -25,8 +31,8 @@ def first_existing_path(paths): def models_dir_from_version(model_version): - # Map API model version selector to local artifact directory. version = str(model_version or "v3").strip().lower() + if version in {"v1", "phase1-v1"}: return first_existing_path( [ @@ -34,6 +40,7 @@ def models_dir_from_version(model_version): os.path.join(PHASE1_DIR, "models_v1"), ] ) + if version in {"v2", "phase1-v2"}: return first_existing_path( [ @@ -41,6 +48,7 @@ def models_dir_from_version(model_version): os.path.join(PHASE1_DIR, "models_v2"), ] ) + if version in {"v3", "phase1-v3"}: return first_existing_path( [ @@ -48,21 +56,24 @@ def models_dir_from_version(model_version): os.path.join(PHASE1_DIR, "models_v3"), ] ) + return None def parse_payload(payload): - # Validate and normalize shared request payload fields. if payload is None: raise ValueError("Invalid JSON body") + if "text" not in payload: raise ValueError("Missing required field: text") text = payload.get("text") if not isinstance(text, str): raise TypeError("Field text must be a string") + if len(text.strip()) == 0: raise ValueError("Field text must be non-empty") + if len(text) > MAX_TEXT_LENGTH: raise OverflowError(f"Field text exceeds max length {MAX_TEXT_LENGTH}") @@ -78,12 +89,13 @@ def parse_payload(payload): def run_prediction_for_version(version, text, rating, helpful_vote, verified_purchase): - # Run prediction for one model version and return UI-ready fields. models_dir = models_dir_from_version(version) if models_dir is None: raise ValueError("model_version must be 'v1', 'v2', or 'v3'") - if str(version).strip().lower() in {"v3", "phase1-v3"}: + version_str = str(version).strip().lower() + + if version_str in {"v3", "phase1-v3"}: result = predict_single_v3( text=text, rating=rating, @@ -106,28 +118,139 @@ def run_prediction_for_version(version, text, rating, helpful_vote, verified_pur def create_app(): - # Create Flask app and define API routes. template_dir = os.path.abspath( os.path.join(os.path.dirname(__file__), "..", "frontend", "templates") ) static_dir = os.path.abspath( os.path.join(os.path.dirname(__file__), "..", "frontend", "static") ) - app = Flask(__name__, template_folder=template_dir, static_folder=static_dir, static_url_path="/static") + + app = Flask( + __name__, + template_folder=template_dir, + static_folder=static_dir, + static_url_path="/static", + ) @app.get("/") def home(): - # Render the web tester UI for direct prediction checks. return render_template("index.html") @app.get("/health") def health(): - # Return simple health status for monitoring. return jsonify({"status": "ok"}), 200 + @app.post("/batch") + def batch_predict(): + if "file" not in request.files: + return jsonify({"error": "Missing CSV file upload"}), 400 + + file = request.files["file"] + if not file or not file.filename: + return jsonify({"error": "Please choose a CSV file"}), 400 + + if not file.filename.lower().endswith(".csv"): + return jsonify({"error": "Only CSV files are supported"}), 400 + + try: + df = pd.read_csv(file) + except Exception as ex: + return jsonify({"error": f"Could not read CSV: {str(ex)}"}), 400 + + if "text" not in df.columns: + return jsonify({"error": "CSV must contain a text column"}), 400 + + try: + texts = df["text"].fillna("").astype(str).tolist() + ratings = ( + pd.to_numeric(df["rating"], errors="coerce").fillna(0.0).tolist() + if "rating" in df.columns + else [0.0] * len(df) + ) + helpful_votes = ( + pd.to_numeric(df["helpful_vote"], errors="coerce").fillna(0.0).tolist() + if "helpful_vote" in df.columns + else [0.0] * len(df) + ) + verified_purchase = ( + pd.to_numeric(df["verified_purchase"], errors="coerce") + .fillna(0) + .astype(int) + .tolist() + if "verified_purchase" in df.columns + else [0] * len(df) + ) + + model_version = str(request.form.get("model_version", "v3")).strip().lower() or "v3" + models_dir = models_dir_from_version(model_version) + if models_dir is None: + return jsonify({"error": "model_version must be 'v1', 'v2', or 'v3'"}), 400 + + if model_version == "v3": + results = predict_batch_v3( + texts=texts, + ratings=ratings, + helpful_votes=helpful_votes, + verified_purchase=verified_purchase, + models_dir=models_dir, + ) + else: + results = ml_predict_batch( + texts=texts, + ratings=ratings, + helpful_votes=helpful_votes, + verified_purchase=verified_purchase, + models_dir=models_dir, + ) + + formatted_results = [] + fake_count = 0 + genuine_count = 0 + + for item in results: + label = str(item.get("label", "genuine")).lower() + fake_probability = round(float(item.get("fake_probability", 0.0)) * 100.0, 2) + + if label == "fake": + fake_count += 1 + else: + genuine_count += 1 + + formatted_results.append( + { + "text": item.get("text", ""), + "label": label, + "fake_probability": fake_probability, + "fake_percent": fake_probability, + "threshold": round(float(item.get("threshold", 0.5)) * 100.0, 2), + "model_version": item.get("model_version", model_version), + } + ) + + csv_output = io.StringIO() + pd.DataFrame(formatted_results).to_csv(csv_output, index=False) + + return jsonify( + { + "summary": { + "total_reviews": len(formatted_results), + "fake_count": fake_count, + "genuine_count": genuine_count, + "manual_review_count": 0, + }, + "results": formatted_results, + "csv_data": csv_output.getvalue(), + "count": len(formatted_results), + } + ), 200 + + except FileNotFoundError: + return jsonify({"error": "Model artifacts not found. Train the model first."}), 500 + except Exception as ex: + return jsonify({"error": f"Batch prediction failed: {str(ex)}"}), 500 + @app.post("/predict") def predict(): - # Validate request payload and return fake probability prediction. started = time.time() if not request.is_json: @@ -152,14 +275,12 @@ def predict(): except Exception as ex: return jsonify({"error": f"Prediction failed: {str(ex)}"}), 500 - # Add lightweight latency info to simplify local acceptance checks. elapsed_ms = (time.time() - started) * 1000.0 result["latency_ms"] = round(elapsed_ms, 2) return jsonify(result), 200 @app.post("/predict_all") def predict_all(): - # Return predictions from all model versions for agreement checking. if not request.is_json: return jsonify({"error": "Request must be JSON"}), 400 @@ -174,6 +295,7 @@ def predict_all(): try: predictions = {} labels = [] + for version in ["v1", "v2", "v3"]: pred = run_prediction_for_version( version=version, @@ -188,6 +310,7 @@ def predict_all(): disagreement = len(set(labels)) > 1 majority_label = max(set(labels), key=labels.count) if labels else "unknown" recommendation = "manual_review" if disagreement else majority_label + return jsonify( { "predictions": predictions, @@ -208,6 +331,5 @@ def predict_all(): if __name__ == "__main__": # pragma: no cover - # Run local development server. port = int(os.environ.get("PORT", "8000")) - app.run(host="0.0.0.0", port=port, debug=False) + app.run(host="0.0.0.0", port=port, debug=False) \ No newline at end of file diff --git a/app/frontend/static/css/style.css b/app/frontend/static/css/style.css index 62dda65..bba1661 100644 --- a/app/frontend/static/css/style.css +++ b/app/frontend/static/css/style.css @@ -108,6 +108,30 @@ body { max-width: 460px; margin: 0 auto; } +/* ── Tabs ────────────────────────────────────────────────────── */ +.tabs { + display: flex; + gap: .5rem; + margin-bottom: 1.5rem; + border-bottom: 1px solid var(--border); + padding-bottom: 0; +} +.tab { + background: none; + border: none; + color: var(--text-muted); + font-family: var(--font-body); + font-size: .9rem; + font-weight: 500; + padding: .6rem 1.2rem; + cursor: pointer; + border-bottom: 2px solid transparent; + margin-bottom: -1px; + transition: color .2s, border-color .2s; +} +.tab:hover { color: var(--text); } +.tab.active { color: var(--accent); border-bottom-color: var(--accent); } + .panel { background: var(--bg2); @@ -220,6 +244,34 @@ body { font-size: 1.1rem; animation: spin 1s linear infinite; } +.btn-secondary { + display: flex; + align-items: center; + justify-content: center; + gap: 0.6rem; + + width: 100%; + padding: 1rem; /* bigger */ + margin-top: 0.5rem; + + background: var(--accent); /* green */ + color: #0a0e1a; + + font-family: var(--font-body); + font-size: 1.05rem; /* slightly bigger text */ + font-weight: 600; + + border: none; + border-radius: 10px; + cursor: pointer; + + transition: opacity 0.2s, transform 0.15s; +} + +.btn-secondary:hover { + opacity: 0.9; + transform: translateY(-1px); +} @keyframes spin { to { transform: rotate(360deg); } } .error-msg { @@ -306,6 +358,81 @@ body { font-size: .7rem; color: var(--text-muted); } +/* ── Upload zone ─────────────────────────────────────────────── */ +.upload-zone { + border: 2px dashed var(--border); + border-radius: var(--radius); + padding: 2.5rem 1.5rem; + text-align: center; + cursor: pointer; + transition: border-color .2s, background .2s; + position: relative; +} +.upload-zone:hover, .upload-zone.dragover { + border-color: var(--accent); + background: var(--accent-dim); +} +.upload-icon { font-size: 2.5rem; margin-bottom: .8rem; } +.upload-title { font-size: 1rem; font-weight: 500; margin-bottom: .3rem; } +.upload-sub { font-size: .82rem; color: var(--text-muted); } +.upload-sub code { font-family: var(--font-mono); font-size: .78rem; color: var(--accent); } +.file-input { + position: absolute; inset: 0; opacity: 0; cursor: pointer; width: 100%; height: 100%; +} +.file-hint { font-size: .82rem; color: var(--accent); font-family: var(--font-mono); } + +/* ── Batch results ───────────────────────────────────────────── */ +.batch-results { display: flex; flex-direction: column; gap: 1rem; } +.batch-summary { + font-size: .9rem; + color: var(--text-muted); + background: var(--bg3); + border-radius: 8px; + padding: .75rem 1rem; +} +.batch-summary strong { color: var(--text); } +.batch-summary .fake-count { color: var(--fake); } +.batch-summary .genuine-count { color: var(--genuine); } + +.table-wrap { + overflow-x: auto; + border-radius: 10px; + border: 1px solid var(--border); + max-height: 360px; + overflow-y: auto; +} +.results-table { width: 100%; border-collapse: collapse; font-size: .85rem; } +.results-table thead { position: sticky; top: 0; z-index: 2; } +.results-table th { + background: var(--bg3); + color: var(--text-muted); + font-weight: 600; + font-size: .72rem; + letter-spacing: .07em; + text-transform: uppercase; + padding: .7rem 1rem; + text-align: left; + border-bottom: 1px solid var(--border); +} +.results-table td { + padding: .65rem 1rem; + border-bottom: 1px solid var(--border); + vertical-align: middle; +} +.results-table tr:last-child td { border-bottom: none; } +.results-table tr:hover td { background: rgba(255,255,255,0.02); } +.verdict-badge { + display: inline-block; + font-family: var(--font-mono); + font-size: .72rem; + font-weight: 600; + letter-spacing: .06em; + padding: .2rem .6rem; + border-radius: 6px; +} +.verdict-badge.fake { background: var(--fake-dim); color: var(--fake); } +.verdict-badge.genuine { background: var(--genuine-dim); color: var(--genuine); } + .footer { text-align: center; @@ -315,4 +442,4 @@ body { border-top: 1px solid var(--border); } -.hidden { display: none !important; } +.hidden { display: none !important; } \ No newline at end of file diff --git a/app/frontend/static/js/script.js b/app/frontend/static/js/script.js index 855b532..0e16e84 100644 --- a/app/frontend/static/js/script.js +++ b/app/frontend/static/js/script.js @@ -1,9 +1,21 @@ -const API = ''; +const API = window.location.origin; + +// ── Tab switching ──────────────────────────────────────────────────────────── + +document.querySelectorAll('.tab').forEach(tab => { + tab.addEventListener('click', () => { + document.querySelectorAll('.tab').forEach(t => t.classList.remove('active')); + document.querySelectorAll('.panel').forEach(p => p.classList.add('hidden')); + tab.classList.add('active'); + + const targetPanel = document.getElementById(`panel-${tab.dataset.tab}`); + if (targetPanel) { + targetPanel.classList.remove('hidden'); + } + }); +}); + -function showError(el, msg) { - el.textContent = msg; - el.classList.remove('hidden'); -} function hideError(el) { el.textContent = ''; @@ -115,6 +127,143 @@ async function analyzeReview() { } } + +const uploadZone = document.getElementById('uploadZone'); +const csvFile = document.getElementById('csvFile'); +const fileHint = document.getElementById('fileHint'); + +uploadZone.addEventListener('dragover', e => { e.preventDefault(); uploadZone.classList.add('dragover'); }); +uploadZone.addEventListener('dragleave', () => uploadZone.classList.remove('dragover')); +uploadZone.addEventListener('drop', e => { + e.preventDefault(); + uploadZone.classList.remove('dragover'); + const file = e.dataTransfer.files[0]; + if (file) setFile(file); +}); +csvFile.addEventListener('change', () => { + if (csvFile.files[0]) setFile(csvFile.files[0]); +}); + +function setFile(file) { + const dt = new DataTransfer(); + dt.items.add(file); + csvFile.files = dt.files; + fileHint.textContent = `📄 ${file.name} (${(file.size / 1024).toFixed(1)} KB)`; +} + +// ── Batch upload ────────────────────────────────────────────────────────────── + +let _batchResults = []; + +async function runBatch() { + const errEl = document.getElementById('batchError'); + const btn = document.getElementById('batchBtn'); + const spinner = document.getElementById('batchSpinner'); + const btnText = btn.querySelector('.btn-text'); + const batchResultsEl = document.getElementById('batchResults'); + + hideError(errEl); + batchResultsEl.classList.add('hidden'); + _batchResults = []; + + const file = csvFile.files[0]; + if (!file) { + showError(errEl, 'Please select a CSV file first.'); + return; + } + + btn.disabled = true; + spinner.classList.remove('hidden'); + btnText.textContent = 'Analysing…'; + + try { + // ✅ FIX: CREATE formData HERE + const formData = new FormData(); + formData.append('file', file); + formData.append('model_version', 'v3'); + + const res = await fetch(`${API}/batch`, { + method: 'POST', + body: formData + }); + + const data = await res.json(); + + if (!res.ok) { + showError(errEl, data.error || 'Batch prediction failed.'); + return; + } + + _batchResults = data.results || []; + renderBatch(data); + + } catch (e) { + showError(errEl, e.message || 'Cannot connect to server.'); + } finally { + btn.disabled = false; + spinner.classList.add('hidden'); + btnText.textContent = 'Analyse CSV'; + } +} + +function renderBatch(data) { + const results = data.results; + const fakeCount = results.filter(r => r.label === 'fake').length; + const genuineCount = results.length - fakeCount; + + document.getElementById('batchSummary').innerHTML = + `Processed ${results.length} reviews — ` + + `⚠ ${fakeCount} fake · ` + + `✓ ${genuineCount} genuine`; + + const tbody = document.getElementById('resultsBody'); + tbody.innerHTML = ''; + results.forEach((r, i) => { + const tr = document.createElement('tr'); + const preview = (r.text || '').slice(0, 80) + ((r.text || '').length > 80 ? '…' : ''); + tr.innerHTML = ` + ${i + 1} + ${escapeHtml(preview)} + ${r.label.toUpperCase()} + ${r.fake_percent}% + `; + tbody.appendChild(tr); + }); + + document.getElementById('batchResults').classList.remove('hidden'); +} + +function downloadCSV() { + if (!_batchResults.length) return; + const headers = ['text', 'label', 'fake_probability']; + const rows = _batchResults.map(r => + headers.map(h => JSON.stringify(r[h] ?? '')).join(',') + ); + const csv = [headers.join(','), ...rows].join('\n'); + const blob = new Blob([csv], { type: 'text/csv' }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = 'fake_review_results.csv'; + a.click(); + URL.revokeObjectURL(url); +} + +// ── Utilities ───────────────────────────────────────────────────────────────── + +function showError(el, msg) { + el.textContent = msg; + el.classList.remove('hidden'); +} + +function escapeHtml(str) { + return String(str) + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"'); +} + document.getElementById('analyzeBtn')?.addEventListener('click', analyzeReview); document.getElementById('reviewText')?.addEventListener('keydown', (e) => { if (e.key === 'Enter' && !e.shiftKey) { @@ -123,4 +272,4 @@ document.getElementById('reviewText')?.addEventListener('keydown', (e) => { } }); -checkHealth(); +checkHealth(); \ No newline at end of file diff --git a/app/frontend/templates/index.html b/app/frontend/templates/index.html index 29653d8..2b28708 100644 --- a/app/frontend/templates/index.html +++ b/app/frontend/templates/index.html @@ -32,8 +32,13 @@

Analyse customer reviews instantly

- -
+ +
+ + +
+ +