File size: 6,848 Bytes
3b558e2
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
5c09346
 
 
 
3b558e2
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
5c09346
 
 
 
3b558e2
 
 
 
 
 
 
 
 
5c09346
3b558e2
 
5c09346
3b558e2
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
5c09346
3b558e2
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
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}