| 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") |
| |
| 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('"', """).replace("<", "<").replace(">", ">") |
| 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} |
|
|