amarshiv86's picture
ci: deploy app/main.py
5c09346 verified
import os
from fastapi import FastAPI, Form
from fastapi.responses import HTMLResponse
from contextlib import asynccontextmanager
from huggingface_hub import hf_hub_download
from transformers import pipeline, AutoTokenizer, AutoModelForSequenceClassification
MODEL = {}
HF_REPO = os.getenv("HF_REPO", "amarshiv86/sentiment-analysis-imdb-model")
@asynccontextmanager
async def lifespan(app: FastAPI):
print(f"Downloading model from HF Hub: {HF_REPO} …")
files = [
"model/config.json",
"model/model.safetensors",
"model/tokenizer.json",
"model/tokenizer_config.json",
]
local_dir = "/tmp/sentiment-model"
os.makedirs(local_dir, exist_ok=True)
for f in files:
hf_hub_download(
repo_id=HF_REPO,
filename=f,
repo_type="model",
local_dir=local_dir,
)
tokenizer = AutoTokenizer.from_pretrained(f"{local_dir}/model")
# DistilBERT does not use token_type_ids — strip them to avoid TypeError
tokenizer.model_input_names = ["input_ids", "attention_mask"]
model = AutoModelForSequenceClassification.from_pretrained(f"{local_dir}/model")
MODEL["clf"] = pipeline(
"sentiment-analysis",
model=model,
tokenizer=tokenizer,
truncation=True,
max_length=256,
)
print("Model loaded ✓")
yield
MODEL.clear()
app = FastAPI(title="Sentiment Analysis API", lifespan=lifespan)
HTML = """<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8"/>
<meta name="viewport" content="width=device-width,initial-scale=1"/>
<title>Sentiment Analyzer · AI Demo</title>
<link rel="preconnect" href="https://fonts.googleapis.com"/>
<link href="https://fonts.googleapis.com/css2?family=Syne:wght@400;600;700;800&family=DM+Mono:wght@300;400;500&display=swap" rel="stylesheet"/>
<style>
:root{
--bg:#0d1117;--surface:#161b22;--border:rgba(255,255,255,.08);
--text:#e6edf3;--muted:#7d8590;
--pos:#2ea043;--neg:#da3633;
--pos-bg:rgba(46,160,67,.12);--neg-bg:rgba(218,54,51,.12);
--accent:#58a6ff;
}
*,*::before,*::after{box-sizing:border-box;margin:0;padding:0}
body{background:var(--bg);color:var(--text);font-family:'DM Mono',monospace;min-height:100vh;display:flex;flex-direction:column;align-items:center;justify-content:center;padding:2rem 1rem}
.card{background:var(--surface);border:1px solid var(--border);border-radius:12px;padding:2.5rem;width:100%;max-width:640px}
h1{font-family:'Syne',sans-serif;font-size:1.8rem;font-weight:800;letter-spacing:-.02em;margin-bottom:.4rem}
h1 span{color:var(--accent)}
.sub{color:var(--muted);font-size:.75rem;letter-spacing:.08em;text-transform:uppercase;margin-bottom:2rem}
label{font-size:.72rem;color:var(--muted);letter-spacing:.06em;display:block;margin-bottom:.4rem}
textarea{width:100%;background:rgba(255,255,255,.04);border:1px solid var(--border);border-radius:8px;color:var(--text);font-family:'DM Mono',monospace;font-size:.85rem;padding:.75rem 1rem;resize:vertical;min-height:130px;outline:none;transition:border-color .2s}
textarea:focus{border-color:rgba(88,166,255,.5)}
textarea::placeholder{color:var(--muted)}
.btn{width:100%;margin-top:1rem;padding:.8rem;background:var(--accent);border:none;border-radius:8px;color:#0d1117;font-family:'Syne',sans-serif;font-size:.9rem;font-weight:700;cursor:pointer;transition:opacity .2s,transform .15s;letter-spacing:.03em}
.btn:hover{opacity:.88;transform:translateY(-1px)}
.btn:active{transform:translateY(0)}
.divider{height:1px;background:var(--border);margin:1.5rem 0}
.result{border-radius:10px;padding:1.2rem 1.5rem;display:flex;align-items:center;gap:1rem;animation:pop .3s ease}
@keyframes pop{from{opacity:0;transform:scale(.97)}to{opacity:1;transform:scale(1)}}
.result.pos{background:var(--pos-bg);border:1px solid rgba(46,160,67,.3)}
.result.neg{background:var(--neg-bg);border:1px solid rgba(218,54,51,.3)}
.result-icon{font-size:2rem;line-height:1}
.result-body h2{font-family:'Syne',sans-serif;font-size:1.1rem;font-weight:700}
.result-body p{font-size:.75rem;color:var(--muted);margin-top:.2rem}
.badge{margin-left:auto;padding:.3rem .8rem;border-radius:99px;font-size:.72rem;font-weight:600;font-family:'Syne',sans-serif}
.pos .badge{background:rgba(46,160,67,.2);color:var(--pos)}
.neg .badge{background:rgba(218,54,51,.2);color:var(--neg)}
.bar-wrap{margin-top:1rem;background:rgba(255,255,255,.06);border-radius:99px;height:6px;overflow:hidden}
.bar-fill{height:100%;border-radius:99px;transition:width .6s ease}
.pos .bar-fill{background:var(--pos)}
.neg .bar-fill{background:var(--neg)}
.bar-label{display:flex;justify-content:space-between;font-size:.7rem;color:var(--muted);margin-top:.4rem}
footer{margin-top:1.5rem;font-size:.7rem;color:var(--muted);text-align:center;letter-spacing:.06em}
footer a{color:var(--accent);text-decoration:none}
</style>
</head>
<body>
<div class="card">
<h1>Sentiment <span>Analyzer</span></h1>
<p class="sub">distilBERT · fine-tuned on IMDB · MLOps demo</p>
<form method="post" action="/predict">
<label>Enter a review or any text</label>
<textarea name="text" placeholder="e.g. This movie was absolutely fantastic, loved every minute of it!" required>{text_value}</textarea>
<button class="btn" type="submit">→ Analyze Sentiment</button>
{result_html}
</form>
</div>
<footer>
Model: <a href="https://huggingface.co/amarshiv86/sentiment-analysis-imdb-model" target="_blank">amarshiv86/sentiment-analysis-imdb-model</a>
</footer>
</body>
</html>
"""
@app.get("/", response_class=HTMLResponse)
async def index():
return HTML.replace("{result_html}", "").replace("{text_value}", "")
@app.post("/predict", response_class=HTMLResponse)
async def predict(text: str = Form(...)):
result = MODEL["clf"](text)[0]
label = result["label"]
score = result["score"]
pct = round(score * 100, 1)
css_class = "pos" if label == "POSITIVE" else "neg"
icon = "😊" if label == "POSITIVE" else "😞"
bar_width = round(score * 100)
result_html = f"""
<div class="divider"></div>
<div class="result {css_class}">
<div class="result-icon">{icon}</div>
<div class="result-body">
<h2>{label}</h2>
<p>Model confidence · {pct}%</p>
</div>
<span class="badge">{label}</span>
</div>
<div class="bar-wrap">
<div class="bar-fill" style="width:{bar_width}%"></div>
</div>
<div class="bar-label">
<span>confidence</span>
<span>{pct}%</span>
</div>
"""
safe_text = text.replace('"', "&quot;").replace("<", "&lt;").replace(">", "&gt;")
return (HTML
.replace("{result_html}", result_html)
.replace("{text_value}", safe_text))
@app.get("/health")
async def health():
return {"status": "ok", "model_loaded": "clf" in MODEL}