Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
152 changes: 137 additions & 15 deletions app/backend/app.py
Original file line number Diff line number Diff line change
@@ -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.
Expand All @@ -11,58 +13,67 @@

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
return paths[0] if paths else None


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(
[
os.path.join(PHASE1_DIR, "artifacts", "models", "v1"),
os.path.join(PHASE1_DIR, "models_v1"),
]
)

if version in {"v2", "phase1-v2"}:
return first_existing_path(
[
os.path.join(PHASE1_DIR, "artifacts", "models", "v2"),
os.path.join(PHASE1_DIR, "models_v2"),
]
)

if version in {"v3", "phase1-v3"}:
return first_existing_path(
[
os.path.join(PHASE1_DIR, "artifacts", "models", "v3"),
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}")

Expand All @@ -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,
Expand All @@ -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:
Expand All @@ -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

Expand All @@ -174,6 +295,7 @@ def predict_all():
try:
predictions = {}
labels = []

for version in ["v1", "v2", "v3"]:
pred = run_prediction_for_version(
version=version,
Expand All @@ -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,
Expand All @@ -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)
Loading
Loading