Emphasist / app.py
YureiYuri's picture
update design
aec9749 verified
"""
app.py — Cognitive Distortion Classifier / emphasisit (FastAPI, HuggingFace Spaces)
Neon green redesign — consistent with Coalitus platform theme.
"""
import json
from pathlib import Path
import torch
import uvicorn
from fastapi import FastAPI, HTTPException
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import HTMLResponse
from pydantic import BaseModel, Field
from transformers import AutoModelForSequenceClassification, AutoTokenizer
# ── Load artefacts ─────────────────────────────────────────────────────────────
BASE = Path(__file__).parent
with open(BASE / "label_config.json") as f:
label_cfg = json.load(f)
ID2LABEL: dict[int, str] = {int(k): v for k, v in label_cfg["id2label"].items()}
NUM_LABELS: int = len(ID2LABEL)
tokenizer = AutoTokenizer.from_pretrained(str(BASE))
model = AutoModelForSequenceClassification.from_pretrained(str(BASE))
model.eval()
# ── Label metadata ─────────────────────────────────────────────────────────────
EMOJI: dict[str, str] = {
"overgeneralization": "🔁",
"catastrophizing": "💥",
"black_and_white": "⚫⚪",
"self_blame": "👤",
"mind_reading": "🔮",
}
DESCRIPTIONS: dict[str, str] = {
"overgeneralization": "Drawing sweeping conclusions from a single event ('I always fail', 'Nobody ever helps me').",
"catastrophizing": "Expecting the worst possible outcome ('This is a disaster', 'Everything is ruined').",
"black_and_white": "Seeing situations as all-or-nothing with no middle ground.",
"self_blame": "Taking excessive personal responsibility for things outside your control.",
"mind_reading": "Assuming you know what others think without direct evidence.",
}
# ── Pydantic models ────────────────────────────────────────────────────────────
class PredictRequest(BaseModel):
text: str
threshold: float = Field(default=0.005, ge=0.001, le=0.02)
model_config = {"json_schema_extra": {
"example": {
"text": "I always mess everything up. I can't do anything right.",
"threshold": 0.005,
}
}}
class DistortionScore(BaseModel):
label: str
pretty: str
emoji: str
description: str
score: float
detected: bool
class PredictResponse(BaseModel):
any_detected: bool
threshold: float
detected: list[DistortionScore]
all_scores: list[DistortionScore]
# ── Inference ──────────────────────────────────────────────────────────────────
def run_inference(text: str, threshold: float) -> PredictResponse:
inputs = tokenizer(
text, return_tensors="pt",
truncation=True, max_length=128, padding=True,
)
with torch.no_grad():
probs = torch.sigmoid(model(**inputs).logits).squeeze()
all_scores = [
DistortionScore(
label = ID2LABEL[i],
pretty = ID2LABEL[i].replace("_", " ").title(),
emoji = EMOJI[ID2LABEL[i]],
description = DESCRIPTIONS[ID2LABEL[i]],
score = round(float(p), 6),
detected = float(p) >= threshold,
)
for i, p in enumerate(probs)
]
all_scores.sort(key=lambda x: x.score, reverse=True)
detected = [s for s in all_scores if s.detected]
return PredictResponse(
any_detected = len(detected) > 0,
threshold = threshold,
detected = detected,
all_scores = all_scores,
)
# ── FastAPI app ────────────────────────────────────────────────────────────────
app = FastAPI(
title = "Cognitive Distortion Classifier API",
description = "Detects 5 CBT cognitive distortions in free-text using sigmoid multi-label classification.",
version = "1.0.0",
)
app.add_middleware(
CORSMiddleware,
allow_origins = ["*"],
allow_methods = ["GET", "POST"],
allow_headers = ["*"],
)
@app.post("/predict", response_model=PredictResponse, summary="Detect cognitive distortions")
def predict(req: PredictRequest) -> PredictResponse:
if not req.text.strip():
raise HTTPException(status_code=422, detail="text must not be empty")
try:
return run_inference(req.text, req.threshold)
except Exception as exc:
raise HTTPException(status_code=500, detail=str(exc)) from exc
@app.get("/labels", summary="All supported distortion labels with metadata")
def list_labels() -> dict:
return {
label: {
"pretty": label.replace("_", " ").title(),
"emoji": EMOJI[label],
"description": DESCRIPTIONS[label],
}
for label in ID2LABEL.values()
}
@app.get("/health")
def health() -> dict:
return {"status": "ok"}
# ── Inline frontend ────────────────────────────────────────────────────────────
FRONTEND_HTML = """<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
<title>Cognitive Distortion Detector</title>
<link rel="preconnect" href="https://fonts.googleapis.com"/>
<link href="https://fonts.googleapis.com/css2?family=Orbitron:wght@400;600;700;900&family=Share+Tech+Mono&family=DM+Sans:wght@300;400;500&display=swap" rel="stylesheet"/>
<style>
:root {
--void: #010608;
--deep: #030d0a;
--panel: #050f0b;
--card: #071410;
--card-alt: #091a14;
--border: #0d2318;
--green: #39ff8e;
--green-mid: #00e66e;
--green-dim: #39ff8e55;
--green-glow: #39ff8e22;
--green-bg: #39ff8e0d;
--amber: #ffb830;
--amber-dim: #ffb83044;
--amber-bg: #ffb8300d;
--text-hi: #e0ffe8;
--text-mid: #5a9e72;
--text-dim: #1e4030;
}
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: 'DM Sans', sans-serif;
background: var(--void);
color: var(--text-hi);
min-height: 100vh;
padding: 2rem 1rem 5rem;
overflow-x: hidden;
background-image:
radial-gradient(ellipse 55% 35% at 10% 5%, #39ff8e09 0%, transparent 60%),
radial-gradient(ellipse 45% 35% at 90% 90%, #39ff8e07 0%, transparent 55%),
radial-gradient(#0d2318 1px, transparent 1px);
background-size: 100% 100%, 100% 100%, 24px 24px;
}
body::before {
content: '';
position: fixed; inset: 0; pointer-events: none; z-index: 9999;
background: repeating-linear-gradient(0deg, transparent, transparent 2px, #00000012 2px, #00000012 4px);
}
.container { max-width: 920px; margin: 0 auto; position: relative; }
/* ── Header ── */
.header {
margin-bottom: 2.5rem;
padding-bottom: 1.5rem;
border-bottom: 1px solid var(--border);
position: relative;
}
.header::after {
content: '';
position: absolute; bottom: -1px; left: 0;
width: 200px; height: 1px;
background: var(--green);
box-shadow: 0 0 14px var(--green), 0 0 40px var(--green-dim);
}
.eyebrow {
font-family: 'Share Tech Mono', monospace;
font-size: 0.68rem;
letter-spacing: 0.25em;
color: var(--green);
text-shadow: 0 0 8px var(--green);
margin-bottom: 0.7rem;
text-transform: uppercase;
}
.header h1 {
font-family: 'Orbitron', monospace;
font-size: clamp(1.2rem, 2.5vw, 1.75rem);
font-weight: 900;
letter-spacing: 0.06em;
color: var(--text-hi);
text-shadow: 0 0 24px var(--green-dim);
margin-bottom: 0.5rem;
}
.header h1 em {
font-style: normal;
color: var(--green);
text-shadow: 0 0 16px var(--green), 0 0 50px var(--green-dim);
}
.header p { color: var(--text-mid); font-size: 0.85rem; line-height: 1.75; max-width: 600px; font-weight: 300; }
.notice {
margin-top: 1rem;
padding: 0.75rem 1rem;
border-left: 3px solid var(--green-dim);
background: var(--green-bg);
font-size: 0.75rem;
color: var(--text-mid);
line-height: 1.7;
clip-path: polygon(0 0, 100% 0, 100% calc(100% - 8px), calc(100% - 8px) 100%, 0 100%);
}
.notice strong { color: var(--text-hi); }
code {
color: var(--green);
background: var(--green-bg);
border-radius: 2px;
padding: 1px 5px;
font-family: 'Share Tech Mono', monospace;
font-size: 0.7rem;
}
/* ── Two-column input layout ── */
.input-row {
display: grid;
grid-template-columns: 3fr 2fr;
gap: 1rem;
margin-bottom: 1rem;
align-items: start;
}
@media (max-width: 680px) { .input-row { grid-template-columns: 1fr; } }
.card {
background: var(--card);
border: 1px solid var(--border);
border-radius: 3px;
padding: 1.25rem;
clip-path: polygon(0 0, calc(100% - 16px) 0, 100% 16px, 100% 100%, 16px 100%, 0 calc(100% - 16px));
transition: border-color 0.3s;
}
.card:focus-within { border-color: var(--green-dim); }
.field-label {
font-family: 'Orbitron', monospace;
font-size: 0.58rem;
font-weight: 700;
letter-spacing: 0.2em;
text-transform: uppercase;
color: var(--green);
text-shadow: 0 0 6px var(--green-dim);
margin-bottom: 0.65rem;
display: flex;
align-items: center;
gap: 0.5rem;
}
.field-label::after { content: ''; flex: 1; height: 1px; background: linear-gradient(90deg, var(--border), transparent); }
textarea {
width: 100%;
background: var(--card-alt);
border: 1px solid var(--border);
border-radius: 2px;
color: var(--text-hi);
font-family: 'Share Tech Mono', monospace;
font-size: 0.82rem;
line-height: 1.7;
padding: 0.9rem 1rem;
resize: vertical;
min-height: 140px;
outline: none;
caret-color: var(--green);
transition: border-color 0.2s, box-shadow 0.2s;
clip-path: polygon(0 0, calc(100% - 10px) 0, 100% 10px, 100% 100%, 10px 100%, 0 calc(100% - 10px));
}
textarea::placeholder { color: var(--text-dim); }
textarea:focus {
border-color: var(--green-mid);
box-shadow: 0 0 0 1px var(--green-dim), 0 0 20px var(--green-glow);
}
/* ── Threshold card ── */
.threshold-card {
background: var(--card);
border: 1px solid var(--border);
border-radius: 3px;
padding: 1.25rem;
display: flex;
flex-direction: column;
gap: 0.9rem;
clip-path: polygon(0 0, calc(100% - 16px) 0, 100% 16px, 100% 100%, 16px 100%, 0 calc(100% - 16px));
}
.slider-val-row { display: flex; justify-content: space-between; align-items: baseline; }
.slider-val {
font-family: 'Orbitron', monospace;
font-size: 1.4rem;
font-weight: 900;
color: var(--green);
text-shadow: 0 0 16px var(--green), 0 0 40px var(--green-dim);
}
.slider-sub {
font-family: 'Share Tech Mono', monospace;
font-size: 0.62rem;
color: var(--text-dim);
letter-spacing: 0.05em;
}
.slider-hint { font-size: 0.73rem; color: var(--text-mid); line-height: 1.6; font-weight: 300; }
/* Slider track + thumb */
.slider-wrap { position: relative; height: 6px; }
.slider-track-bg {
position: absolute; inset: 0;
background: var(--border);
border-radius: 2px;
}
.slider-track-fill {
position: absolute; top: 0; left: 0; height: 100%;
background: var(--green);
border-radius: 2px;
box-shadow: 0 0 8px var(--green), 0 0 20px var(--green-dim);
transition: width 0.05s;
pointer-events: none;
}
input[type="range"] {
-webkit-appearance: none; appearance: none;
position: absolute; inset: 0;
width: 100%; height: 100%;
background: transparent; outline: none; cursor: pointer;
margin: 0; z-index: 1;
}
input[type="range"]::-webkit-slider-thumb {
-webkit-appearance: none;
width: 14px; height: 14px;
border-radius: 50%;
background: var(--void);
border: 2px solid var(--green);
box-shadow: 0 0 8px var(--green), 0 0 22px var(--green-dim), inset 0 0 4px var(--green-dim);
cursor: pointer;
transition: transform 0.15s, box-shadow 0.15s;
}
input[type="range"]::-webkit-slider-thumb:hover {
transform: scale(1.3);
box-shadow: 0 0 14px var(--green), 0 0 40px var(--green-dim);
}
input[type="range"]::-moz-range-thumb {
width: 14px; height: 14px; border-radius: 50%;
background: var(--void); border: 2px solid var(--green);
box-shadow: 0 0 8px var(--green); cursor: pointer;
}
/* ── Run button ── */
.btn-run {
width: 100%;
padding: 0.85rem 1.5rem;
font-family: 'Orbitron', monospace;
font-size: 0.75rem;
font-weight: 700;
letter-spacing: 0.18em;
text-transform: uppercase;
color: var(--void);
background: var(--green);
border: none;
border-radius: 2px;
cursor: pointer;
clip-path: polygon(0 0, calc(100% - 12px) 0, 100% 12px, 100% 100%, 12px 100%, 0 calc(100% - 12px));
box-shadow: 0 0 20px var(--green-dim), 0 0 60px var(--green-glow);
transition: box-shadow 0.2s, opacity 0.2s, transform 0.1s;
position: relative;
overflow: hidden;
}
.btn-run::after {
content: '';
position: absolute; inset: 0;
background: linear-gradient(90deg, transparent, #ffffff25, transparent);
transform: translateX(-100%);
transition: transform 0.5s;
}
.btn-run:hover::after { transform: translateX(100%); }
.btn-run:hover { box-shadow: 0 0 30px var(--green), 0 0 80px var(--green-dim); }
.btn-run:active { transform: scale(0.99); }
.btn-run:disabled { opacity: 0.35; cursor: not-allowed; box-shadow: none; }
/* ── Results ── */
.results-row {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 1rem;
margin-bottom: 1rem;
}
@media (max-width: 680px) { .results-row { grid-template-columns: 1fr; } }
.result-panel {
background: var(--card);
border: 1px solid var(--border);
border-radius: 3px;
padding: 1.1rem 1.25rem;
display: none;
clip-path: polygon(0 0, calc(100% - 14px) 0, 100% 14px, 100% 100%, 14px 100%, 0 calc(100% - 14px));
}
.result-panel.visible { display: block; }
.panel-title {
font-family: 'Orbitron', monospace;
font-size: 0.58rem;
font-weight: 700;
letter-spacing: 0.2em;
text-transform: uppercase;
color: var(--green);
text-shadow: 0 0 6px var(--green-dim);
margin-bottom: 1rem;
display: flex;
align-items: center;
gap: 0.5rem;
}
.panel-title::after { content: ''; flex: 1; height: 1px; background: linear-gradient(90deg, var(--border), transparent); }
/* Detected distortion card */
.distortion-item {
background: var(--green-bg);
border: 1px solid var(--green-dim);
border-left: 3px solid var(--green);
border-radius: 2px;
padding: 0.85rem 1rem;
margin-bottom: 0.6rem;
clip-path: polygon(0 0, calc(100% - 8px) 0, 100% 8px, 100% 100%, 8px 100%, 0 calc(100% - 8px));
box-shadow: 0 0 12px var(--green-glow);
}
.distortion-item:last-child { margin-bottom: 0; }
.d-name {
font-family: 'Orbitron', monospace;
font-size: 0.75rem;
font-weight: 700;
color: var(--green);
text-shadow: 0 0 8px var(--green-dim);
letter-spacing: 0.05em;
margin-bottom: 0.3rem;
}
.d-desc {
font-size: 0.74rem;
color: var(--text-mid);
line-height: 1.6;
font-weight: 300;
margin-bottom: 0.3rem;
}
.d-score {
font-family: 'Share Tech Mono', monospace;
font-size: 0.7rem;
color: var(--green);
opacity: 0.65;
}
/* Clean result */
.clean-box {
background: var(--green-bg);
border: 1px solid var(--green-dim);
border-left: 3px solid var(--green);
border-radius: 2px;
padding: 1rem 1.1rem;
box-shadow: 0 0 16px var(--green-glow);
clip-path: polygon(0 0, calc(100% - 10px) 0, 100% 10px, 100% 100%, 10px 100%, 0 calc(100% - 10px));
}
.clean-box h3 {
font-family: 'Orbitron', monospace;
font-size: 0.78rem;
font-weight: 700;
color: var(--green);
text-shadow: 0 0 8px var(--green-dim);
margin-bottom: 0.4rem;
letter-spacing: 0.05em;
}
.clean-box p { font-size: 0.78rem; color: var(--text-mid); line-height: 1.65; font-weight: 300; }
/* Score bars */
.prob-row { margin-bottom: 0.7rem; }
.prob-row:last-child { margin-bottom: 0; }
.prob-header {
display: flex; justify-content: space-between;
font-size: 0.74rem; color: var(--text-mid); margin-bottom: 0.3rem;
font-family: 'Share Tech Mono', monospace;
}
.prob-score { color: var(--text-hi); font-weight: 700; }
.prob-bar-bg { background: var(--border); border-radius: 1px; height: 4px; overflow: hidden; }
.prob-bar-fill {
height: 100%; border-radius: 1px; background: var(--green);
box-shadow: 0 0 6px var(--green-dim);
transition: width 0.55s cubic-bezier(0.4,0,0.2,1);
}
/* Examples */
.section { margin-top: 2rem; }
.section-hdr {
font-family: 'Orbitron', monospace;
font-size: 0.58rem; font-weight: 700; letter-spacing: 0.2em;
text-transform: uppercase; color: var(--green);
text-shadow: 0 0 6px var(--green-dim); margin-bottom: 0.75rem;
}
.chip {
display: inline-block; margin: 0 0.35rem 0.45rem 0;
padding: 0.35rem 0.8rem;
font-family: 'Share Tech Mono', monospace; font-size: 0.69rem;
color: var(--text-mid); background: var(--card); border: 1px solid var(--border);
border-radius: 1px; cursor: pointer; transition: all 0.15s;
clip-path: polygon(0 0, calc(100% - 6px) 0, 100% 6px, 100% 100%, 6px 100%, 0 calc(100% - 6px));
}
.chip:hover { color: var(--green); border-color: var(--green-dim); background: var(--green-bg); box-shadow: 0 0 10px var(--green-glow); }
hr { border: none; border-top: 1px solid var(--border); margin: 2rem 0; }
.ref-table { width: 100%; border-collapse: collapse; font-size: 0.75rem; }
.ref-table th {
font-family: 'Share Tech Mono', monospace; color: var(--green);
text-align: left; padding: 7px 12px; border-bottom: 1px solid var(--green-dim); letter-spacing: 0.05em;
}
.ref-table td { color: var(--text-mid); padding: 7px 12px; border-bottom: 1px solid var(--border); font-weight: 300; }
.ref-table td em { color: var(--green); font-style: normal; }
.ref-table tr:hover td { color: var(--green); background: var(--green-bg); }
::-webkit-scrollbar { width: 5px; }
::-webkit-scrollbar-track { background: var(--void); }
::-webkit-scrollbar-thumb { background: var(--border); border-radius: 2px; }
::-webkit-scrollbar-thumb:hover { background: var(--green-dim); }
</style>
</head>
<body>
<div class="container">
<div class="header">
<div class="eyebrow">// coalitus · cbt analysis · v1.0</div>
<h1>Cognitive Distortion <em>Detector</em></h1>
<p>Analyses text for five CBT cognitive distortions: overgeneralization, catastrophizing, black-and-white thinking, self-blame, and mind-reading.</p>
<div class="notice">
<strong>⚕️ Educational tool only</strong> — not a substitute for professional mental health support.
Raw scores are typically <code>0.002–0.009</code> (conservative classifier head).
Results reflect <em>relative</em> confidence rather than absolute certainty.
</div>
</div>
<div class="input-row">
<div class="card">
<div class="field-label">Enter your text</div>
<textarea id="text-input" placeholder="e.g. I always ruin everything. Nobody will ever trust me again."></textarea>
</div>
<div class="threshold-card">
<div class="field-label">Detection Threshold</div>
<div class="slider-val-row">
<span class="slider-val" id="threshold-val">0.005</span>
<span class="slider-sub">sensitivity</span>
</div>
<div class="slider-wrap">
<div class="slider-track-bg"></div>
<div class="slider-track-fill" id="slider-fill" style="width:20%"></div>
<input type="range" id="threshold-slider" min="0.001" max="0.020" step="0.001" value="0.005"
oninput="updateThreshold(this.value)"/>
</div>
<div class="slider-hint">Lower = more sensitive.<br/>Typical scores: 0.002–0.009</div>
<button class="btn-run" id="run-btn" onclick="runAnalyse()">⚡ Analyse Text</button>
</div>
</div>
<div class="results-row">
<div class="result-panel" id="detected-panel">
<div class="panel-title" id="detected-title">Detected Distortions</div>
<div id="detected-list"></div>
</div>
<div class="result-panel" id="scores-panel">
<div class="panel-title">Raw Scores</div>
<div id="scores-list"></div>
</div>
</div>
<div class="section">
<div class="section-hdr">// try an example</div>
<div id="chips"></div>
</div>
<hr/>
<div class="section-hdr">// distortion reference</div>
<table class="ref-table">
<thead><tr><th>Distortion</th><th>What It Looks Like</th></tr></thead>
<tbody>
<tr><td>🔁 Overgeneralization</td><td>"I <em>always</em> fail", "Nobody <em>ever</em> helps me"</td></tr>
<tr><td>💥 Catastrophizing</td> <td>"This is a <em>disaster</em>", "Everything is <em>ruined</em>"</td></tr>
<tr><td>⚫⚪ Black &amp; White</td><td>"Either perfect or a complete failure"</td></tr>
<tr><td>👤 Self-Blame</td> <td>"It's <em>all my fault</em>" even when it isn't</td></tr>
<tr><td>🔮 Mind Reading</td> <td>"They <em>must</em> think I'm stupid"</td></tr>
</tbody>
</table>
</div>
<script>
const EXAMPLES = [
"I always mess everything up. I can't do anything right.",
"This is going to be a complete disaster. Everything will fall apart.",
"Either I succeed perfectly or I'm a total failure — there's no in between.",
"It's all my fault the team didn't hit the target.",
"They didn't reply — they must be angry at me.",
"It was a hard week but I handled most of it pretty well.",
];
function updateThreshold(val) {
document.getElementById('threshold-val').textContent = parseFloat(val).toFixed(3);
const pct = ((val - 0.001) / (0.020 - 0.001)) * 100;
document.getElementById('slider-fill').style.width = pct + '%';
}
const chipsEl = document.getElementById('chips');
EXAMPLES.forEach(ex => {
const chip = document.createElement('span');
chip.className = 'chip';
chip.textContent = ex.length > 55 ? ex.slice(0, 55) + '…' : ex;
chip.title = ex;
chip.onclick = () => { document.getElementById('text-input').value = ex; runAnalyse(); };
chipsEl.appendChild(chip);
});
async function runAnalyse() {
const text = document.getElementById('text-input').value.trim();
const threshold = parseFloat(document.getElementById('threshold-slider').value);
if (!text) return;
const btn = document.getElementById('run-btn');
btn.disabled = true;
btn.textContent = '⏳ Analysing...';
try {
const res = await fetch('/predict', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ text, threshold }),
});
if (!res.ok) throw new Error('Server error ' + res.status);
renderResults(await res.json());
} catch (err) {
document.getElementById('detected-panel').classList.add('visible');
document.getElementById('detected-list').innerHTML =
'<div class="d-name" style="color:#ff3c5a">// ERROR</div>' +
'<div class="d-desc">' + err.message + '</div>';
} finally {
btn.disabled = false;
btn.textContent = '⚡ Analyse Text';
}
}
function renderResults(data) {
const detectedPanel = document.getElementById('detected-panel');
const detectedList = document.getElementById('detected-list');
const detectedTitle = document.getElementById('detected-title');
detectedPanel.classList.add('visible');
if (!data.any_detected) {
detectedTitle.textContent = '// result';
detectedList.innerHTML = `
<div class="clean-box">
<h3>✅ No Distortions Detected</h3>
<p>Thinking pattern appears healthy at this threshold.<br/>
Try lowering the threshold slider if you expected a result.</p>
</div>`;
} else {
detectedTitle.textContent = '// detected (' + data.detected.length + ')';
detectedList.innerHTML = data.detected.map(d => `
<div class="distortion-item">
<div class="d-name">${d.emoji} ${d.pretty}</div>
<div class="d-desc">${d.description}</div>
<div class="d-score">// score: ${d.score.toFixed(5)}</div>
</div>`).join('');
}
const maxScore = Math.max(...data.all_scores.map(s => s.score));
document.getElementById('scores-panel').classList.add('visible');
document.getElementById('scores-list').innerHTML = data.all_scores.map(s => {
const barPct = maxScore > 0 ? ((s.score / maxScore) * 100).toFixed(1) : 0;
const hi = s.detected ? ';color:var(--green);text-shadow:0 0 6px var(--green)' : '';
return `
<div class="prob-row">
<div class="prob-header" style="${hi}">
<span>${s.emoji} ${s.pretty}</span>
<span class="prob-score">${s.score.toFixed(5)}</span>
</div>
<div class="prob-bar-bg">
<div class="prob-bar-fill" style="width:${barPct}%;${s.detected ? '' : 'opacity:0.3'}"></div>
</div>
</div>`;
}).join('');
}
document.getElementById('text-input').addEventListener('keydown', e => {
if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) runAnalyse();
});
</script>
</body>
</html>"""
@app.get("/", include_in_schema=False)
def index() -> HTMLResponse:
return HTMLResponse(content=FRONTEND_HTML)
if __name__ == "__main__":
uvicorn.run(app, host="0.0.0.0", port=7860)