Upload 33 files
Browse files- .dockerignore +12 -0
- .gitattributes +35 -35
- .gitignore +5 -0
- Dockerfile +33 -12
- README.md +296 -10
- app/__init__.py +0 -0
- app/__pycache__/__init__.cpython-311.pyc +0 -0
- app/__pycache__/classifier.cpython-311.pyc +0 -0
- app/__pycache__/config.cpython-311.pyc +0 -0
- app/__pycache__/main.cpython-311.pyc +0 -0
- app/__pycache__/schemas.cpython-311.pyc +0 -0
- app/__pycache__/store.cpython-311.pyc +0 -0
- app/__pycache__/translator.cpython-311.pyc +0 -0
- app/classifier.py +251 -0
- app/config.py +67 -0
- app/main.py +239 -0
- app/schemas.py +66 -0
- app/static/css/style.css +596 -0
- app/static/js/app.js +87 -0
- app/static/js/classify.js +127 -0
- app/static/js/history.js +39 -0
- app/static/js/labels.js +124 -0
- app/store.py +193 -0
- app/templates/base.html +75 -0
- app/templates/history.html +103 -0
- app/templates/index.html +144 -0
- app/templates/labels.html +94 -0
- app/translator.py +194 -0
- data/complaints.db +0 -0
- requirements.txt +21 -6
- run.bat +3 -0
- run.sh +3 -0
- scripts/bench.py +124 -0
.dockerignore
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
__pycache__/
|
| 2 |
+
*.pyc
|
| 3 |
+
*.pyo
|
| 4 |
+
.git/
|
| 5 |
+
.gitignore
|
| 6 |
+
data/
|
| 7 |
+
.venv/
|
| 8 |
+
venv/
|
| 9 |
+
*.db
|
| 10 |
+
*.db-wal
|
| 11 |
+
*.db-shm
|
| 12 |
+
.DS_Store
|
.gitattributes
CHANGED
|
@@ -1,35 +1,35 @@
|
|
| 1 |
-
*.7z filter=lfs diff=lfs merge=lfs -text
|
| 2 |
-
*.arrow filter=lfs diff=lfs merge=lfs -text
|
| 3 |
-
*.bin filter=lfs diff=lfs merge=lfs -text
|
| 4 |
-
*.bz2 filter=lfs diff=lfs merge=lfs -text
|
| 5 |
-
*.ckpt filter=lfs diff=lfs merge=lfs -text
|
| 6 |
-
*.ftz filter=lfs diff=lfs merge=lfs -text
|
| 7 |
-
*.gz filter=lfs diff=lfs merge=lfs -text
|
| 8 |
-
*.h5 filter=lfs diff=lfs merge=lfs -text
|
| 9 |
-
*.joblib filter=lfs diff=lfs merge=lfs -text
|
| 10 |
-
*.lfs.* filter=lfs diff=lfs merge=lfs -text
|
| 11 |
-
*.mlmodel filter=lfs diff=lfs merge=lfs -text
|
| 12 |
-
*.model filter=lfs diff=lfs merge=lfs -text
|
| 13 |
-
*.msgpack filter=lfs diff=lfs merge=lfs -text
|
| 14 |
-
*.npy filter=lfs diff=lfs merge=lfs -text
|
| 15 |
-
*.npz filter=lfs diff=lfs merge=lfs -text
|
| 16 |
-
*.onnx filter=lfs diff=lfs merge=lfs -text
|
| 17 |
-
*.ot filter=lfs diff=lfs merge=lfs -text
|
| 18 |
-
*.parquet filter=lfs diff=lfs merge=lfs -text
|
| 19 |
-
*.pb filter=lfs diff=lfs merge=lfs -text
|
| 20 |
-
*.pickle filter=lfs diff=lfs merge=lfs -text
|
| 21 |
-
*.pkl filter=lfs diff=lfs merge=lfs -text
|
| 22 |
-
*.pt filter=lfs diff=lfs merge=lfs -text
|
| 23 |
-
*.pth filter=lfs diff=lfs merge=lfs -text
|
| 24 |
-
*.rar filter=lfs diff=lfs merge=lfs -text
|
| 25 |
-
*.safetensors filter=lfs diff=lfs merge=lfs -text
|
| 26 |
-
saved_model/**/* filter=lfs diff=lfs merge=lfs -text
|
| 27 |
-
*.tar.* filter=lfs diff=lfs merge=lfs -text
|
| 28 |
-
*.tar filter=lfs diff=lfs merge=lfs -text
|
| 29 |
-
*.tflite filter=lfs diff=lfs merge=lfs -text
|
| 30 |
-
*.tgz filter=lfs diff=lfs merge=lfs -text
|
| 31 |
-
*.wasm filter=lfs diff=lfs merge=lfs -text
|
| 32 |
-
*.xz filter=lfs diff=lfs merge=lfs -text
|
| 33 |
-
*.zip filter=lfs diff=lfs merge=lfs -text
|
| 34 |
-
*.zst filter=lfs diff=lfs merge=lfs -text
|
| 35 |
-
*tfevents* filter=lfs diff=lfs merge=lfs -text
|
|
|
|
| 1 |
+
*.7z filter=lfs diff=lfs merge=lfs -text
|
| 2 |
+
*.arrow filter=lfs diff=lfs merge=lfs -text
|
| 3 |
+
*.bin filter=lfs diff=lfs merge=lfs -text
|
| 4 |
+
*.bz2 filter=lfs diff=lfs merge=lfs -text
|
| 5 |
+
*.ckpt filter=lfs diff=lfs merge=lfs -text
|
| 6 |
+
*.ftz filter=lfs diff=lfs merge=lfs -text
|
| 7 |
+
*.gz filter=lfs diff=lfs merge=lfs -text
|
| 8 |
+
*.h5 filter=lfs diff=lfs merge=lfs -text
|
| 9 |
+
*.joblib filter=lfs diff=lfs merge=lfs -text
|
| 10 |
+
*.lfs.* filter=lfs diff=lfs merge=lfs -text
|
| 11 |
+
*.mlmodel filter=lfs diff=lfs merge=lfs -text
|
| 12 |
+
*.model filter=lfs diff=lfs merge=lfs -text
|
| 13 |
+
*.msgpack filter=lfs diff=lfs merge=lfs -text
|
| 14 |
+
*.npy filter=lfs diff=lfs merge=lfs -text
|
| 15 |
+
*.npz filter=lfs diff=lfs merge=lfs -text
|
| 16 |
+
*.onnx filter=lfs diff=lfs merge=lfs -text
|
| 17 |
+
*.ot filter=lfs diff=lfs merge=lfs -text
|
| 18 |
+
*.parquet filter=lfs diff=lfs merge=lfs -text
|
| 19 |
+
*.pb filter=lfs diff=lfs merge=lfs -text
|
| 20 |
+
*.pickle filter=lfs diff=lfs merge=lfs -text
|
| 21 |
+
*.pkl filter=lfs diff=lfs merge=lfs -text
|
| 22 |
+
*.pt filter=lfs diff=lfs merge=lfs -text
|
| 23 |
+
*.pth filter=lfs diff=lfs merge=lfs -text
|
| 24 |
+
*.rar filter=lfs diff=lfs merge=lfs -text
|
| 25 |
+
*.safetensors filter=lfs diff=lfs merge=lfs -text
|
| 26 |
+
saved_model/**/* filter=lfs diff=lfs merge=lfs -text
|
| 27 |
+
*.tar.* filter=lfs diff=lfs merge=lfs -text
|
| 28 |
+
*.tar filter=lfs diff=lfs merge=lfs -text
|
| 29 |
+
*.tflite filter=lfs diff=lfs merge=lfs -text
|
| 30 |
+
*.tgz filter=lfs diff=lfs merge=lfs -text
|
| 31 |
+
*.wasm filter=lfs diff=lfs merge=lfs -text
|
| 32 |
+
*.xz filter=lfs diff=lfs merge=lfs -text
|
| 33 |
+
*.zip filter=lfs diff=lfs merge=lfs -text
|
| 34 |
+
*.zst filter=lfs diff=lfs merge=lfs -text
|
| 35 |
+
*tfevents* filter=lfs diff=lfs merge=lfs -text
|
.gitignore
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
__pycache__/
|
| 2 |
+
*.pyc
|
| 3 |
+
data/
|
| 4 |
+
.venv/
|
| 5 |
+
venv/
|
Dockerfile
CHANGED
|
@@ -1,27 +1,48 @@
|
|
| 1 |
-
#
|
| 2 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3 |
|
| 4 |
-
# Set the working directory to /code
|
| 5 |
WORKDIR /code
|
| 6 |
|
| 7 |
-
#
|
| 8 |
-
|
|
|
|
|
|
|
| 9 |
|
| 10 |
-
|
| 11 |
RUN pip install --no-cache-dir --upgrade -r /code/requirements.txt
|
| 12 |
|
| 13 |
# Set up a new user named "user" with user ID 1000
|
| 14 |
RUN useradd -m -u 1000 user
|
| 15 |
-
# Switch to the "user" user
|
| 16 |
USER user
|
| 17 |
-
# Set home to the user's home directory
|
| 18 |
ENV HOME=/home/user \
|
| 19 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 20 |
|
| 21 |
-
# Set the working directory to the user's home directory
|
| 22 |
WORKDIR $HOME/app
|
| 23 |
|
| 24 |
-
#
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 25 |
COPY --chown=user . $HOME/app
|
| 26 |
|
| 27 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Hugging Face Space (SDK: docker) -- listens on 7860.
|
| 2 |
+
#
|
| 3 |
+
# NOTE: python:3.9 from the stock HF template does NOT work for this app.
|
| 4 |
+
# FastAPI/Pydantic evaluate annotations like `str | None` at runtime, which
|
| 5 |
+
# requires Python 3.10+. 3.11 matches the development environment.
|
| 6 |
+
FROM python:3.11-slim
|
| 7 |
|
|
|
|
| 8 |
WORKDIR /code
|
| 9 |
|
| 10 |
+
# Install CPU-only torch first -- the default PyPI wheel pulls ~2.5GB of CUDA
|
| 11 |
+
# libraries that a CPU Space can never use.
|
| 12 |
+
RUN pip install --no-cache-dir --upgrade pip && \
|
| 13 |
+
pip install --no-cache-dir torch --index-url https://download.pytorch.org/whl/cpu
|
| 14 |
|
| 15 |
+
COPY ./requirements.txt /code/requirements.txt
|
| 16 |
RUN pip install --no-cache-dir --upgrade -r /code/requirements.txt
|
| 17 |
|
| 18 |
# Set up a new user named "user" with user ID 1000
|
| 19 |
RUN useradd -m -u 1000 user
|
|
|
|
| 20 |
USER user
|
|
|
|
| 21 |
ENV HOME=/home/user \
|
| 22 |
+
PATH=/home/user/.local/bin:$PATH
|
| 23 |
+
|
| 24 |
+
# Model + SQLite both need writable paths. On a Space, $HOME is the only one.
|
| 25 |
+
ENV HF_HOME=/home/user/.cache/huggingface \
|
| 26 |
+
DATA_DIR=/home/user/app/data \
|
| 27 |
+
MODEL_NAME=typeform/distilbert-base-uncased-mnli \
|
| 28 |
+
PRELOAD_MODEL=1 \
|
| 29 |
+
PYTHONUNBUFFERED=1
|
| 30 |
|
|
|
|
| 31 |
WORKDIR $HOME/app
|
| 32 |
|
| 33 |
+
# Bake the model into the image so the Space answers the first request fast
|
| 34 |
+
# instead of downloading ~250MB on cold start.
|
| 35 |
+
RUN python -c "\
|
| 36 |
+
from transformers import pipeline; \
|
| 37 |
+
import os; \
|
| 38 |
+
pipeline('zero-shot-classification', model=os.environ['MODEL_NAME'], device=-1)" \
|
| 39 |
+
&& echo "model cached"
|
| 40 |
+
|
| 41 |
COPY --chown=user . $HOME/app
|
| 42 |
|
| 43 |
+
RUN mkdir -p $HOME/app/data
|
| 44 |
+
|
| 45 |
+
EXPOSE 7860
|
| 46 |
+
|
| 47 |
+
# app.main:app -- the FastAPI instance lives in the `app` package, not main.py
|
| 48 |
+
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "7860"]
|
README.md
CHANGED
|
@@ -1,10 +1,296 @@
|
|
| 1 |
-
---
|
| 2 |
-
title:
|
| 3 |
-
emoji:
|
| 4 |
-
colorFrom:
|
| 5 |
-
colorTo:
|
| 6 |
-
sdk: docker
|
| 7 |
-
|
| 8 |
-
|
| 9 |
-
|
| 10 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
---
|
| 2 |
+
title: Complaint Classifier
|
| 3 |
+
emoji: 📝
|
| 4 |
+
colorFrom: blue
|
| 5 |
+
colorTo: indigo
|
| 6 |
+
sdk: docker
|
| 7 |
+
app_port: 7860
|
| 8 |
+
pinned: false
|
| 9 |
+
short_description: Multilingual complaint classifier with runtime labels
|
| 10 |
+
---
|
| 11 |
+
|
| 12 |
+
# Complaint Classifier
|
| 13 |
+
|
| 14 |
+
A FastAPI app with a web UI that takes a public complaint **in any language**,
|
| 15 |
+
detects the language, translates it to English with Google Translate, and
|
| 16 |
+
classifies it into a problem type (Water Supply, Electricity, Road, Waste, …)
|
| 17 |
+
using a **small pretrained zero-shot model**.
|
| 18 |
+
|
| 19 |
+
The labels are **not baked into the model**. You edit them in the UI and the very
|
| 20 |
+
next classification uses them — no retraining, no restart.
|
| 21 |
+
|
| 22 |
+
---
|
| 23 |
+
|
| 24 |
+
## Why zero-shot
|
| 25 |
+
|
| 26 |
+
A normal fine-tuned text classifier has its labels frozen at training time; adding
|
| 27 |
+
"Street Lighting" would mean collecting data and retraining.
|
| 28 |
+
|
| 29 |
+
This app uses a **zero-shot NLI classifier** instead. The candidate labels are an
|
| 30 |
+
*input* to every inference call. The model checks "does this complaint entail the
|
| 31 |
+
hypothesis *This complaint is about broken street lights*?" for each label and
|
| 32 |
+
ranks them. That is what makes runtime-editable labels possible.
|
| 33 |
+
|
| 34 |
+
Default model: [`typeform/distilbert-base-uncased-mnli`](https://huggingface.co/typeform/distilbert-base-uncased-mnli)
|
| 35 |
+
— ~250 MB, CPU-friendly, ~100–400 ms per complaint on a laptop.
|
| 36 |
+
|
| 37 |
+
---
|
| 38 |
+
|
| 39 |
+
## Pipeline
|
| 40 |
+
|
| 41 |
+
```
|
| 42 |
+
complaint (any language)
|
| 43 |
+
│
|
| 44 |
+
▼
|
| 45 |
+
langdetect → ISO code + confidence (offline, instant)
|
| 46 |
+
│
|
| 47 |
+
▼
|
| 48 |
+
deep-translator → Google Translate → English
|
| 49 |
+
(skipped when already confidently English)
|
| 50 |
+
│
|
| 51 |
+
▼
|
| 52 |
+
zero-shot NLI model → score every ACTIVE label from the database
|
| 53 |
+
│
|
| 54 |
+
▼
|
| 55 |
+
top label + confidence + full score breakdown → saved to SQLite
|
| 56 |
+
```
|
| 57 |
+
|
| 58 |
+
---
|
| 59 |
+
|
| 60 |
+
## Install & run
|
| 61 |
+
|
| 62 |
+
```bash
|
| 63 |
+
pip install -r requirements.txt
|
| 64 |
+
|
| 65 |
+
# Windows
|
| 66 |
+
run.bat
|
| 67 |
+
# macOS / Linux
|
| 68 |
+
./run.sh
|
| 69 |
+
# or directly
|
| 70 |
+
python -m uvicorn app.main:app --reload
|
| 71 |
+
```
|
| 72 |
+
|
| 73 |
+
Then open **http://127.0.0.1:8000**.
|
| 74 |
+
|
| 75 |
+
The first start downloads the model (~250 MB) in a background thread, so the UI is
|
| 76 |
+
usable immediately — the dot in the top-right shows `loading model…` → `model ready`.
|
| 77 |
+
|
| 78 |
+
---
|
| 79 |
+
|
| 80 |
+
## Pages
|
| 81 |
+
|
| 82 |
+
| Page | What it does |
|
| 83 |
+
| --- | --- |
|
| 84 |
+
| `/` | Submit a complaint, see the predicted category, detected language, English translation and a score bar for **every** label. |
|
| 85 |
+
| `/labels` | Add / rename / describe / activate / delete labels. Changes are live instantly. |
|
| 86 |
+
| `/history` | Every classified complaint with per-category counts, plus a text filter. |
|
| 87 |
+
| `/docs` | Auto-generated OpenAPI docs (Swagger UI). |
|
| 88 |
+
|
| 89 |
+
---
|
| 90 |
+
|
| 91 |
+
## API
|
| 92 |
+
|
| 93 |
+
### `POST /api/classify`
|
| 94 |
+
|
| 95 |
+
```json
|
| 96 |
+
{
|
| 97 |
+
"text": "අපේ ගමේ දින තුනක් තිස්සේ වතුර නැහැ",
|
| 98 |
+
"translate": true,
|
| 99 |
+
"multi_label": false,
|
| 100 |
+
"threshold": 0.35,
|
| 101 |
+
"save": true,
|
| 102 |
+
"labels": null
|
| 103 |
+
}
|
| 104 |
+
```
|
| 105 |
+
|
| 106 |
+
Response:
|
| 107 |
+
|
| 108 |
+
```json
|
| 109 |
+
{
|
| 110 |
+
"id": 12,
|
| 111 |
+
"predicted_label": "Water Supply",
|
| 112 |
+
"confidence": 0.8421,
|
| 113 |
+
"confident": true,
|
| 114 |
+
"threshold": 0.35,
|
| 115 |
+
"scores": [
|
| 116 |
+
{"label": "Water Supply", "score": 0.8421},
|
| 117 |
+
{"label": "Public Health", "score": 0.0612}
|
| 118 |
+
],
|
| 119 |
+
"translation": {
|
| 120 |
+
"source_lang": "si",
|
| 121 |
+
"source_lang_name": "Sinhala",
|
| 122 |
+
"detection_confidence": 0.9999,
|
| 123 |
+
"was_translated": true,
|
| 124 |
+
"original_text": "අපේ ගමේ දින තුනක් තිස්සේ වතුර නැහැ",
|
| 125 |
+
"translated_text": "There is no water in our village for three days",
|
| 126 |
+
"note": ""
|
| 127 |
+
},
|
| 128 |
+
"engine": "zero-shot:typeform/distilbert-base-uncased-mnli",
|
| 129 |
+
"multi_label": false,
|
| 130 |
+
"took_ms": 187
|
| 131 |
+
}
|
| 132 |
+
```
|
| 133 |
+
|
| 134 |
+
Pass `"labels": ["Water", "Electricity"]` to classify against an ad-hoc set for one
|
| 135 |
+
request only, without touching the saved labels.
|
| 136 |
+
|
| 137 |
+
### Labels
|
| 138 |
+
|
| 139 |
+
| Method | Path | Purpose |
|
| 140 |
+
| --- | --- | --- |
|
| 141 |
+
| `GET` | `/api/labels?active_only=true` | List labels |
|
| 142 |
+
| `POST` | `/api/labels` | Add `{name, description, active}` |
|
| 143 |
+
| `PATCH` | `/api/labels/{id}` | Update any subset of fields |
|
| 144 |
+
| `DELETE` | `/api/labels/{id}` | Remove a label |
|
| 145 |
+
|
| 146 |
+
### Other
|
| 147 |
+
|
| 148 |
+
`GET /api/history` · `GET /api/stats` · `DELETE /api/history` ·
|
| 149 |
+
`GET /api/health` · `POST /api/model/reload`
|
| 150 |
+
|
| 151 |
+
---
|
| 152 |
+
|
| 153 |
+
## Configuration
|
| 154 |
+
|
| 155 |
+
All optional, via environment variables:
|
| 156 |
+
|
| 157 |
+
| Variable | Default | Meaning |
|
| 158 |
+
| --- | --- | --- |
|
| 159 |
+
| `MODEL_NAME` | `typeform/distilbert-base-uncased-mnli` | Any HF zero-shot/NLI model |
|
| 160 |
+
| `HYPOTHESIS_TEMPLATE` | `This complaint is about {}.` | Steers the NLI model |
|
| 161 |
+
| `LEXICAL_WEIGHT` | `0.6` | Weight of the description keyword prior (0 = pure model) |
|
| 162 |
+
| `CONFIDENCE_THRESHOLD` | `0.35` | Below this the result is flagged low-confidence |
|
| 163 |
+
| `PRELOAD_MODEL` | `1` | Load at startup instead of first request |
|
| 164 |
+
| `TRANSLATE_ENABLED` | `1` | Set `0` to skip Google Translate entirely |
|
| 165 |
+
| `DATA_DIR` | `./data` | Where `complaints.db` lives |
|
| 166 |
+
| `DISABLE_XET` | `0` | Set `1` if model downloads hang at 0 bytes (see below) |
|
| 167 |
+
| `MODEL_OFFLINE` | `0` | Set `1` to use only the local HF cache, never the network |
|
| 168 |
+
| `MODEL_DOWNLOAD_TIMEOUT` | `30` | Seconds a download chunk may stall before failing |
|
| 169 |
+
|
| 170 |
+
Alternatives for `MODEL_NAME` — **benchmark before switching**:
|
| 171 |
+
|
| 172 |
+
- `MoritzLaurer/deberta-v3-xsmall-zeroshot-v1.1-all-33` (~146 MB). Far more
|
| 173 |
+
confident (0.80 vs 0.24 on the same complaint) but measured at **~41 s per
|
| 174 |
+
inference** on the development CPU against distilbert's **~0.4 s**. That is
|
| 175 |
+
100× slower and unusable for real-time intake, so it is not the default
|
| 176 |
+
despite the better scores. Needs `sentencepiece` + `protobuf`.
|
| 177 |
+
- `valhalla/distilbart-mnli-12-1` (~890 MB)
|
| 178 |
+
- `facebook/bart-large-mnli` (~1.6 GB, the reference model)
|
| 179 |
+
|
| 180 |
+
Run `python scripts/bench.py <model>` to check both accuracy and latency on your
|
| 181 |
+
own hardware — the latency gap above may be specific to this CPU/torch build.
|
| 182 |
+
|
| 183 |
+
---
|
| 184 |
+
|
| 185 |
+
## Getting good accuracy
|
| 186 |
+
|
| 187 |
+
**Keep label names short and plain; put the vocabulary in the description.**
|
| 188 |
+
|
| 189 |
+
The label *name* is the model's hypothesis ("This complaint is about **Electricity**.")
|
| 190 |
+
— NLI models want a clean noun phrase there, so `Electricity` beats
|
| 191 |
+
`Electricity / power / outages etc`.
|
| 192 |
+
|
| 193 |
+
The *description* is a separate keyword prior blended into the score
|
| 194 |
+
(`LEXICAL_WEIGHT`, default `0.6`). This is where the domain vocabulary belongs:
|
| 195 |
+
|
| 196 |
+
> `Electricity` → *"power cut, outage, voltage fluctuation, broken street light,
|
| 197 |
+
> damaged electric pole or hanging wire"*
|
| 198 |
+
|
| 199 |
+
This split matters. Measured on the bundled 18-case benchmark:
|
| 200 |
+
|
| 201 |
+
| candidates | accuracy |
|
| 202 |
+
| --- | --- |
|
| 203 |
+
| descriptions as the hypothesis (the obvious-looking choice) | 0/18 |
|
| 204 |
+
| label names, no blend (`LEXICAL_WEIGHT=0`) | 10/18 |
|
| 205 |
+
| label names + description prior at `0.6` | **16/18 (89%)** |
|
| 206 |
+
|
| 207 |
+
Reproduce with `python scripts/bench.py`.
|
| 208 |
+
|
| 209 |
+
Other levers: raise `CONFIDENCE_THRESHOLD` if you would rather route ambiguous
|
| 210 |
+
complaints to a human than mislabel them; keep an `Other` label active so
|
| 211 |
+
off-topic complaints have somewhere to land; use `multi_label` when a single
|
| 212 |
+
complaint can legitimately belong to two categories.
|
| 213 |
+
|
| 214 |
+
---
|
| 215 |
+
|
| 216 |
+
## Troubleshooting
|
| 217 |
+
|
| 218 |
+
**The model download hangs at 0 bytes.** Some corporate and ISP networks block
|
| 219 |
+
Hugging Face's Xet storage backend while ordinary HTTPS to `huggingface.co` still
|
| 220 |
+
works, so the download never starts and never errors. Force the classic CDN path:
|
| 221 |
+
|
| 222 |
+
```bash
|
| 223 |
+
# Windows
|
| 224 |
+
set DISABLE_XET=1
|
| 225 |
+
# macOS / Linux
|
| 226 |
+
export DISABLE_XET=1
|
| 227 |
+
```
|
| 228 |
+
|
| 229 |
+
This was hit on the development machine — 0 bytes after 15 minutes with Xet, normal
|
| 230 |
+
throughput immediately after disabling it.
|
| 231 |
+
|
| 232 |
+
**The status dot says `keyword fallback`.** The model failed to load. `GET /api/health`
|
| 233 |
+
returns the full error under `model.error`. The app keeps working on the keyword
|
| 234 |
+
scorer until you fix it and call `POST /api/model/reload`.
|
| 235 |
+
|
| 236 |
+
---
|
| 237 |
+
|
| 238 |
+
## Behaviour when things are offline
|
| 239 |
+
|
| 240 |
+
- **No network for Google Translate** → the complaint is classified in its original
|
| 241 |
+
language and the response carries a `note` explaining why. Nothing is lost.
|
| 242 |
+
- **Model can't be downloaded** → a keyword-overlap fallback scorer keeps the app
|
| 243 |
+
working, and `engine` reports `keyword-fallback` so you always know which path
|
| 244 |
+
produced a result.
|
| 245 |
+
|
| 246 |
+
---
|
| 247 |
+
|
| 248 |
+
## Deploying to Hugging Face Spaces
|
| 249 |
+
|
| 250 |
+
The repo ships a `Dockerfile` for a Space with `sdk: docker` (set in the README
|
| 251 |
+
frontmatter above, together with `app_port: 7860`).
|
| 252 |
+
|
| 253 |
+
```bash
|
| 254 |
+
git clone https://huggingface.co/spaces/Arafath10/textclsy
|
| 255 |
+
cd textclsy
|
| 256 |
+
# copy this project in, then
|
| 257 |
+
git add -A && git commit -m "Complaint classifier" && git push
|
| 258 |
+
```
|
| 259 |
+
|
| 260 |
+
Pushing needs an HF token with **write** access:
|
| 261 |
+
|
| 262 |
+
```bash
|
| 263 |
+
huggingface-cli login # or:
|
| 264 |
+
git remote set-url origin https://<user>:<hf_token>@huggingface.co/spaces/Arafath10/textclsy
|
| 265 |
+
```
|
| 266 |
+
|
| 267 |
+
Three things differ from the stock HF Docker template, all deliberate:
|
| 268 |
+
|
| 269 |
+
1. **`python:3.11-slim`, not `python:3.9`.** FastAPI and Pydantic evaluate
|
| 270 |
+
annotations such as `str | None` at runtime; that syntax is a `TypeError` on 3.9.
|
| 271 |
+
2. **CPU-only torch.** The default PyPI wheel drags in ~2.5 GB of CUDA libraries a
|
| 272 |
+
CPU Space can never use, so torch is installed from the PyTorch CPU index first.
|
| 273 |
+
3. **The model is baked into the image** at build time, so a cold start serves the
|
| 274 |
+
first request immediately instead of downloading ~250 MB.
|
| 275 |
+
|
| 276 |
+
Note that a Space's filesystem is ephemeral — `data/complaints.db` resets when the
|
| 277 |
+
Space restarts or rebuilds. Attach a persistent volume, or point `DATA_DIR` at one,
|
| 278 |
+
if the complaint history has to survive.
|
| 279 |
+
|
| 280 |
+
---
|
| 281 |
+
|
| 282 |
+
## Layout
|
| 283 |
+
|
| 284 |
+
```
|
| 285 |
+
app/
|
| 286 |
+
main.py FastAPI routes (pages + JSON API)
|
| 287 |
+
classifier.py zero-shot pipeline, keyword fallback, model state
|
| 288 |
+
translator.py langdetect + Google Translate, degrades gracefully
|
| 289 |
+
store.py SQLite: labels + complaint history
|
| 290 |
+
config.py env-var configuration and seed labels
|
| 291 |
+
schemas.py Pydantic request/response models
|
| 292 |
+
templates/ Jinja2 pages
|
| 293 |
+
static/ CSS + vanilla JS (no build step)
|
| 294 |
+
data/
|
| 295 |
+
complaints.db created on first run
|
| 296 |
+
```
|
app/__init__.py
ADDED
|
File without changes
|
app/__pycache__/__init__.cpython-311.pyc
ADDED
|
Binary file (137 Bytes). View file
|
|
|
app/__pycache__/classifier.cpython-311.pyc
ADDED
|
Binary file (15.3 kB). View file
|
|
|
app/__pycache__/config.cpython-311.pyc
ADDED
|
Binary file (2.84 kB). View file
|
|
|
app/__pycache__/main.cpython-311.pyc
ADDED
|
Binary file (13.5 kB). View file
|
|
|
app/__pycache__/schemas.cpython-311.pyc
ADDED
|
Binary file (4.31 kB). View file
|
|
|
app/__pycache__/store.cpython-311.pyc
ADDED
|
Binary file (13 kB). View file
|
|
|
app/__pycache__/translator.cpython-311.pyc
ADDED
|
Binary file (9.94 kB). View file
|
|
|
app/classifier.py
ADDED
|
@@ -0,0 +1,251 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Zero-shot complaint classifier.
|
| 2 |
+
|
| 3 |
+
Zero-shot (NLI) is what makes runtime labels possible: the candidate labels are
|
| 4 |
+
an *input* to every inference call, so adding "Street Lighting" in the UI takes
|
| 5 |
+
effect on the very next complaint -- no retraining, no restart.
|
| 6 |
+
|
| 7 |
+
If transformers/torch are unavailable or the model cannot be downloaded, a
|
| 8 |
+
keyword-overlap fallback keeps the app usable; every response reports which
|
| 9 |
+
engine produced it.
|
| 10 |
+
"""
|
| 11 |
+
from __future__ import annotations
|
| 12 |
+
|
| 13 |
+
import logging
|
| 14 |
+
import math
|
| 15 |
+
import re
|
| 16 |
+
import threading
|
| 17 |
+
import time
|
| 18 |
+
from typing import Any
|
| 19 |
+
|
| 20 |
+
from .config import (
|
| 21 |
+
CONFIDENCE_THRESHOLD,
|
| 22 |
+
HYPOTHESIS_TEMPLATE,
|
| 23 |
+
LEXICAL_WEIGHT,
|
| 24 |
+
MODEL_NAME,
|
| 25 |
+
)
|
| 26 |
+
|
| 27 |
+
log = logging.getLogger("classifier")
|
| 28 |
+
|
| 29 |
+
# Two locks on purpose: _load_lock is held for the whole (possibly very slow)
|
| 30 |
+
# model download, so inference must not wait on it -- a reload would otherwise
|
| 31 |
+
# stall every live request. _infer_lock only guards the pipeline call itself.
|
| 32 |
+
_load_lock = threading.Lock()
|
| 33 |
+
_infer_lock = threading.Lock()
|
| 34 |
+
_pipe: Any = None
|
| 35 |
+
_state: dict[str, Any] = {
|
| 36 |
+
"status": "not_loaded", # not_loaded | loading | ready | failed
|
| 37 |
+
"engine": "none",
|
| 38 |
+
"model": MODEL_NAME,
|
| 39 |
+
"error": "",
|
| 40 |
+
"load_seconds": 0.0,
|
| 41 |
+
}
|
| 42 |
+
|
| 43 |
+
# Candidate phrases longer than this are trimmed -- NLI hypotheses work best short.
|
| 44 |
+
MAX_CANDIDATE_WORDS = 16
|
| 45 |
+
_WORD_RE = re.compile(r"[a-z0-9']+")
|
| 46 |
+
_STOPWORDS = {
|
| 47 |
+
"the", "a", "an", "is", "are", "was", "were", "be", "been", "being", "to", "of",
|
| 48 |
+
"in", "on", "at", "for", "with", "and", "or", "not", "no", "my", "our", "i", "we",
|
| 49 |
+
"it", "this", "that", "there", "here", "from", "by", "as", "has", "have", "had",
|
| 50 |
+
"do", "does", "did", "but", "so", "very", "please", "sir", "madam", "any", "some",
|
| 51 |
+
}
|
| 52 |
+
|
| 53 |
+
|
| 54 |
+
def state() -> dict[str, Any]:
|
| 55 |
+
return dict(_state)
|
| 56 |
+
|
| 57 |
+
|
| 58 |
+
def is_ready() -> bool:
|
| 59 |
+
return _state["status"] == "ready"
|
| 60 |
+
|
| 61 |
+
|
| 62 |
+
def load_model(force: bool = False) -> dict[str, Any]:
|
| 63 |
+
"""Load the zero-shot pipeline. Safe to call repeatedly and concurrently."""
|
| 64 |
+
global _pipe
|
| 65 |
+
with _load_lock:
|
| 66 |
+
if _pipe is not None and not force:
|
| 67 |
+
return state()
|
| 68 |
+
_state.update(status="loading", error="")
|
| 69 |
+
started = time.perf_counter()
|
| 70 |
+
try:
|
| 71 |
+
from transformers import pipeline as hf_pipeline
|
| 72 |
+
|
| 73 |
+
try:
|
| 74 |
+
import torch
|
| 75 |
+
|
| 76 |
+
torch.set_num_threads(max(1, (torch.get_num_threads() or 2) - 1))
|
| 77 |
+
except Exception:
|
| 78 |
+
pass
|
| 79 |
+
|
| 80 |
+
_pipe = hf_pipeline(
|
| 81 |
+
"zero-shot-classification",
|
| 82 |
+
model=MODEL_NAME,
|
| 83 |
+
device=-1, # CPU; small model, fine for real-time single requests
|
| 84 |
+
)
|
| 85 |
+
_state.update(
|
| 86 |
+
status="ready",
|
| 87 |
+
engine=f"zero-shot:{MODEL_NAME}",
|
| 88 |
+
load_seconds=round(time.perf_counter() - started, 2),
|
| 89 |
+
error="",
|
| 90 |
+
)
|
| 91 |
+
log.info("Loaded %s in %.2fs", MODEL_NAME, _state["load_seconds"])
|
| 92 |
+
except Exception as exc:
|
| 93 |
+
_pipe = None
|
| 94 |
+
_state.update(
|
| 95 |
+
status="failed",
|
| 96 |
+
engine="keyword-fallback",
|
| 97 |
+
error=f"{type(exc).__name__}: {exc}",
|
| 98 |
+
load_seconds=round(time.perf_counter() - started, 2),
|
| 99 |
+
)
|
| 100 |
+
log.warning("Model load failed (%s); using keyword fallback.", exc)
|
| 101 |
+
return state()
|
| 102 |
+
|
| 103 |
+
|
| 104 |
+
def _candidate_for(label: dict[str, Any]) -> str:
|
| 105 |
+
"""The NLI hypothesis phrase for a label -- the label NAME, kept short.
|
| 106 |
+
|
| 107 |
+
Feeding the description in here instead was measurably worse: on a 6-case
|
| 108 |
+
benchmark, descriptions as candidates scored 0/6 against 3/6 for bare names.
|
| 109 |
+
An NLI hypothesis wants a clean noun phrase ("Water Supply"), not a
|
| 110 |
+
comma-separated keyword dump. Descriptions are still used -- as a lexical
|
| 111 |
+
prior blended into the scores, see _blend().
|
| 112 |
+
"""
|
| 113 |
+
name = (label.get("name") or "").strip()
|
| 114 |
+
words = name.split()
|
| 115 |
+
if len(words) > MAX_CANDIDATE_WORDS:
|
| 116 |
+
name = " ".join(words[:MAX_CANDIDATE_WORDS])
|
| 117 |
+
return name
|
| 118 |
+
|
| 119 |
+
|
| 120 |
+
def _blend(
|
| 121 |
+
model_scores: list[tuple[str, float]],
|
| 122 |
+
text: str,
|
| 123 |
+
labels: list[dict[str, Any]],
|
| 124 |
+
weight: float,
|
| 125 |
+
multi_label: bool = False,
|
| 126 |
+
) -> list[tuple[str, float]]:
|
| 127 |
+
"""Mix the model scores with a keyword prior built from label descriptions.
|
| 128 |
+
|
| 129 |
+
The descriptions carry real domain signal ("burst pipe", "lamp post") that a
|
| 130 |
+
small NLI model does not get from the label name alone. Blending keeps the
|
| 131 |
+
model in charge while letting those hints break ties.
|
| 132 |
+
|
| 133 |
+
Skipped entirely in multi-label mode: there each score is an INDEPENDENT
|
| 134 |
+
probability, while the lexical prior is a distribution summing to 1 across
|
| 135 |
+
labels. Mixing the two would systematically depress every score and the
|
| 136 |
+
renormalisation below would destroy the independence the caller asked for.
|
| 137 |
+
"""
|
| 138 |
+
if weight <= 0 or multi_label:
|
| 139 |
+
return model_scores
|
| 140 |
+
|
| 141 |
+
lexical = dict(_keyword_scores(text, labels))
|
| 142 |
+
# A flat prior means no keyword matched -- blending it would only dilute.
|
| 143 |
+
if max(lexical.values(), default=0.0) - min(lexical.values(), default=0.0) < 1e-6:
|
| 144 |
+
return model_scores
|
| 145 |
+
|
| 146 |
+
blended = [
|
| 147 |
+
(name, (1.0 - weight) * score + weight * lexical.get(name, 0.0))
|
| 148 |
+
for name, score in model_scores
|
| 149 |
+
]
|
| 150 |
+
total = sum(s for _, s in blended)
|
| 151 |
+
if total > 0:
|
| 152 |
+
blended = [(n, s / total) for n, s in blended]
|
| 153 |
+
return blended
|
| 154 |
+
|
| 155 |
+
|
| 156 |
+
def _tokens(text: str) -> set[str]:
|
| 157 |
+
return {w for w in _WORD_RE.findall(text.lower()) if w not in _STOPWORDS and len(w) > 2}
|
| 158 |
+
|
| 159 |
+
|
| 160 |
+
def _keyword_scores(text: str, labels: list[dict[str, Any]]) -> list[tuple[str, float]]:
|
| 161 |
+
"""Fallback scorer: overlap between complaint tokens and label tokens."""
|
| 162 |
+
text_tokens = _tokens(text)
|
| 163 |
+
raw = []
|
| 164 |
+
for label in labels:
|
| 165 |
+
label_tokens = _tokens(f"{label.get('name','')} {label.get('description','')}")
|
| 166 |
+
if not label_tokens:
|
| 167 |
+
raw.append(0.0)
|
| 168 |
+
continue
|
| 169 |
+
hits = sum(1 for t in label_tokens if t in text_tokens)
|
| 170 |
+
# partial credit for stem-ish prefix matches ("leaking" vs "leak")
|
| 171 |
+
near = sum(
|
| 172 |
+
1 for t in label_tokens
|
| 173 |
+
if t not in text_tokens and any(t.startswith(u[:4]) or u.startswith(t[:4])
|
| 174 |
+
for u in text_tokens if len(u) > 3)
|
| 175 |
+
)
|
| 176 |
+
raw.append(hits + 0.35 * near)
|
| 177 |
+
|
| 178 |
+
if max(raw, default=0.0) <= 0:
|
| 179 |
+
even = 1.0 / len(labels) if labels else 0.0
|
| 180 |
+
return [(l["name"], even) for l in labels]
|
| 181 |
+
|
| 182 |
+
exps = [math.exp(s) for s in raw]
|
| 183 |
+
total = sum(exps)
|
| 184 |
+
return sorted(
|
| 185 |
+
((l["name"], e / total) for l, e in zip(labels, exps)),
|
| 186 |
+
key=lambda p: p[1],
|
| 187 |
+
reverse=True,
|
| 188 |
+
)
|
| 189 |
+
|
| 190 |
+
|
| 191 |
+
def classify(
|
| 192 |
+
text: str,
|
| 193 |
+
labels: list[dict[str, Any]],
|
| 194 |
+
multi_label: bool = False,
|
| 195 |
+
threshold: float | None = None,
|
| 196 |
+
) -> dict[str, Any]:
|
| 197 |
+
"""Classify `text` against `labels` (list of {name, description}).
|
| 198 |
+
|
| 199 |
+
Returns predicted label, per-label scores, engine used and latency.
|
| 200 |
+
"""
|
| 201 |
+
threshold = CONFIDENCE_THRESHOLD if threshold is None else threshold
|
| 202 |
+
text = (text or "").strip()
|
| 203 |
+
started = time.perf_counter()
|
| 204 |
+
|
| 205 |
+
if not text:
|
| 206 |
+
raise ValueError("Complaint text is empty.")
|
| 207 |
+
if not labels:
|
| 208 |
+
raise ValueError("No active labels configured. Add at least one label first.")
|
| 209 |
+
|
| 210 |
+
if _pipe is None and _state["status"] == "not_loaded":
|
| 211 |
+
load_model()
|
| 212 |
+
|
| 213 |
+
pipe = _pipe # local ref: a concurrent reload must not swap this mid-call
|
| 214 |
+
if pipe is not None:
|
| 215 |
+
# Candidates are label names. Stored names are UNIQUE, but an ad-hoc
|
| 216 |
+
# label list from the API may repeat, and the pipeline dislikes dupes.
|
| 217 |
+
candidates = list(dict.fromkeys(_candidate_for(l) for l in labels if _candidate_for(l)))
|
| 218 |
+
try:
|
| 219 |
+
with _infer_lock:
|
| 220 |
+
out = pipe(
|
| 221 |
+
text,
|
| 222 |
+
candidate_labels=candidates,
|
| 223 |
+
hypothesis_template=HYPOTHESIS_TEMPLATE,
|
| 224 |
+
multi_label=multi_label,
|
| 225 |
+
)
|
| 226 |
+
scored = [(lbl, float(score))
|
| 227 |
+
for lbl, score in zip(out["labels"], out["scores"])]
|
| 228 |
+
scored = _blend(scored, text, labels, LEXICAL_WEIGHT, multi_label)
|
| 229 |
+
engine = _state["engine"]
|
| 230 |
+
except Exception as exc:
|
| 231 |
+
log.warning("Inference failed (%s); falling back to keywords.", exc)
|
| 232 |
+
scored = _keyword_scores(text, labels)
|
| 233 |
+
engine = "keyword-fallback (inference error)"
|
| 234 |
+
else:
|
| 235 |
+
scored = _keyword_scores(text, labels)
|
| 236 |
+
engine = "keyword-fallback"
|
| 237 |
+
|
| 238 |
+
scored.sort(key=lambda p: p[1], reverse=True)
|
| 239 |
+
top_label, top_score = scored[0]
|
| 240 |
+
took_ms = int((time.perf_counter() - started) * 1000)
|
| 241 |
+
|
| 242 |
+
return {
|
| 243 |
+
"predicted_label": top_label,
|
| 244 |
+
"confidence": round(top_score, 4),
|
| 245 |
+
"confident": top_score >= threshold,
|
| 246 |
+
"threshold": threshold,
|
| 247 |
+
"scores": [{"label": n, "score": round(s, 4)} for n, s in scored],
|
| 248 |
+
"engine": engine,
|
| 249 |
+
"multi_label": multi_label,
|
| 250 |
+
"took_ms": took_ms,
|
| 251 |
+
}
|
app/config.py
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Runtime configuration. Override any value with an environment variable."""
|
| 2 |
+
import os
|
| 3 |
+
from pathlib import Path
|
| 4 |
+
|
| 5 |
+
BASE_DIR = Path(__file__).resolve().parent.parent
|
| 6 |
+
DATA_DIR = Path(os.getenv("DATA_DIR", BASE_DIR / "data"))
|
| 7 |
+
DATA_DIR.mkdir(parents=True, exist_ok=True)
|
| 8 |
+
|
| 9 |
+
DB_PATH = DATA_DIR / "complaints.db"
|
| 10 |
+
|
| 11 |
+
# Small NLI model used for zero-shot classification (~250MB).
|
| 12 |
+
# Alternatives:
|
| 13 |
+
# valhalla/distilbart-mnli-12-1 (~890MB, more accurate)
|
| 14 |
+
# MoritzLaurer/deberta-v3-xsmall-zeroshot-v1.1-all-33 (~146MB, far more
|
| 15 |
+
# confident -- 0.80 vs 0.24 on a sample complaint -- but measured at
|
| 16 |
+
# ~41s per inference on this CPU against distilbert's ~0.4s. Unusable for
|
| 17 |
+
# real-time intake here; benchmark it yourself before switching.)
|
| 18 |
+
MODEL_NAME = os.getenv("MODEL_NAME", "typeform/distilbert-base-uncased-mnli")
|
| 19 |
+
|
| 20 |
+
# Seconds a single chunk of the model download may stall before giving up.
|
| 21 |
+
# Without this a flaky/blocked network leaves the loader thread hanging forever
|
| 22 |
+
# instead of cleanly falling back. Applied to huggingface_hub at import time.
|
| 23 |
+
os.environ.setdefault("HF_HUB_DOWNLOAD_TIMEOUT", os.getenv("MODEL_DOWNLOAD_TIMEOUT", "30"))
|
| 24 |
+
os.environ.setdefault("HF_HUB_ETAG_TIMEOUT", "15")
|
| 25 |
+
|
| 26 |
+
# Some corporate/ISP networks block Hugging Face's Xet storage backend, which
|
| 27 |
+
# makes model downloads hang at 0 bytes forever while ordinary HTTPS to
|
| 28 |
+
# huggingface.co works fine. DISABLE_XET=1 forces the classic CDN path.
|
| 29 |
+
if os.getenv("DISABLE_XET", "0") not in ("0", "false", "False"):
|
| 30 |
+
os.environ["HF_HUB_DISABLE_XET"] = "1"
|
| 31 |
+
|
| 32 |
+
# Set MODEL_OFFLINE=1 to never hit the network (uses the local HF cache only).
|
| 33 |
+
if os.getenv("MODEL_OFFLINE", "0") not in ("0", "false", "False"):
|
| 34 |
+
os.environ["HF_HUB_OFFLINE"] = "1"
|
| 35 |
+
|
| 36 |
+
# "This complaint is about {}." -- steers the NLI model towards the domain.
|
| 37 |
+
HYPOTHESIS_TEMPLATE = os.getenv(
|
| 38 |
+
"HYPOTHESIS_TEMPLATE", "This complaint is about {}."
|
| 39 |
+
)
|
| 40 |
+
|
| 41 |
+
# Below this top score the complaint is reported as "unclassified" instead of
|
| 42 |
+
# being forced into the closest label.
|
| 43 |
+
CONFIDENCE_THRESHOLD = float(os.getenv("CONFIDENCE_THRESHOLD", "0.35"))
|
| 44 |
+
|
| 45 |
+
# How much the keyword prior built from label descriptions counts against the
|
| 46 |
+
# model's own score (0 = pure model, 1 = pure keywords). Small NLI models only
|
| 47 |
+
# see the label NAME as their hypothesis, so this is how the example phrases in
|
| 48 |
+
# a description actually influence the outcome.
|
| 49 |
+
LEXICAL_WEIGHT = float(os.getenv("LEXICAL_WEIGHT", "0.6"))
|
| 50 |
+
|
| 51 |
+
# Load the model at startup instead of on the first request.
|
| 52 |
+
PRELOAD_MODEL = os.getenv("PRELOAD_MODEL", "1") not in ("0", "false", "False")
|
| 53 |
+
|
| 54 |
+
TRANSLATE_ENABLED = os.getenv("TRANSLATE_ENABLED", "1") not in ("0", "false", "False")
|
| 55 |
+
TRANSLATE_TIMEOUT = float(os.getenv("TRANSLATE_TIMEOUT", "8"))
|
| 56 |
+
|
| 57 |
+
# Seed labels, only inserted when the label table is empty.
|
| 58 |
+
DEFAULT_LABELS = [
|
| 59 |
+
("Water Supply", "No water, low pressure, dirty or contaminated water, burst pipe, leaking main"),
|
| 60 |
+
("Electricity", "Power cut, outage, voltage fluctuation, broken street light, damaged electric pole or wire"),
|
| 61 |
+
("Road & Transport", "Potholes, damaged road, blocked street, broken pavement, traffic signal not working"),
|
| 62 |
+
("Waste & Sanitation", "Garbage not collected, overflowing bin, blocked drain, sewage overflow, bad smell"),
|
| 63 |
+
("Public Health", "Mosquito breeding, stray animals, food safety, dengue, unhygienic public place"),
|
| 64 |
+
("Noise & Pollution", "Loud noise, construction sound, air pollution, smoke, illegal dumping"),
|
| 65 |
+
("Building & Property", "Illegal construction, unsafe building, encroachment, permit issue"),
|
| 66 |
+
("Other", "Anything that does not fit the other categories"),
|
| 67 |
+
]
|
app/main.py
ADDED
|
@@ -0,0 +1,239 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""FastAPI app: multilingual complaint classification with runtime labels."""
|
| 2 |
+
from __future__ import annotations
|
| 3 |
+
|
| 4 |
+
import logging
|
| 5 |
+
import threading
|
| 6 |
+
from contextlib import asynccontextmanager
|
| 7 |
+
from pathlib import Path
|
| 8 |
+
|
| 9 |
+
from fastapi import FastAPI, HTTPException, Query, Request
|
| 10 |
+
from fastapi.responses import JSONResponse
|
| 11 |
+
from fastapi.staticfiles import StaticFiles
|
| 12 |
+
from fastapi.templating import Jinja2Templates
|
| 13 |
+
from starlette.concurrency import run_in_threadpool
|
| 14 |
+
|
| 15 |
+
from . import classifier, store
|
| 16 |
+
from .config import CONFIDENCE_THRESHOLD, MODEL_NAME, PRELOAD_MODEL
|
| 17 |
+
from .schemas import ClassifyRequest, ClassifyResponse, LabelIn, LabelOut, LabelPatch
|
| 18 |
+
from .translator import TranslationResult, detect_language, language_name, to_english
|
| 19 |
+
|
| 20 |
+
logging.basicConfig(
|
| 21 |
+
level=logging.INFO,
|
| 22 |
+
format="%(asctime)s %(levelname)-7s %(name)s %(message)s",
|
| 23 |
+
datefmt="%H:%M:%S",
|
| 24 |
+
)
|
| 25 |
+
log = logging.getLogger("app")
|
| 26 |
+
|
| 27 |
+
BASE_DIR = Path(__file__).resolve().parent
|
| 28 |
+
|
| 29 |
+
|
| 30 |
+
@asynccontextmanager
|
| 31 |
+
async def lifespan(_: FastAPI):
|
| 32 |
+
store.init_db()
|
| 33 |
+
log.info("Database ready at %s", store.DB_PATH)
|
| 34 |
+
if PRELOAD_MODEL:
|
| 35 |
+
# Load in the background so the UI is reachable during the first download.
|
| 36 |
+
threading.Thread(target=classifier.load_model, name="model-loader",
|
| 37 |
+
daemon=True).start()
|
| 38 |
+
log.info("Loading %s in the background...", MODEL_NAME)
|
| 39 |
+
yield
|
| 40 |
+
|
| 41 |
+
|
| 42 |
+
app = FastAPI(
|
| 43 |
+
title="Complaint Classifier",
|
| 44 |
+
description=(
|
| 45 |
+
"Multilingual complaint intake. Detects the language, translates to English "
|
| 46 |
+
"with Google Translate, then classifies against labels you can edit at "
|
| 47 |
+
"runtime using a small zero-shot NLI model."
|
| 48 |
+
),
|
| 49 |
+
version="1.0.0",
|
| 50 |
+
lifespan=lifespan,
|
| 51 |
+
)
|
| 52 |
+
|
| 53 |
+
app.mount("/static", StaticFiles(directory=BASE_DIR / "static"), name="static")
|
| 54 |
+
templates = Jinja2Templates(directory=str(BASE_DIR / "templates"))
|
| 55 |
+
|
| 56 |
+
|
| 57 |
+
def label_hue(name: str) -> int:
|
| 58 |
+
"""Stable colour for a label name. Mirrored by hueOf() in app.js so
|
| 59 |
+
server-rendered and client-rendered pills always match."""
|
| 60 |
+
h = 0
|
| 61 |
+
for ch in str(name):
|
| 62 |
+
h = (h * 31 + ord(ch)) & 0xFFFFFFFF
|
| 63 |
+
return h % 360
|
| 64 |
+
|
| 65 |
+
|
| 66 |
+
templates.env.filters["hue"] = label_hue
|
| 67 |
+
|
| 68 |
+
|
| 69 |
+
@app.exception_handler(ValueError)
|
| 70 |
+
async def value_error_handler(_: Request, exc: ValueError):
|
| 71 |
+
return JSONResponse(status_code=400, content={"detail": str(exc)})
|
| 72 |
+
|
| 73 |
+
|
| 74 |
+
def _label_out(row: dict) -> dict:
|
| 75 |
+
return {**row, "active": bool(row["active"])}
|
| 76 |
+
|
| 77 |
+
|
| 78 |
+
# ------------------------------------------------------------------ pages ---
|
| 79 |
+
|
| 80 |
+
@app.get("/", include_in_schema=False)
|
| 81 |
+
async def page_classify(request: Request):
|
| 82 |
+
return templates.TemplateResponse(
|
| 83 |
+
"index.html",
|
| 84 |
+
{"request": request, "page": "classify",
|
| 85 |
+
"labels": store.list_labels(active_only=True),
|
| 86 |
+
"threshold": CONFIDENCE_THRESHOLD},
|
| 87 |
+
)
|
| 88 |
+
|
| 89 |
+
|
| 90 |
+
@app.get("/labels", include_in_schema=False)
|
| 91 |
+
async def page_labels(request: Request):
|
| 92 |
+
return templates.TemplateResponse(
|
| 93 |
+
"labels.html",
|
| 94 |
+
{"request": request, "page": "labels", "labels": store.list_labels()},
|
| 95 |
+
)
|
| 96 |
+
|
| 97 |
+
|
| 98 |
+
@app.get("/history", include_in_schema=False)
|
| 99 |
+
async def page_history(request: Request):
|
| 100 |
+
return templates.TemplateResponse(
|
| 101 |
+
"history.html",
|
| 102 |
+
{"request": request, "page": "history",
|
| 103 |
+
"complaints": store.list_complaints(limit=200),
|
| 104 |
+
"counts": store.label_counts()},
|
| 105 |
+
)
|
| 106 |
+
|
| 107 |
+
|
| 108 |
+
# -------------------------------------------------------------- classify ----
|
| 109 |
+
|
| 110 |
+
@app.post("/api/classify", response_model=ClassifyResponse, tags=["classify"])
|
| 111 |
+
async def api_classify(req: ClassifyRequest):
|
| 112 |
+
"""Detect language -> translate to English -> zero-shot classify."""
|
| 113 |
+
text = req.text.strip()
|
| 114 |
+
if not text:
|
| 115 |
+
raise HTTPException(400, "Complaint text is empty.")
|
| 116 |
+
|
| 117 |
+
# 1. language detection + translation (network I/O -> threadpool)
|
| 118 |
+
if req.translate:
|
| 119 |
+
tr = await run_in_threadpool(to_english, text)
|
| 120 |
+
else:
|
| 121 |
+
lang, conf = await run_in_threadpool(detect_language, text)
|
| 122 |
+
tr = TranslationResult(text, text, lang, language_name(lang), conf, False,
|
| 123 |
+
"Translation skipped by request.")
|
| 124 |
+
|
| 125 |
+
# 2. resolve the label set for this request
|
| 126 |
+
if req.labels:
|
| 127 |
+
labels = [{"name": n.strip(), "description": ""} for n in req.labels if n.strip()]
|
| 128 |
+
else:
|
| 129 |
+
labels = store.list_labels(active_only=True)
|
| 130 |
+
if not labels:
|
| 131 |
+
raise HTTPException(400, "No active labels configured. Add labels on the Labels page.")
|
| 132 |
+
|
| 133 |
+
# 3. classify (CPU bound -> threadpool)
|
| 134 |
+
try:
|
| 135 |
+
result = await run_in_threadpool(
|
| 136 |
+
classifier.classify, tr.text or text, labels, req.multi_label, req.threshold
|
| 137 |
+
)
|
| 138 |
+
except ValueError as exc:
|
| 139 |
+
raise HTTPException(400, str(exc)) from exc
|
| 140 |
+
|
| 141 |
+
# 4. persist
|
| 142 |
+
complaint_id = None
|
| 143 |
+
if req.save:
|
| 144 |
+
complaint_id = await run_in_threadpool(
|
| 145 |
+
store.save_complaint,
|
| 146 |
+
{
|
| 147 |
+
"original_text": tr.original_text,
|
| 148 |
+
"translated_text": tr.text,
|
| 149 |
+
"source_lang": tr.source_lang,
|
| 150 |
+
"was_translated": tr.was_translated,
|
| 151 |
+
"predicted_label": result["predicted_label"],
|
| 152 |
+
"confidence": result["confidence"],
|
| 153 |
+
"scores": result["scores"],
|
| 154 |
+
"engine": result["engine"],
|
| 155 |
+
"took_ms": result["took_ms"],
|
| 156 |
+
},
|
| 157 |
+
)
|
| 158 |
+
|
| 159 |
+
return {
|
| 160 |
+
"id": complaint_id,
|
| 161 |
+
**result,
|
| 162 |
+
"translation": {
|
| 163 |
+
"source_lang": tr.source_lang,
|
| 164 |
+
"source_lang_name": tr.source_lang_name,
|
| 165 |
+
"detection_confidence": round(tr.confidence, 4),
|
| 166 |
+
"was_translated": tr.was_translated,
|
| 167 |
+
"original_text": tr.original_text,
|
| 168 |
+
"translated_text": tr.text,
|
| 169 |
+
"note": tr.note,
|
| 170 |
+
},
|
| 171 |
+
}
|
| 172 |
+
|
| 173 |
+
|
| 174 |
+
# ---------------------------------------------------------------- labels ----
|
| 175 |
+
|
| 176 |
+
@app.get("/api/labels", response_model=list[LabelOut], tags=["labels"])
|
| 177 |
+
async def api_list_labels(active_only: bool = Query(False)):
|
| 178 |
+
return [_label_out(l) for l in store.list_labels(active_only=active_only)]
|
| 179 |
+
|
| 180 |
+
|
| 181 |
+
@app.post("/api/labels", response_model=LabelOut, status_code=201, tags=["labels"])
|
| 182 |
+
async def api_add_label(body: LabelIn):
|
| 183 |
+
try:
|
| 184 |
+
return _label_out(store.add_label(body.name, body.description, body.active))
|
| 185 |
+
except ValueError as exc:
|
| 186 |
+
raise HTTPException(409, str(exc)) from exc
|
| 187 |
+
|
| 188 |
+
|
| 189 |
+
@app.patch("/api/labels/{label_id}", response_model=LabelOut, tags=["labels"])
|
| 190 |
+
async def api_update_label(label_id: int, body: LabelPatch):
|
| 191 |
+
try:
|
| 192 |
+
row = store.update_label(label_id, body.name, body.description, body.active)
|
| 193 |
+
except ValueError as exc:
|
| 194 |
+
raise HTTPException(409, str(exc)) from exc
|
| 195 |
+
if row is None:
|
| 196 |
+
raise HTTPException(404, "Label not found.")
|
| 197 |
+
return _label_out(row)
|
| 198 |
+
|
| 199 |
+
|
| 200 |
+
@app.delete("/api/labels/{label_id}", status_code=204, tags=["labels"])
|
| 201 |
+
async def api_delete_label(label_id: int):
|
| 202 |
+
if not store.delete_label(label_id):
|
| 203 |
+
raise HTTPException(404, "Label not found.")
|
| 204 |
+
|
| 205 |
+
|
| 206 |
+
# --------------------------------------------------------------- history ----
|
| 207 |
+
|
| 208 |
+
@app.get("/api/history", tags=["history"])
|
| 209 |
+
async def api_history(limit: int = Query(100, ge=1, le=1000), label: str | None = None):
|
| 210 |
+
return store.list_complaints(limit=limit, label=label)
|
| 211 |
+
|
| 212 |
+
|
| 213 |
+
@app.get("/api/stats", tags=["history"])
|
| 214 |
+
async def api_stats():
|
| 215 |
+
counts = store.label_counts()
|
| 216 |
+
return {"total": sum(c["count"] for c in counts), "by_label": counts}
|
| 217 |
+
|
| 218 |
+
|
| 219 |
+
@app.delete("/api/history", tags=["history"])
|
| 220 |
+
async def api_clear_history():
|
| 221 |
+
return {"deleted": store.clear_complaints()}
|
| 222 |
+
|
| 223 |
+
|
| 224 |
+
# ---------------------------------------------------------------- system ----
|
| 225 |
+
|
| 226 |
+
@app.get("/api/health", tags=["system"])
|
| 227 |
+
async def api_health():
|
| 228 |
+
st = classifier.state()
|
| 229 |
+
return {
|
| 230 |
+
"status": "ok",
|
| 231 |
+
"model": st,
|
| 232 |
+
"labels_active": len(store.list_labels(active_only=True)),
|
| 233 |
+
"threshold": CONFIDENCE_THRESHOLD,
|
| 234 |
+
}
|
| 235 |
+
|
| 236 |
+
|
| 237 |
+
@app.post("/api/model/reload", tags=["system"])
|
| 238 |
+
async def api_reload_model():
|
| 239 |
+
return await run_in_threadpool(classifier.load_model, True)
|
app/schemas.py
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Request/response models for the JSON API."""
|
| 2 |
+
from __future__ import annotations
|
| 3 |
+
|
| 4 |
+
from pydantic import BaseModel, Field
|
| 5 |
+
|
| 6 |
+
|
| 7 |
+
class ClassifyRequest(BaseModel):
|
| 8 |
+
text: str = Field(..., min_length=1, max_length=8000,
|
| 9 |
+
description="Complaint in any language.")
|
| 10 |
+
translate: bool = Field(True, description="Detect language and translate to English first.")
|
| 11 |
+
multi_label: bool = Field(False, description="Score labels independently instead of softmax.")
|
| 12 |
+
threshold: float | None = Field(None, ge=0.0, le=1.0,
|
| 13 |
+
description="Override the confidence threshold.")
|
| 14 |
+
save: bool = Field(True, description="Store the result in the complaint history.")
|
| 15 |
+
labels: list[str] | None = Field(
|
| 16 |
+
None, description="Ad-hoc label names for this request only; omit to use the saved labels."
|
| 17 |
+
)
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
class LabelScore(BaseModel):
|
| 21 |
+
label: str
|
| 22 |
+
score: float
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
class TranslationInfo(BaseModel):
|
| 26 |
+
source_lang: str
|
| 27 |
+
source_lang_name: str
|
| 28 |
+
detection_confidence: float
|
| 29 |
+
was_translated: bool
|
| 30 |
+
original_text: str
|
| 31 |
+
translated_text: str
|
| 32 |
+
note: str = ""
|
| 33 |
+
|
| 34 |
+
|
| 35 |
+
class ClassifyResponse(BaseModel):
|
| 36 |
+
id: int | None = None
|
| 37 |
+
predicted_label: str
|
| 38 |
+
confidence: float
|
| 39 |
+
confident: bool
|
| 40 |
+
threshold: float
|
| 41 |
+
scores: list[LabelScore]
|
| 42 |
+
translation: TranslationInfo
|
| 43 |
+
engine: str
|
| 44 |
+
multi_label: bool
|
| 45 |
+
took_ms: int
|
| 46 |
+
|
| 47 |
+
|
| 48 |
+
class LabelIn(BaseModel):
|
| 49 |
+
name: str = Field(..., min_length=1, max_length=80)
|
| 50 |
+
description: str = Field("", max_length=500,
|
| 51 |
+
description="Example phrases; strongly improves zero-shot accuracy.")
|
| 52 |
+
active: bool = True
|
| 53 |
+
|
| 54 |
+
|
| 55 |
+
class LabelPatch(BaseModel):
|
| 56 |
+
name: str | None = Field(None, min_length=1, max_length=80)
|
| 57 |
+
description: str | None = Field(None, max_length=500)
|
| 58 |
+
active: bool | None = None
|
| 59 |
+
|
| 60 |
+
|
| 61 |
+
class LabelOut(BaseModel):
|
| 62 |
+
id: int
|
| 63 |
+
name: str
|
| 64 |
+
description: str
|
| 65 |
+
active: bool
|
| 66 |
+
created_at: str
|
app/static/css/style.css
ADDED
|
@@ -0,0 +1,596 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/* ==========================================================================
|
| 2 |
+
Complaint Classifier — design system
|
| 3 |
+
Indigo/violet accent, soft elevated surfaces, full light+dark support.
|
| 4 |
+
========================================================================== */
|
| 5 |
+
|
| 6 |
+
:root {
|
| 7 |
+
/* surfaces */
|
| 8 |
+
--bg: #f4f6fb;
|
| 9 |
+
--bg-tint: #eceffa;
|
| 10 |
+
--surface: #ffffff;
|
| 11 |
+
--surface-2: #f8f9fd;
|
| 12 |
+
--line: #e4e8f4;
|
| 13 |
+
--line-strong: #d3d9ea;
|
| 14 |
+
|
| 15 |
+
/* text */
|
| 16 |
+
--ink: #171a26;
|
| 17 |
+
--ink-2: #414a63;
|
| 18 |
+
--muted: #6d7793;
|
| 19 |
+
--faint: #98a1b8;
|
| 20 |
+
|
| 21 |
+
/* brand */
|
| 22 |
+
--accent: #5b5bd6;
|
| 23 |
+
--accent-2: #8b5cf6;
|
| 24 |
+
--accent-ink: #4a45c4;
|
| 25 |
+
--accent-soft: #ecebfd;
|
| 26 |
+
--accent-line: #d5d3fa;
|
| 27 |
+
--grad: linear-gradient(135deg, #6366f1 0%, #8b5cf6 55%, #a855f7 100%);
|
| 28 |
+
--grad-soft: linear-gradient(135deg, #eef2ff 0%, #f5f0ff 100%);
|
| 29 |
+
|
| 30 |
+
/* semantic */
|
| 31 |
+
--good: #0d9f6e;
|
| 32 |
+
--good-soft: #e3f7ef;
|
| 33 |
+
--warn: #b45309;
|
| 34 |
+
--warn-soft: #fdf3e3;
|
| 35 |
+
--danger: #dc2626;
|
| 36 |
+
--danger-soft: #fdeced;
|
| 37 |
+
|
| 38 |
+
/* shape */
|
| 39 |
+
--r-sm: 8px;
|
| 40 |
+
--r: 14px;
|
| 41 |
+
--r-lg: 20px;
|
| 42 |
+
--shadow-sm: 0 1px 2px rgba(23, 26, 38, .06);
|
| 43 |
+
--shadow: 0 1px 3px rgba(23, 26, 38, .06), 0 8px 24px -8px rgba(23, 26, 38, .10);
|
| 44 |
+
--shadow-lg: 0 2px 8px rgba(23, 26, 38, .06), 0 24px 48px -16px rgba(23, 26, 38, .18);
|
| 45 |
+
--ring: 0 0 0 3px var(--accent-soft);
|
| 46 |
+
|
| 47 |
+
--font: "Inter", ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto,
|
| 48 |
+
"Noto Sans", "Noto Sans Sinhala", "Noto Sans Tamil", sans-serif;
|
| 49 |
+
--mono: "JetBrains Mono", ui-monospace, "SF Mono", "Cascadia Code", Consolas, monospace;
|
| 50 |
+
}
|
| 51 |
+
|
| 52 |
+
@media (prefers-color-scheme: dark) {
|
| 53 |
+
:root {
|
| 54 |
+
--bg: #0a0c13;
|
| 55 |
+
--bg-tint: #0f1220;
|
| 56 |
+
--surface: #141824;
|
| 57 |
+
--surface-2: #1a1f2e;
|
| 58 |
+
--line: #242a3b;
|
| 59 |
+
--line-strong: #333a50;
|
| 60 |
+
|
| 61 |
+
--ink: #e9ecf5;
|
| 62 |
+
--ink-2: #b9c0d4;
|
| 63 |
+
--muted: #8790a8;
|
| 64 |
+
--faint: #646d85;
|
| 65 |
+
|
| 66 |
+
--accent: #818cf8;
|
| 67 |
+
--accent-2: #a78bfa;
|
| 68 |
+
--accent-ink: #a5b0ff;
|
| 69 |
+
--accent-soft: #1e2340;
|
| 70 |
+
--accent-line: #2f3660;
|
| 71 |
+
--grad: linear-gradient(135deg, #818cf8 0%, #a78bfa 55%, #c084fc 100%);
|
| 72 |
+
--grad-soft: linear-gradient(135deg, #171b2e 0%, #1d1830 100%);
|
| 73 |
+
|
| 74 |
+
--good: #34d399;
|
| 75 |
+
--good-soft: #10241d;
|
| 76 |
+
--warn: #fbbf24;
|
| 77 |
+
--warn-soft: #241d10;
|
| 78 |
+
--danger: #f87171;
|
| 79 |
+
--danger-soft: #2a1418;
|
| 80 |
+
|
| 81 |
+
--shadow-sm: 0 1px 2px rgba(0, 0, 0, .5);
|
| 82 |
+
--shadow: 0 1px 3px rgba(0, 0, 0, .5), 0 8px 24px -8px rgba(0, 0, 0, .6);
|
| 83 |
+
--shadow-lg: 0 2px 8px rgba(0, 0, 0, .5), 0 24px 48px -16px rgba(0, 0, 0, .7);
|
| 84 |
+
--ring: 0 0 0 3px rgba(129, 140, 248, .25);
|
| 85 |
+
}
|
| 86 |
+
}
|
| 87 |
+
|
| 88 |
+
* { box-sizing: border-box; }
|
| 89 |
+
|
| 90 |
+
html { -webkit-text-size-adjust: 100%; }
|
| 91 |
+
|
| 92 |
+
body {
|
| 93 |
+
margin: 0;
|
| 94 |
+
min-height: 100vh;
|
| 95 |
+
background: var(--bg);
|
| 96 |
+
background-image:
|
| 97 |
+
radial-gradient(900px 400px at 12% -8%, var(--bg-tint), transparent 70%),
|
| 98 |
+
radial-gradient(700px 360px at 100% 0%, var(--accent-soft), transparent 65%);
|
| 99 |
+
background-attachment: fixed;
|
| 100 |
+
color: var(--ink);
|
| 101 |
+
font-family: var(--font);
|
| 102 |
+
font-size: 15px;
|
| 103 |
+
line-height: 1.6;
|
| 104 |
+
-webkit-font-smoothing: antialiased;
|
| 105 |
+
}
|
| 106 |
+
|
| 107 |
+
.wrap { max-width: 1240px; margin: 0 auto; padding: 0 24px; }
|
| 108 |
+
|
| 109 |
+
/* ============================================================== topbar === */
|
| 110 |
+
|
| 111 |
+
.topbar {
|
| 112 |
+
position: sticky; top: 0; z-index: 40;
|
| 113 |
+
background: color-mix(in srgb, var(--surface) 78%, transparent);
|
| 114 |
+
backdrop-filter: saturate(1.6) blur(14px);
|
| 115 |
+
-webkit-backdrop-filter: saturate(1.6) blur(14px);
|
| 116 |
+
border-bottom: 1px solid var(--line);
|
| 117 |
+
}
|
| 118 |
+
.topbar-inner { display: flex; align-items: center; gap: 20px; height: 64px; }
|
| 119 |
+
|
| 120 |
+
.brand {
|
| 121 |
+
display: flex; align-items: center; gap: 11px;
|
| 122 |
+
color: var(--ink); text-decoration: none;
|
| 123 |
+
font-weight: 700; font-size: 15.5px; letter-spacing: -.015em; white-space: nowrap;
|
| 124 |
+
}
|
| 125 |
+
.brand-mark {
|
| 126 |
+
display: grid; place-items: center;
|
| 127 |
+
width: 32px; height: 32px; border-radius: 10px;
|
| 128 |
+
background: var(--grad); color: #fff;
|
| 129 |
+
font-size: 12px; font-weight: 800; letter-spacing: -.03em;
|
| 130 |
+
box-shadow: 0 4px 12px -3px rgba(99, 102, 241, .55);
|
| 131 |
+
}
|
| 132 |
+
|
| 133 |
+
.nav { display: flex; gap: 2px; margin-left: auto; }
|
| 134 |
+
.nav a {
|
| 135 |
+
display: flex; align-items: center; gap: 7px;
|
| 136 |
+
padding: 8px 14px; border-radius: 10px;
|
| 137 |
+
color: var(--muted); text-decoration: none;
|
| 138 |
+
font-weight: 500; font-size: 14px;
|
| 139 |
+
transition: background .16s, color .16s;
|
| 140 |
+
}
|
| 141 |
+
.nav a svg { width: 16px; height: 16px; opacity: .85; }
|
| 142 |
+
.nav a:hover { background: var(--surface-2); color: var(--ink); }
|
| 143 |
+
.nav a.active { background: var(--accent-soft); color: var(--accent-ink); font-weight: 600; }
|
| 144 |
+
.nav a.active svg { opacity: 1; }
|
| 145 |
+
|
| 146 |
+
.model-status {
|
| 147 |
+
display: flex; align-items: center; gap: 8px;
|
| 148 |
+
padding: 6px 12px; border-radius: 999px;
|
| 149 |
+
background: var(--surface-2); border: 1px solid var(--line);
|
| 150 |
+
font-size: 12px; color: var(--muted); white-space: nowrap;
|
| 151 |
+
}
|
| 152 |
+
.dot { width: 7px; height: 7px; border-radius: 50%; background: var(--faint); flex: none; }
|
| 153 |
+
.dot-ok { background: var(--good); box-shadow: 0 0 0 3px color-mix(in srgb, var(--good) 22%, transparent); }
|
| 154 |
+
.dot-load { background: var(--warn); animation: pulse 1.4s ease-in-out infinite; }
|
| 155 |
+
.dot-bad { background: var(--danger); box-shadow: 0 0 0 3px color-mix(in srgb, var(--danger) 22%, transparent); }
|
| 156 |
+
@keyframes pulse {
|
| 157 |
+
0%, 100% { opacity: 1; box-shadow: 0 0 0 0 color-mix(in srgb, var(--warn) 45%, transparent); }
|
| 158 |
+
50% { opacity: .65; box-shadow: 0 0 0 5px transparent; }
|
| 159 |
+
}
|
| 160 |
+
|
| 161 |
+
/* ================================================================ page === */
|
| 162 |
+
|
| 163 |
+
.page { padding: 30px 24px 12px; display: flex; flex-direction: column; gap: 20px; }
|
| 164 |
+
|
| 165 |
+
.hero { margin-bottom: 2px; }
|
| 166 |
+
.hero h1 {
|
| 167 |
+
margin: 0 0 8px;
|
| 168 |
+
font-size: clamp(26px, 3.4vw, 34px); font-weight: 800;
|
| 169 |
+
letter-spacing: -.03em; line-height: 1.15;
|
| 170 |
+
}
|
| 171 |
+
.hero h1 .grad {
|
| 172 |
+
background: var(--grad); -webkit-background-clip: text; background-clip: text;
|
| 173 |
+
-webkit-text-fill-color: transparent; color: transparent;
|
| 174 |
+
}
|
| 175 |
+
.hero p { margin: 0; color: var(--muted); font-size: 15.5px; max-width: 68ch; }
|
| 176 |
+
|
| 177 |
+
.grid-2 { display: grid; grid-template-columns: 1.05fr .95fr; gap: 20px; align-items: start; }
|
| 178 |
+
@media (max-width: 980px) { .grid-2 { grid-template-columns: 1fr; } }
|
| 179 |
+
|
| 180 |
+
.card {
|
| 181 |
+
background: var(--surface);
|
| 182 |
+
border: 1px solid var(--line);
|
| 183 |
+
border-radius: var(--r-lg);
|
| 184 |
+
box-shadow: var(--shadow);
|
| 185 |
+
padding: 24px;
|
| 186 |
+
}
|
| 187 |
+
.card h2 {
|
| 188 |
+
font-size: 16px; font-weight: 650; margin: 0 0 4px; letter-spacing: -.015em;
|
| 189 |
+
display: flex; align-items: center; gap: 9px;
|
| 190 |
+
}
|
| 191 |
+
.card h2 svg { width: 17px; height: 17px; color: var(--accent); }
|
| 192 |
+
.card h3 {
|
| 193 |
+
font-size: 11.5px; font-weight: 700; margin: 22px 0 11px;
|
| 194 |
+
color: var(--faint); text-transform: uppercase; letter-spacing: .09em;
|
| 195 |
+
}
|
| 196 |
+
.sub { color: var(--muted); font-size: 13.5px; margin: 0 0 16px; }
|
| 197 |
+
|
| 198 |
+
.muted { color: var(--muted); }
|
| 199 |
+
.small { font-size: 12.5px; }
|
| 200 |
+
.mono { font-family: var(--mono); font-size: 12.5px; }
|
| 201 |
+
.center { text-align: center; }
|
| 202 |
+
.right { text-align: right; }
|
| 203 |
+
.hidden { display: none !important; }
|
| 204 |
+
.row-between {
|
| 205 |
+
display: flex; align-items: center; justify-content: space-between;
|
| 206 |
+
gap: 14px; flex-wrap: wrap; margin-bottom: 12px;
|
| 207 |
+
}
|
| 208 |
+
|
| 209 |
+
/* ============================================================== inputs === */
|
| 210 |
+
|
| 211 |
+
textarea, input[type=text], input[type=search] {
|
| 212 |
+
width: 100%; padding: 13px 15px;
|
| 213 |
+
border: 1px solid var(--line-strong); border-radius: var(--r);
|
| 214 |
+
background: var(--surface-2); color: var(--ink);
|
| 215 |
+
font-family: inherit; font-size: 14.5px; line-height: 1.6;
|
| 216 |
+
resize: vertical;
|
| 217 |
+
transition: border-color .16s, box-shadow .16s, background .16s;
|
| 218 |
+
}
|
| 219 |
+
textarea::placeholder, input::placeholder { color: var(--faint); }
|
| 220 |
+
textarea:focus, input[type=text]:focus, input[type=search]:focus {
|
| 221 |
+
outline: none; border-color: var(--accent);
|
| 222 |
+
background: var(--surface); box-shadow: var(--ring);
|
| 223 |
+
}
|
| 224 |
+
textarea { min-height: 150px; }
|
| 225 |
+
|
| 226 |
+
input[type=checkbox] { accent-color: var(--accent); width: 15px; height: 15px; cursor: pointer; }
|
| 227 |
+
input[type=range] { accent-color: var(--accent); }
|
| 228 |
+
|
| 229 |
+
.samples { display: flex; flex-wrap: wrap; gap: 7px; align-items: center; margin: 14px 0 4px; }
|
| 230 |
+
.samples-label {
|
| 231 |
+
font-size: 11.5px; font-weight: 700; color: var(--faint);
|
| 232 |
+
text-transform: uppercase; letter-spacing: .08em; margin-right: 2px;
|
| 233 |
+
}
|
| 234 |
+
.chip {
|
| 235 |
+
display: inline-flex; align-items: center; gap: 6px;
|
| 236 |
+
border: 1px solid var(--line-strong); background: var(--surface-2); color: var(--ink-2);
|
| 237 |
+
border-radius: 999px; padding: 5px 13px;
|
| 238 |
+
font-family: inherit; font-size: 12.5px; font-weight: 500; cursor: pointer;
|
| 239 |
+
transition: all .16s;
|
| 240 |
+
}
|
| 241 |
+
.chip:hover {
|
| 242 |
+
border-color: var(--accent); color: var(--accent-ink);
|
| 243 |
+
background: var(--accent-soft); transform: translateY(-1px);
|
| 244 |
+
}
|
| 245 |
+
.chip .flag { font-size: 13px; }
|
| 246 |
+
|
| 247 |
+
.options {
|
| 248 |
+
display: flex; flex-wrap: wrap; gap: 10px 22px; align-items: center;
|
| 249 |
+
margin: 18px 0 4px; padding: 14px 16px;
|
| 250 |
+
background: var(--surface-2); border: 1px solid var(--line); border-radius: var(--r);
|
| 251 |
+
font-size: 13px;
|
| 252 |
+
}
|
| 253 |
+
.check { display: flex; align-items: center; gap: 8px; color: var(--ink-2); cursor: pointer; user-select: none; }
|
| 254 |
+
.check:hover { color: var(--ink); }
|
| 255 |
+
.range { display: flex; align-items: center; gap: 9px; color: var(--ink-2); margin-left: auto; }
|
| 256 |
+
.range input[type=range] { width: 100px; }
|
| 257 |
+
.range output {
|
| 258 |
+
font-family: var(--mono); font-size: 12px; font-weight: 600; color: var(--accent-ink);
|
| 259 |
+
background: var(--accent-soft); border-radius: 6px; padding: 1px 7px; min-width: 40px; text-align: center;
|
| 260 |
+
}
|
| 261 |
+
|
| 262 |
+
.actions { display: flex; align-items: center; gap: 11px; margin-top: 18px; }
|
| 263 |
+
|
| 264 |
+
.btn {
|
| 265 |
+
display: inline-flex; align-items: center; justify-content: center; gap: 8px;
|
| 266 |
+
border: 1px solid var(--line-strong); background: var(--surface); color: var(--ink);
|
| 267 |
+
padding: 10px 18px; border-radius: var(--r);
|
| 268 |
+
font-family: inherit; font-size: 14px; font-weight: 550;
|
| 269 |
+
cursor: pointer; text-decoration: none; white-space: nowrap;
|
| 270 |
+
transition: all .16s;
|
| 271 |
+
}
|
| 272 |
+
.btn svg { width: 16px; height: 16px; }
|
| 273 |
+
.btn:hover { border-color: var(--line-strong); background: var(--surface-2); transform: translateY(-1px); }
|
| 274 |
+
.btn:active { transform: translateY(0); }
|
| 275 |
+
.btn:focus-visible { outline: none; box-shadow: var(--ring); }
|
| 276 |
+
|
| 277 |
+
.btn-primary {
|
| 278 |
+
background: var(--grad); border-color: transparent; color: #fff;
|
| 279 |
+
box-shadow: 0 4px 14px -4px rgba(99, 102, 241, .6);
|
| 280 |
+
}
|
| 281 |
+
.btn-primary:hover {
|
| 282 |
+
background: var(--grad); color: #fff;
|
| 283 |
+
box-shadow: 0 8px 22px -6px rgba(99, 102, 241, .7); transform: translateY(-1px);
|
| 284 |
+
}
|
| 285 |
+
.btn-primary:disabled {
|
| 286 |
+
opacity: .55; cursor: not-allowed; transform: none;
|
| 287 |
+
box-shadow: 0 4px 14px -4px rgba(99, 102, 241, .35);
|
| 288 |
+
}
|
| 289 |
+
.btn-sm { padding: 6px 12px; font-size: 12.5px; border-radius: 9px; }
|
| 290 |
+
.btn-danger { color: var(--danger); }
|
| 291 |
+
.btn-danger:hover { border-color: var(--danger); background: var(--danger-soft); }
|
| 292 |
+
.btn-ghost { border-color: transparent; background: transparent; color: var(--muted); }
|
| 293 |
+
.btn-ghost:hover { background: var(--surface-2); color: var(--ink); }
|
| 294 |
+
|
| 295 |
+
.spinner {
|
| 296 |
+
width: 17px; height: 17px; border-radius: 50%;
|
| 297 |
+
border: 2px solid var(--line-strong); border-top-color: var(--accent);
|
| 298 |
+
animation: spin .65s linear infinite;
|
| 299 |
+
}
|
| 300 |
+
@keyframes spin { to { transform: rotate(360deg); } }
|
| 301 |
+
|
| 302 |
+
/* ============================================================== result === */
|
| 303 |
+
|
| 304 |
+
.empty {
|
| 305 |
+
display: flex; flex-direction: column; align-items: center; justify-content: center;
|
| 306 |
+
text-align: center; padding: 44px 20px; color: var(--muted);
|
| 307 |
+
}
|
| 308 |
+
.empty-icon {
|
| 309 |
+
width: 52px; height: 52px; border-radius: 16px; margin-bottom: 16px;
|
| 310 |
+
display: grid; place-items: center;
|
| 311 |
+
background: var(--grad-soft); border: 1px solid var(--accent-line); color: var(--accent);
|
| 312 |
+
}
|
| 313 |
+
.empty-icon svg { width: 24px; height: 24px; }
|
| 314 |
+
.empty strong { color: var(--ink); font-weight: 600; display: block; margin-bottom: 5px; font-size: 14.5px; }
|
| 315 |
+
.empty p { margin: 0; font-size: 13px; max-width: 40ch; }
|
| 316 |
+
|
| 317 |
+
.verdict {
|
| 318 |
+
display: flex; align-items: center; gap: 20px;
|
| 319 |
+
padding: 20px; border-radius: var(--r);
|
| 320 |
+
background: var(--grad-soft); border: 1px solid var(--accent-line);
|
| 321 |
+
}
|
| 322 |
+
.verdict-main { min-width: 0; flex: 1; }
|
| 323 |
+
.verdict-eyebrow {
|
| 324 |
+
font-size: 11px; font-weight: 700; color: var(--accent-ink);
|
| 325 |
+
text-transform: uppercase; letter-spacing: .1em; margin-bottom: 5px;
|
| 326 |
+
}
|
| 327 |
+
.verdict-label {
|
| 328 |
+
font-size: clamp(20px, 2.6vw, 27px); font-weight: 750;
|
| 329 |
+
letter-spacing: -.03em; line-height: 1.15; word-break: break-word;
|
| 330 |
+
}
|
| 331 |
+
|
| 332 |
+
/* confidence ring */
|
| 333 |
+
.ring { position: relative; width: 86px; height: 86px; flex: none; }
|
| 334 |
+
.ring svg { width: 100%; height: 100%; transform: rotate(-90deg); }
|
| 335 |
+
.ring circle { fill: none; stroke-width: 9; stroke-linecap: round; }
|
| 336 |
+
.ring .ring-bg { stroke: var(--accent-line); }
|
| 337 |
+
.ring .ring-fg {
|
| 338 |
+
stroke: url(#ringGrad);
|
| 339 |
+
stroke-dasharray: 232.5; stroke-dashoffset: 232.5;
|
| 340 |
+
transition: stroke-dashoffset .8s cubic-bezier(.22, 1, .36, 1);
|
| 341 |
+
}
|
| 342 |
+
.ring.low .ring-fg { stroke: var(--warn); }
|
| 343 |
+
.ring-val {
|
| 344 |
+
position: absolute; inset: 0; display: grid; place-items: center;
|
| 345 |
+
font-family: var(--mono); font-size: 17px; font-weight: 700;
|
| 346 |
+
letter-spacing: -.04em; color: var(--ink);
|
| 347 |
+
}
|
| 348 |
+
|
| 349 |
+
.warn {
|
| 350 |
+
display: flex; gap: 10px; align-items: flex-start;
|
| 351 |
+
background: var(--warn-soft); color: var(--warn);
|
| 352 |
+
border: 1px solid color-mix(in srgb, var(--warn) 28%, transparent);
|
| 353 |
+
border-radius: var(--r); padding: 12px 14px; font-size: 13px; margin-top: 14px;
|
| 354 |
+
}
|
| 355 |
+
.warn svg { width: 16px; height: 16px; flex: none; margin-top: 2px; }
|
| 356 |
+
.warn a { color: inherit; }
|
| 357 |
+
|
| 358 |
+
.kv { display: grid; grid-template-columns: repeat(3, 1fr); gap: 10px; margin-top: 16px; }
|
| 359 |
+
.kv > div {
|
| 360 |
+
padding: 11px 13px; border-radius: var(--r-sm);
|
| 361 |
+
background: var(--surface-2); border: 1px solid var(--line); min-width: 0;
|
| 362 |
+
}
|
| 363 |
+
.kv-k {
|
| 364 |
+
font-size: 10.5px; font-weight: 700; color: var(--faint);
|
| 365 |
+
text-transform: uppercase; letter-spacing: .08em; margin-bottom: 3px;
|
| 366 |
+
}
|
| 367 |
+
.kv-v {
|
| 368 |
+
font-size: 13.5px; font-weight: 600; color: var(--ink);
|
| 369 |
+
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
|
| 370 |
+
}
|
| 371 |
+
|
| 372 |
+
.trans { margin-top: 16px; border: 1px solid var(--line); border-radius: var(--r); overflow: hidden; }
|
| 373 |
+
.trans summary {
|
| 374 |
+
cursor: pointer; padding: 11px 15px; background: var(--surface-2);
|
| 375 |
+
font-size: 13px; font-weight: 550; color: var(--ink-2);
|
| 376 |
+
list-style: none; display: flex; align-items: center; gap: 8px;
|
| 377 |
+
}
|
| 378 |
+
.trans summary::-webkit-details-marker { display: none; }
|
| 379 |
+
.trans summary::before {
|
| 380 |
+
content: "›"; font-size: 17px; line-height: 1; color: var(--faint);
|
| 381 |
+
transition: transform .18s; display: inline-block;
|
| 382 |
+
}
|
| 383 |
+
.trans[open] summary::before { transform: rotate(90deg); }
|
| 384 |
+
.trans-body { padding: 14px 15px; }
|
| 385 |
+
.q-label {
|
| 386 |
+
font-size: 10.5px; font-weight: 700; color: var(--faint);
|
| 387 |
+
text-transform: uppercase; letter-spacing: .08em; margin-bottom: 4px;
|
| 388 |
+
}
|
| 389 |
+
.quote {
|
| 390 |
+
margin: 0 0 14px; padding: 10px 13px;
|
| 391 |
+
background: var(--surface-2); border-left: 3px solid var(--accent-line);
|
| 392 |
+
border-radius: 0 var(--r-sm) var(--r-sm) 0;
|
| 393 |
+
font-size: 13.5px; word-break: break-word;
|
| 394 |
+
}
|
| 395 |
+
.quote:last-of-type { margin-bottom: 0; }
|
| 396 |
+
.note { color: var(--warn); font-size: 12.5px; margin: 10px 0 0; }
|
| 397 |
+
|
| 398 |
+
.scores { display: flex; flex-direction: column; gap: 3px; }
|
| 399 |
+
.score-row {
|
| 400 |
+
display: grid; grid-template-columns: minmax(0, 1fr) 96px 46px;
|
| 401 |
+
align-items: center; gap: 12px;
|
| 402 |
+
padding: 7px 10px; border-radius: var(--r-sm);
|
| 403 |
+
transition: background .16s;
|
| 404 |
+
}
|
| 405 |
+
.score-row:hover { background: var(--surface-2); }
|
| 406 |
+
.score-row .n {
|
| 407 |
+
font-size: 13px; color: var(--ink-2);
|
| 408 |
+
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
|
| 409 |
+
display: flex; align-items: center; gap: 8px;
|
| 410 |
+
}
|
| 411 |
+
.score-row .n::before {
|
| 412 |
+
content: ""; width: 7px; height: 7px; border-radius: 2px; flex: none;
|
| 413 |
+
background: hsl(var(--h, 250) 62% 58%);
|
| 414 |
+
}
|
| 415 |
+
.score-row .v {
|
| 416 |
+
font-family: var(--mono); font-size: 12px; font-weight: 600;
|
| 417 |
+
text-align: right; color: var(--muted);
|
| 418 |
+
}
|
| 419 |
+
.score-row.top { background: var(--accent-soft); }
|
| 420 |
+
.score-row.top .n { font-weight: 650; color: var(--ink); }
|
| 421 |
+
.score-row.top .v { color: var(--accent-ink); }
|
| 422 |
+
|
| 423 |
+
.bar { background: var(--line); border-radius: 999px; height: 7px; overflow: hidden; }
|
| 424 |
+
.bar span {
|
| 425 |
+
display: block; height: 100%; border-radius: 999px;
|
| 426 |
+
background: hsl(var(--h, 250) 62% 58%);
|
| 427 |
+
width: 0; transition: width .7s cubic-bezier(.22, 1, .36, 1);
|
| 428 |
+
}
|
| 429 |
+
.score-row.top .bar span { background: var(--grad); }
|
| 430 |
+
|
| 431 |
+
/* ============================================================== labels === */
|
| 432 |
+
|
| 433 |
+
.badge {
|
| 434 |
+
background: var(--accent-soft); color: var(--accent-ink);
|
| 435 |
+
border-radius: 999px; padding: 2px 10px;
|
| 436 |
+
font-size: 12px; font-weight: 700; font-family: var(--mono);
|
| 437 |
+
}
|
| 438 |
+
.pill {
|
| 439 |
+
display: inline-flex; align-items: center; gap: 7px;
|
| 440 |
+
border-radius: 999px; padding: 5px 13px;
|
| 441 |
+
font-size: 12.5px; font-weight: 550; white-space: nowrap;
|
| 442 |
+
background: hsl(var(--h, 250) 70% 96%);
|
| 443 |
+
color: hsl(var(--h, 250) 52% 38%);
|
| 444 |
+
border: 1px solid hsl(var(--h, 250) 60% 88%);
|
| 445 |
+
}
|
| 446 |
+
.pill::before {
|
| 447 |
+
content: ""; width: 6px; height: 6px; border-radius: 50%; flex: none;
|
| 448 |
+
background: hsl(var(--h, 250) 62% 55%);
|
| 449 |
+
}
|
| 450 |
+
@media (prefers-color-scheme: dark) {
|
| 451 |
+
.pill {
|
| 452 |
+
background: hsl(var(--h, 250) 40% 16%);
|
| 453 |
+
color: hsl(var(--h, 250) 70% 76%);
|
| 454 |
+
border-color: hsl(var(--h, 250) 35% 26%);
|
| 455 |
+
}
|
| 456 |
+
.score-row .n::before, .bar span { background: hsl(var(--h, 250) 60% 62%); }
|
| 457 |
+
}
|
| 458 |
+
.label-strip { display: flex; flex-wrap: wrap; gap: 8px; margin: 4px 0 14px; }
|
| 459 |
+
|
| 460 |
+
.add-form {
|
| 461 |
+
display: grid; grid-template-columns: minmax(180px, 1fr) minmax(240px, 2fr) auto;
|
| 462 |
+
gap: 12px; align-items: end; margin-top: 18px;
|
| 463 |
+
padding: 18px; background: var(--grad-soft);
|
| 464 |
+
border: 1px solid var(--accent-line); border-radius: var(--r);
|
| 465 |
+
}
|
| 466 |
+
@media (max-width: 760px) { .add-form { grid-template-columns: 1fr; } }
|
| 467 |
+
.field { display: flex; flex-direction: column; gap: 6px; min-width: 0; }
|
| 468 |
+
.field label { font-size: 12px; font-weight: 600; color: var(--ink-2); }
|
| 469 |
+
.field input { background: var(--surface); }
|
| 470 |
+
|
| 471 |
+
.table { width: 100%; border-collapse: separate; border-spacing: 0; }
|
| 472 |
+
.table th {
|
| 473 |
+
text-align: left; font-size: 10.5px; font-weight: 700; color: var(--faint);
|
| 474 |
+
text-transform: uppercase; letter-spacing: .09em;
|
| 475 |
+
padding: 10px 10px; border-bottom: 1px solid var(--line);
|
| 476 |
+
}
|
| 477 |
+
.table td { padding: 8px 10px; border-bottom: 1px solid var(--line); vertical-align: middle; }
|
| 478 |
+
.table tbody tr { transition: background .16s; }
|
| 479 |
+
.table tbody tr:hover { background: var(--surface-2); }
|
| 480 |
+
.table tr:last-child td { border-bottom: 0; }
|
| 481 |
+
|
| 482 |
+
.cell-input {
|
| 483 |
+
width: 100%; padding: 8px 11px;
|
| 484 |
+
border: 1px solid transparent; border-radius: var(--r-sm);
|
| 485 |
+
background: transparent; color: var(--ink);
|
| 486 |
+
font-family: inherit; font-size: 13.5px;
|
| 487 |
+
transition: all .16s;
|
| 488 |
+
}
|
| 489 |
+
.cell-input:hover { border-color: var(--line-strong); background: var(--surface-2); }
|
| 490 |
+
.cell-input:focus {
|
| 491 |
+
outline: none; border-color: var(--accent);
|
| 492 |
+
background: var(--surface); box-shadow: var(--ring);
|
| 493 |
+
}
|
| 494 |
+
.cell-input.name { font-weight: 600; }
|
| 495 |
+
tr.dirty { background: var(--warn-soft) !important; }
|
| 496 |
+
tr.dirty .save { border-color: var(--warn); color: var(--warn); font-weight: 650; }
|
| 497 |
+
|
| 498 |
+
.switch { position: relative; display: inline-block; width: 38px; height: 22px; vertical-align: middle; }
|
| 499 |
+
.switch input { opacity: 0; width: 0; height: 0; position: absolute; }
|
| 500 |
+
.switch .slider {
|
| 501 |
+
position: absolute; inset: 0; cursor: pointer;
|
| 502 |
+
background: var(--line-strong); border-radius: 999px; transition: background .2s;
|
| 503 |
+
}
|
| 504 |
+
.switch .slider::before {
|
| 505 |
+
content: ""; position: absolute; height: 16px; width: 16px; left: 3px; top: 3px;
|
| 506 |
+
background: #fff; border-radius: 50%;
|
| 507 |
+
transition: transform .2s; box-shadow: var(--shadow-sm);
|
| 508 |
+
}
|
| 509 |
+
.switch input:checked + .slider { background: var(--accent); }
|
| 510 |
+
.switch input:checked + .slider::before { transform: translateX(16px); }
|
| 511 |
+
.switch input:focus-visible + .slider { box-shadow: var(--ring); }
|
| 512 |
+
|
| 513 |
+
/* ============================================================= history === */
|
| 514 |
+
|
| 515 |
+
.stats { display: grid; grid-template-columns: repeat(auto-fill, minmax(215px, 1fr)); gap: 12px; margin: 6px 0 22px; }
|
| 516 |
+
.stat {
|
| 517 |
+
padding: 15px 16px; border-radius: var(--r);
|
| 518 |
+
background: var(--surface-2); border: 1px solid var(--line);
|
| 519 |
+
transition: transform .16s, box-shadow .16s;
|
| 520 |
+
}
|
| 521 |
+
.stat:hover { transform: translateY(-2px); box-shadow: var(--shadow); }
|
| 522 |
+
.stat-top { display: flex; justify-content: space-between; align-items: baseline; gap: 10px; margin-bottom: 10px; }
|
| 523 |
+
.stat-label {
|
| 524 |
+
font-size: 13px; font-weight: 600; color: var(--ink);
|
| 525 |
+
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
|
| 526 |
+
}
|
| 527 |
+
.stat-count {
|
| 528 |
+
font-family: var(--mono); font-size: 18px; font-weight: 700;
|
| 529 |
+
color: hsl(var(--h, 250) 55% 50%); letter-spacing: -.04em;
|
| 530 |
+
}
|
| 531 |
+
@media (prefers-color-scheme: dark) { .stat-count { color: hsl(var(--h, 250) 65% 68%); } }
|
| 532 |
+
.stat .bar { height: 6px; }
|
| 533 |
+
|
| 534 |
+
.filter-row { display: flex; align-items: center; gap: 14px; margin-bottom: 14px; flex-wrap: wrap; }
|
| 535 |
+
.search-wrap { position: relative; flex: 1; max-width: 340px; }
|
| 536 |
+
.search-wrap svg {
|
| 537 |
+
position: absolute; left: 13px; top: 50%; transform: translateY(-50%);
|
| 538 |
+
width: 15px; height: 15px; color: var(--faint); pointer-events: none;
|
| 539 |
+
}
|
| 540 |
+
.search-wrap input { padding-left: 37px; }
|
| 541 |
+
|
| 542 |
+
.history td { font-size: 13.5px; }
|
| 543 |
+
.c-orig { white-space: pre-wrap; word-break: break-word; color: var(--ink); line-height: 1.5; }
|
| 544 |
+
.c-trans {
|
| 545 |
+
margin-top: 5px; padding-left: 10px;
|
| 546 |
+
border-left: 2px solid var(--accent-line); color: var(--muted); font-size: 12.5px;
|
| 547 |
+
}
|
| 548 |
+
.lang {
|
| 549 |
+
display: inline-block; font-family: var(--mono); font-size: 11px; font-weight: 600;
|
| 550 |
+
text-transform: uppercase; color: var(--ink-2);
|
| 551 |
+
background: var(--surface-2); border: 1px solid var(--line);
|
| 552 |
+
border-radius: 6px; padding: 2px 7px;
|
| 553 |
+
}
|
| 554 |
+
.tag {
|
| 555 |
+
display: inline-block; background: var(--accent-soft); color: var(--accent-ink);
|
| 556 |
+
border-radius: 5px; padding: 1px 6px; font-size: 10px; font-weight: 700;
|
| 557 |
+
margin-left: 4px; vertical-align: middle;
|
| 558 |
+
}
|
| 559 |
+
.conf-cell { display: flex; align-items: center; gap: 7px; }
|
| 560 |
+
.conf-dot { width: 7px; height: 7px; border-radius: 50%; flex: none; background: var(--good); }
|
| 561 |
+
.conf-dot.low { background: var(--warn); }
|
| 562 |
+
|
| 563 |
+
.table-scroll { overflow-x: auto; margin: 0 -4px; padding: 0 4px; }
|
| 564 |
+
|
| 565 |
+
/* ============================================================== footer === */
|
| 566 |
+
|
| 567 |
+
.foot {
|
| 568 |
+
color: var(--faint); font-size: 12.5px; padding: 32px 24px 40px;
|
| 569 |
+
text-align: center; line-height: 1.9;
|
| 570 |
+
}
|
| 571 |
+
.foot .sep { opacity: .45; margin: 0 8px; }
|
| 572 |
+
|
| 573 |
+
/* =============================================================== toast === */
|
| 574 |
+
|
| 575 |
+
.toast {
|
| 576 |
+
position: fixed; left: 50%; bottom: 28px; transform: translate(-50%, 20px);
|
| 577 |
+
display: flex; align-items: center; gap: 10px;
|
| 578 |
+
background: var(--ink); color: var(--bg);
|
| 579 |
+
padding: 12px 20px; border-radius: 999px;
|
| 580 |
+
font-size: 13.5px; font-weight: 500;
|
| 581 |
+
box-shadow: var(--shadow-lg);
|
| 582 |
+
opacity: 0; pointer-events: none;
|
| 583 |
+
transition: opacity .22s, transform .22s cubic-bezier(.22, 1, .36, 1);
|
| 584 |
+
z-index: 80; max-width: min(90vw, 520px);
|
| 585 |
+
}
|
| 586 |
+
.toast.show { opacity: 1; transform: translate(-50%, 0); }
|
| 587 |
+
.toast.err { background: var(--danger); color: #fff; }
|
| 588 |
+
|
| 589 |
+
/* ========================================================= motion prefs === */
|
| 590 |
+
|
| 591 |
+
@media (prefers-reduced-motion: reduce) {
|
| 592 |
+
*, *::before, *::after {
|
| 593 |
+
animation-duration: .01ms !important; animation-iteration-count: 1 !important;
|
| 594 |
+
transition-duration: .01ms !important;
|
| 595 |
+
}
|
| 596 |
+
}
|
app/static/js/app.js
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/* Shared helpers: fetch wrapper, toasts, model-status poller. */
|
| 2 |
+
|
| 3 |
+
const $ = (sel, root = document) => root.querySelector(sel);
|
| 4 |
+
const $$ = (sel, root = document) => [...root.querySelectorAll(sel)];
|
| 5 |
+
|
| 6 |
+
function toast(message, isError = false) {
|
| 7 |
+
const el = $("#toast");
|
| 8 |
+
if (!el) return;
|
| 9 |
+
el.textContent = message;
|
| 10 |
+
el.classList.toggle("err", isError);
|
| 11 |
+
el.classList.add("show");
|
| 12 |
+
clearTimeout(toast._t);
|
| 13 |
+
toast._t = setTimeout(() => el.classList.remove("show"), 3200);
|
| 14 |
+
}
|
| 15 |
+
|
| 16 |
+
/* Stable colour per label. Must match label_hue() in main.py so that
|
| 17 |
+
server-rendered and client-rendered pills agree. */
|
| 18 |
+
function hueOf(name) {
|
| 19 |
+
let h = 0;
|
| 20 |
+
for (const ch of String(name)) h = (Math.imul(h, 31) + ch.codePointAt(0)) >>> 0;
|
| 21 |
+
return h % 360;
|
| 22 |
+
}
|
| 23 |
+
|
| 24 |
+
function escapeHtml(s) {
|
| 25 |
+
return String(s).replace(/[&<>"']/g, (c) =>
|
| 26 |
+
({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[c]));
|
| 27 |
+
}
|
| 28 |
+
|
| 29 |
+
async function api(path, { method = "GET", body } = {}) {
|
| 30 |
+
const res = await fetch(path, {
|
| 31 |
+
method,
|
| 32 |
+
headers: body ? { "Content-Type": "application/json" } : {},
|
| 33 |
+
body: body ? JSON.stringify(body) : undefined,
|
| 34 |
+
});
|
| 35 |
+
if (res.status === 204) return null;
|
| 36 |
+
|
| 37 |
+
let payload = null;
|
| 38 |
+
try { payload = await res.json(); } catch { /* empty or non-JSON body */ }
|
| 39 |
+
|
| 40 |
+
if (!res.ok) {
|
| 41 |
+
const detail = payload?.detail;
|
| 42 |
+
const msg = Array.isArray(detail)
|
| 43 |
+
? detail.map((d) => d.msg || JSON.stringify(d)).join("; ")
|
| 44 |
+
: detail || `Request failed (${res.status})`;
|
| 45 |
+
throw new Error(msg);
|
| 46 |
+
}
|
| 47 |
+
return payload;
|
| 48 |
+
}
|
| 49 |
+
|
| 50 |
+
/* ---------------------------------------------------------- model status */
|
| 51 |
+
|
| 52 |
+
const STATUS_UI = {
|
| 53 |
+
ready: ["dot-ok", "model ready"],
|
| 54 |
+
loading: ["dot-load", "loading model…"],
|
| 55 |
+
not_loaded: ["dot-idle", "model idle"],
|
| 56 |
+
failed: ["dot-bad", "keyword fallback"],
|
| 57 |
+
};
|
| 58 |
+
|
| 59 |
+
async function pollModelStatus() {
|
| 60 |
+
const box = $("#modelStatus");
|
| 61 |
+
if (!box) return;
|
| 62 |
+
const dot = $(".dot", box);
|
| 63 |
+
const text = $(".model-status-text", box);
|
| 64 |
+
|
| 65 |
+
try {
|
| 66 |
+
const health = await api("/api/health");
|
| 67 |
+
const st = health.model || {};
|
| 68 |
+
const [cls, label] = STATUS_UI[st.status] || STATUS_UI.not_loaded;
|
| 69 |
+
|
| 70 |
+
dot.className = "dot " + cls;
|
| 71 |
+
text.textContent = st.status === "ready"
|
| 72 |
+
? `${label} · ${(st.model || "").split("/").pop()}`
|
| 73 |
+
: label;
|
| 74 |
+
box.title = st.error
|
| 75 |
+
? `${st.model}\n${st.error}`
|
| 76 |
+
: `${st.model}${st.load_seconds ? ` · loaded in ${st.load_seconds}s` : ""}`;
|
| 77 |
+
|
| 78 |
+
if (st.status === "loading" || st.status === "not_loaded") {
|
| 79 |
+
setTimeout(pollModelStatus, 2500);
|
| 80 |
+
}
|
| 81 |
+
} catch {
|
| 82 |
+
dot.className = "dot dot-bad";
|
| 83 |
+
text.textContent = "server offline";
|
| 84 |
+
}
|
| 85 |
+
}
|
| 86 |
+
|
| 87 |
+
document.addEventListener("DOMContentLoaded", pollModelStatus);
|
app/static/js/classify.js
ADDED
|
@@ -0,0 +1,127 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/* Classify page: submit a complaint and render the result. */
|
| 2 |
+
|
| 3 |
+
document.addEventListener("DOMContentLoaded", () => {
|
| 4 |
+
const input = $("#complaint");
|
| 5 |
+
const btn = $("#btnClassify");
|
| 6 |
+
const spin = $("#spin");
|
| 7 |
+
const thr = $("#optThreshold");
|
| 8 |
+
|
| 9 |
+
// r=37 in the ring SVG -> circumference 2*pi*37
|
| 10 |
+
const RING_C = 2 * Math.PI * 37;
|
| 11 |
+
|
| 12 |
+
thr.addEventListener("input", () => { $("#thrOut").value = (+thr.value).toFixed(2); });
|
| 13 |
+
|
| 14 |
+
$$(".chip").forEach((chip) =>
|
| 15 |
+
chip.addEventListener("click", () => {
|
| 16 |
+
input.value = chip.dataset.sample;
|
| 17 |
+
input.focus();
|
| 18 |
+
})
|
| 19 |
+
);
|
| 20 |
+
|
| 21 |
+
$("#btnClear").addEventListener("click", () => {
|
| 22 |
+
input.value = "";
|
| 23 |
+
$("#result").classList.add("hidden");
|
| 24 |
+
$("#resultEmpty").classList.remove("hidden");
|
| 25 |
+
input.focus();
|
| 26 |
+
});
|
| 27 |
+
|
| 28 |
+
input.addEventListener("keydown", (e) => {
|
| 29 |
+
if ((e.ctrlKey || e.metaKey) && e.key === "Enter") classify();
|
| 30 |
+
});
|
| 31 |
+
btn.addEventListener("click", classify);
|
| 32 |
+
|
| 33 |
+
async function classify() {
|
| 34 |
+
const text = input.value.trim();
|
| 35 |
+
if (!text) { toast("Type a complaint first.", true); input.focus(); return; }
|
| 36 |
+
|
| 37 |
+
btn.disabled = true;
|
| 38 |
+
spin.classList.remove("hidden");
|
| 39 |
+
try {
|
| 40 |
+
const data = await api("/api/classify", {
|
| 41 |
+
method: "POST",
|
| 42 |
+
body: {
|
| 43 |
+
text,
|
| 44 |
+
translate: $("#optTranslate").checked,
|
| 45 |
+
multi_label: $("#optMulti").checked,
|
| 46 |
+
save: $("#optSave").checked,
|
| 47 |
+
threshold: parseFloat(thr.value),
|
| 48 |
+
},
|
| 49 |
+
});
|
| 50 |
+
render(data);
|
| 51 |
+
toast(`Classified as "${data.predicted_label}" in ${data.took_ms} ms`);
|
| 52 |
+
} catch (err) {
|
| 53 |
+
toast(err.message, true);
|
| 54 |
+
} finally {
|
| 55 |
+
btn.disabled = false;
|
| 56 |
+
spin.classList.add("hidden");
|
| 57 |
+
}
|
| 58 |
+
}
|
| 59 |
+
|
| 60 |
+
function render(d) {
|
| 61 |
+
$("#resultEmpty").classList.add("hidden");
|
| 62 |
+
$("#result").classList.remove("hidden");
|
| 63 |
+
|
| 64 |
+
$("#rLabel").textContent = d.predicted_label;
|
| 65 |
+
$("#rTime").textContent = d.took_ms + " ms";
|
| 66 |
+
$("#rEngine").textContent = d.engine;
|
| 67 |
+
$("#rEngine").title = d.engine;
|
| 68 |
+
|
| 69 |
+
// Confidence ring -- reset to empty first so the sweep animates every time.
|
| 70 |
+
const ring = $("#rRing");
|
| 71 |
+
const arc = $(".ring-fg", ring);
|
| 72 |
+
ring.classList.toggle("low", !d.confident);
|
| 73 |
+
$("#rConf").textContent = Math.round(d.confidence * 100) + "%";
|
| 74 |
+
arc.style.strokeDasharray = RING_C;
|
| 75 |
+
arc.style.strokeDashoffset = RING_C;
|
| 76 |
+
requestAnimationFrame(() =>
|
| 77 |
+
requestAnimationFrame(() => {
|
| 78 |
+
arc.style.strokeDashoffset = RING_C * (1 - Math.min(d.confidence, 1));
|
| 79 |
+
})
|
| 80 |
+
);
|
| 81 |
+
|
| 82 |
+
const t = d.translation;
|
| 83 |
+
// Romanised text (Hindi typed in Latin letters, say) often detects with low
|
| 84 |
+
// confidence even when the translation itself is fine -- say so rather than
|
| 85 |
+
// presenting a shaky guess as fact.
|
| 86 |
+
const shaky = t.detection_confidence > 0 && t.detection_confidence < 0.6 ? " · uncertain" : "";
|
| 87 |
+
const lang = `${t.source_lang_name} (${t.source_lang})${shaky}`;
|
| 88 |
+
$("#rLang").textContent = lang + (t.was_translated ? " → EN" : "");
|
| 89 |
+
$("#rLang").title = t.was_translated ? `${lang}, translated to English` : lang;
|
| 90 |
+
|
| 91 |
+
const warn = $("#rWarn");
|
| 92 |
+
if (!d.confident) {
|
| 93 |
+
$("#rWarnText").textContent =
|
| 94 |
+
`Low confidence — the top score of ${(d.confidence * 100).toFixed(1)}% is below the ` +
|
| 95 |
+
`${(d.threshold * 100).toFixed(0)}% threshold. Treat this as unclassified, or add a ` +
|
| 96 |
+
`label that covers this kind of complaint.`;
|
| 97 |
+
warn.classList.remove("hidden");
|
| 98 |
+
} else {
|
| 99 |
+
warn.classList.add("hidden");
|
| 100 |
+
}
|
| 101 |
+
|
| 102 |
+
$("#rOriginal").textContent = t.original_text;
|
| 103 |
+
$("#rTranslated").textContent = t.translated_text;
|
| 104 |
+
const note = $("#rNote");
|
| 105 |
+
note.textContent = t.note || "";
|
| 106 |
+
note.classList.toggle("hidden", !t.note);
|
| 107 |
+
$("#rTransBox").open = t.was_translated;
|
| 108 |
+
|
| 109 |
+
// Bars are scaled to the top score so small differences stay readable.
|
| 110 |
+
const max = Math.max(...d.scores.map((s) => s.score), 0.0001);
|
| 111 |
+
$("#rScores").innerHTML = d.scores
|
| 112 |
+
.map((s, i) => `
|
| 113 |
+
<div class="score-row ${i === 0 ? "top" : ""}" style="--h: ${hueOf(s.label)}">
|
| 114 |
+
<span class="n" title="${escapeHtml(s.label)}">${escapeHtml(s.label)}</span>
|
| 115 |
+
<span class="bar"><span data-w="${(s.score / max * 100).toFixed(1)}%"></span></span>
|
| 116 |
+
<span class="v">${(s.score * 100).toFixed(1)}%</span>
|
| 117 |
+
</div>`)
|
| 118 |
+
.join("");
|
| 119 |
+
|
| 120 |
+
// Stagger the bar fills so the ranking reads left-to-right, top-down.
|
| 121 |
+
requestAnimationFrame(() =>
|
| 122 |
+
$$("#rScores .bar > span").forEach((el, i) => {
|
| 123 |
+
setTimeout(() => { el.style.width = el.dataset.w; }, 40 + i * 35);
|
| 124 |
+
})
|
| 125 |
+
);
|
| 126 |
+
}
|
| 127 |
+
});
|
app/static/js/history.js
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/* History page: client-side filtering, animated bars and history reset. */
|
| 2 |
+
|
| 3 |
+
document.addEventListener("DOMContentLoaded", () => {
|
| 4 |
+
// Animate the breakdown bars in on load.
|
| 5 |
+
$$(".stat .bar > span").forEach((el, i) => {
|
| 6 |
+
const w = el.style.width;
|
| 7 |
+
el.style.width = "0";
|
| 8 |
+
setTimeout(() => { el.style.width = w; }, 60 + i * 45);
|
| 9 |
+
});
|
| 10 |
+
|
| 11 |
+
const filter = $("#filter");
|
| 12 |
+
if (filter) {
|
| 13 |
+
const rows = $$(".history tbody tr");
|
| 14 |
+
const countEl = $("#filterCount");
|
| 15 |
+
const totalText = countEl?.textContent || "";
|
| 16 |
+
|
| 17 |
+
filter.addEventListener("input", () => {
|
| 18 |
+
const q = filter.value.toLowerCase().trim();
|
| 19 |
+
let shown = 0;
|
| 20 |
+
rows.forEach((tr) => {
|
| 21 |
+
const hit = !q || tr.textContent.toLowerCase().includes(q);
|
| 22 |
+
tr.style.display = hit ? "" : "none";
|
| 23 |
+
if (hit) shown++;
|
| 24 |
+
});
|
| 25 |
+
if (countEl) countEl.textContent = q ? `${shown} of ${rows.length} shown` : totalText;
|
| 26 |
+
});
|
| 27 |
+
}
|
| 28 |
+
|
| 29 |
+
$("#btnClearHistory")?.addEventListener("click", async () => {
|
| 30 |
+
if (!confirm("Delete every stored complaint? This cannot be undone.")) return;
|
| 31 |
+
try {
|
| 32 |
+
const res = await api("/api/history", { method: "DELETE" });
|
| 33 |
+
toast(`Deleted ${res.deleted} complaint(s).`);
|
| 34 |
+
setTimeout(() => location.reload(), 700);
|
| 35 |
+
} catch (err) {
|
| 36 |
+
toast(err.message, true);
|
| 37 |
+
}
|
| 38 |
+
});
|
| 39 |
+
});
|
app/static/js/labels.js
ADDED
|
@@ -0,0 +1,124 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/* Labels page: add, edit, toggle and delete labels without a page reload. */
|
| 2 |
+
|
| 3 |
+
document.addEventListener("DOMContentLoaded", () => {
|
| 4 |
+
const tbody = $("#labelTable tbody");
|
| 5 |
+
|
| 6 |
+
/* ------------------------------------------------------------- add --- */
|
| 7 |
+
$("#addForm").addEventListener("submit", async (e) => {
|
| 8 |
+
e.preventDefault();
|
| 9 |
+
const name = $("#newName").value.trim();
|
| 10 |
+
const description = $("#newDesc").value.trim();
|
| 11 |
+
if (!name) return;
|
| 12 |
+
|
| 13 |
+
try {
|
| 14 |
+
const label = await api("/api/labels", {
|
| 15 |
+
method: "POST",
|
| 16 |
+
body: { name, description, active: true },
|
| 17 |
+
});
|
| 18 |
+
const row = rowFor(label);
|
| 19 |
+
tbody.appendChild(row);
|
| 20 |
+
$("#emptyLabels")?.remove();
|
| 21 |
+
row.animate(
|
| 22 |
+
[{ opacity: 0, transform: "translateY(-6px)" }, { opacity: 1, transform: "none" }],
|
| 23 |
+
{ duration: 260, easing: "cubic-bezier(.22,1,.36,1)" }
|
| 24 |
+
);
|
| 25 |
+
$("#newName").value = "";
|
| 26 |
+
$("#newDesc").value = "";
|
| 27 |
+
$("#newName").focus();
|
| 28 |
+
bumpCount(1);
|
| 29 |
+
toast(`Added "${label.name}" — live for the next classification.`);
|
| 30 |
+
} catch (err) {
|
| 31 |
+
toast(err.message, true);
|
| 32 |
+
}
|
| 33 |
+
});
|
| 34 |
+
|
| 35 |
+
/* -------------------------------------------------- edit / delete --- */
|
| 36 |
+
tbody.addEventListener("click", async (e) => {
|
| 37 |
+
const row = e.target.closest("tr");
|
| 38 |
+
if (!row) return;
|
| 39 |
+
|
| 40 |
+
if (e.target.closest(".save")) {
|
| 41 |
+
await save(row, row.dataset.id);
|
| 42 |
+
} else if (e.target.closest(".del")) {
|
| 43 |
+
const name = $(".name", row).value;
|
| 44 |
+
if (!confirm(`Delete label "${name}"? Past complaints keep their label text.`)) return;
|
| 45 |
+
try {
|
| 46 |
+
await api(`/api/labels/${row.dataset.id}`, { method: "DELETE" });
|
| 47 |
+
row.animate([{ opacity: 1 }, { opacity: 0 }], { duration: 160 })
|
| 48 |
+
.finished.then(() => row.remove());
|
| 49 |
+
bumpCount(-1);
|
| 50 |
+
toast(`Deleted "${name}".`);
|
| 51 |
+
} catch (err) {
|
| 52 |
+
toast(err.message, true);
|
| 53 |
+
}
|
| 54 |
+
}
|
| 55 |
+
});
|
| 56 |
+
|
| 57 |
+
// Toggling Active saves straight away; text edits just mark the row dirty.
|
| 58 |
+
tbody.addEventListener("change", (e) => {
|
| 59 |
+
if (e.target.classList.contains("active")) {
|
| 60 |
+
const row = e.target.closest("tr");
|
| 61 |
+
save(row, row.dataset.id);
|
| 62 |
+
}
|
| 63 |
+
});
|
| 64 |
+
tbody.addEventListener("input", (e) => {
|
| 65 |
+
if (e.target.classList.contains("cell-input")) {
|
| 66 |
+
e.target.closest("tr").classList.add("dirty");
|
| 67 |
+
}
|
| 68 |
+
});
|
| 69 |
+
tbody.addEventListener("keydown", (e) => {
|
| 70 |
+
if (e.key === "Enter" && e.target.classList.contains("cell-input")) {
|
| 71 |
+
e.preventDefault();
|
| 72 |
+
const row = e.target.closest("tr");
|
| 73 |
+
save(row, row.dataset.id);
|
| 74 |
+
}
|
| 75 |
+
});
|
| 76 |
+
|
| 77 |
+
async function save(row, id) {
|
| 78 |
+
const body = {
|
| 79 |
+
name: $(".name", row).value.trim(),
|
| 80 |
+
description: $(".desc", row).value.trim(),
|
| 81 |
+
active: $(".active", row).checked,
|
| 82 |
+
};
|
| 83 |
+
if (!body.name) { toast("Label name cannot be empty.", true); return; }
|
| 84 |
+
|
| 85 |
+
try {
|
| 86 |
+
const updated = await api(`/api/labels/${id}`, { method: "PATCH", body });
|
| 87 |
+
row.classList.remove("dirty");
|
| 88 |
+
row.style.setProperty("--h", hueOf(updated.name)); // colour follows a rename
|
| 89 |
+
toast(`Saved "${updated.name}".`);
|
| 90 |
+
} catch (err) {
|
| 91 |
+
toast(err.message, true);
|
| 92 |
+
}
|
| 93 |
+
}
|
| 94 |
+
|
| 95 |
+
const TRASH_ICON = `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="3 6 5 6 21 6"/><path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/></svg>`;
|
| 96 |
+
|
| 97 |
+
function rowFor(l) {
|
| 98 |
+
const tr = document.createElement("tr");
|
| 99 |
+
tr.dataset.id = l.id;
|
| 100 |
+
tr.style.setProperty("--h", hueOf(l.name));
|
| 101 |
+
tr.innerHTML = `
|
| 102 |
+
<td><input class="cell-input name" maxlength="80"></td>
|
| 103 |
+
<td><input class="cell-input desc" maxlength="500"
|
| 104 |
+
placeholder="example phrases help the zero-shot model"></td>
|
| 105 |
+
<td class="center">
|
| 106 |
+
<label class="switch">
|
| 107 |
+
<input type="checkbox" class="active" ${l.active ? "checked" : ""}>
|
| 108 |
+
<span class="slider"></span>
|
| 109 |
+
</label>
|
| 110 |
+
</td>
|
| 111 |
+
<td class="right">
|
| 112 |
+
<button class="btn btn-sm save">Save</button>
|
| 113 |
+
<button class="btn btn-sm btn-danger del" title="Delete label">${TRASH_ICON}</button>
|
| 114 |
+
</td>`;
|
| 115 |
+
$(".name", tr).value = l.name; // set via value, never innerHTML
|
| 116 |
+
$(".desc", tr).value = l.description;
|
| 117 |
+
return tr;
|
| 118 |
+
}
|
| 119 |
+
|
| 120 |
+
function bumpCount(delta) {
|
| 121 |
+
const el = $("#count");
|
| 122 |
+
el.textContent = Math.max(0, parseInt(el.textContent, 10) + delta);
|
| 123 |
+
}
|
| 124 |
+
});
|
app/store.py
ADDED
|
@@ -0,0 +1,193 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""SQLite persistence for labels and classified complaints.
|
| 2 |
+
|
| 3 |
+
A fresh connection is opened per call so the module is safe to use from
|
| 4 |
+
FastAPI's threadpool workers.
|
| 5 |
+
"""
|
| 6 |
+
import json
|
| 7 |
+
import sqlite3
|
| 8 |
+
from contextlib import contextmanager
|
| 9 |
+
from datetime import datetime, timezone
|
| 10 |
+
from typing import Any
|
| 11 |
+
|
| 12 |
+
from .config import DB_PATH, DEFAULT_LABELS
|
| 13 |
+
|
| 14 |
+
SCHEMA = """
|
| 15 |
+
CREATE TABLE IF NOT EXISTS labels (
|
| 16 |
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
| 17 |
+
name TEXT NOT NULL UNIQUE,
|
| 18 |
+
description TEXT NOT NULL DEFAULT '',
|
| 19 |
+
active INTEGER NOT NULL DEFAULT 1,
|
| 20 |
+
created_at TEXT NOT NULL
|
| 21 |
+
);
|
| 22 |
+
|
| 23 |
+
CREATE TABLE IF NOT EXISTS complaints (
|
| 24 |
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
| 25 |
+
original_text TEXT NOT NULL,
|
| 26 |
+
translated_text TEXT NOT NULL,
|
| 27 |
+
source_lang TEXT NOT NULL DEFAULT 'unknown',
|
| 28 |
+
was_translated INTEGER NOT NULL DEFAULT 0,
|
| 29 |
+
predicted_label TEXT NOT NULL,
|
| 30 |
+
confidence REAL NOT NULL,
|
| 31 |
+
scores_json TEXT NOT NULL DEFAULT '[]',
|
| 32 |
+
engine TEXT NOT NULL DEFAULT '',
|
| 33 |
+
took_ms INTEGER NOT NULL DEFAULT 0,
|
| 34 |
+
created_at TEXT NOT NULL
|
| 35 |
+
);
|
| 36 |
+
|
| 37 |
+
CREATE INDEX IF NOT EXISTS idx_complaints_created ON complaints(created_at DESC);
|
| 38 |
+
"""
|
| 39 |
+
|
| 40 |
+
|
| 41 |
+
def _now() -> str:
|
| 42 |
+
return datetime.now(timezone.utc).isoformat(timespec="seconds")
|
| 43 |
+
|
| 44 |
+
|
| 45 |
+
@contextmanager
|
| 46 |
+
def _conn():
|
| 47 |
+
conn = sqlite3.connect(DB_PATH, timeout=15)
|
| 48 |
+
conn.row_factory = sqlite3.Row
|
| 49 |
+
conn.execute("PRAGMA journal_mode=WAL")
|
| 50 |
+
conn.execute("PRAGMA foreign_keys=ON")
|
| 51 |
+
try:
|
| 52 |
+
yield conn
|
| 53 |
+
conn.commit()
|
| 54 |
+
finally:
|
| 55 |
+
conn.close()
|
| 56 |
+
|
| 57 |
+
|
| 58 |
+
def init_db() -> None:
|
| 59 |
+
with _conn() as conn:
|
| 60 |
+
conn.executescript(SCHEMA)
|
| 61 |
+
empty = conn.execute("SELECT COUNT(*) AS n FROM labels").fetchone()["n"] == 0
|
| 62 |
+
if empty:
|
| 63 |
+
conn.executemany(
|
| 64 |
+
"INSERT INTO labels (name, description, active, created_at) VALUES (?,?,1,?)",
|
| 65 |
+
[(name, desc, _now()) for name, desc in DEFAULT_LABELS],
|
| 66 |
+
)
|
| 67 |
+
|
| 68 |
+
|
| 69 |
+
# ---------------------------------------------------------------- labels ----
|
| 70 |
+
|
| 71 |
+
def list_labels(active_only: bool = False) -> list[dict[str, Any]]:
|
| 72 |
+
sql = "SELECT * FROM labels"
|
| 73 |
+
if active_only:
|
| 74 |
+
sql += " WHERE active = 1"
|
| 75 |
+
sql += " ORDER BY name COLLATE NOCASE"
|
| 76 |
+
with _conn() as conn:
|
| 77 |
+
return [dict(r) for r in conn.execute(sql)]
|
| 78 |
+
|
| 79 |
+
|
| 80 |
+
def get_label(label_id: int) -> dict[str, Any] | None:
|
| 81 |
+
with _conn() as conn:
|
| 82 |
+
row = conn.execute("SELECT * FROM labels WHERE id = ?", (label_id,)).fetchone()
|
| 83 |
+
return dict(row) if row else None
|
| 84 |
+
|
| 85 |
+
|
| 86 |
+
def add_label(name: str, description: str = "", active: bool = True) -> dict[str, Any]:
|
| 87 |
+
name = name.strip()
|
| 88 |
+
if not name:
|
| 89 |
+
raise ValueError("Label name cannot be empty.")
|
| 90 |
+
with _conn() as conn:
|
| 91 |
+
try:
|
| 92 |
+
cur = conn.execute(
|
| 93 |
+
"INSERT INTO labels (name, description, active, created_at) VALUES (?,?,?,?)",
|
| 94 |
+
(name, description.strip(), int(active), _now()),
|
| 95 |
+
)
|
| 96 |
+
except sqlite3.IntegrityError as exc:
|
| 97 |
+
raise ValueError(f"Label {name!r} already exists.") from exc
|
| 98 |
+
row = conn.execute("SELECT * FROM labels WHERE id = ?", (cur.lastrowid,)).fetchone()
|
| 99 |
+
return dict(row)
|
| 100 |
+
|
| 101 |
+
|
| 102 |
+
def update_label(
|
| 103 |
+
label_id: int,
|
| 104 |
+
name: str | None = None,
|
| 105 |
+
description: str | None = None,
|
| 106 |
+
active: bool | None = None,
|
| 107 |
+
) -> dict[str, Any] | None:
|
| 108 |
+
sets, params = [], []
|
| 109 |
+
if name is not None:
|
| 110 |
+
if not name.strip():
|
| 111 |
+
raise ValueError("Label name cannot be empty.")
|
| 112 |
+
sets.append("name = ?")
|
| 113 |
+
params.append(name.strip())
|
| 114 |
+
if description is not None:
|
| 115 |
+
sets.append("description = ?")
|
| 116 |
+
params.append(description.strip())
|
| 117 |
+
if active is not None:
|
| 118 |
+
sets.append("active = ?")
|
| 119 |
+
params.append(int(active))
|
| 120 |
+
if not sets:
|
| 121 |
+
return get_label(label_id)
|
| 122 |
+
|
| 123 |
+
params.append(label_id)
|
| 124 |
+
with _conn() as conn:
|
| 125 |
+
try:
|
| 126 |
+
conn.execute(f"UPDATE labels SET {', '.join(sets)} WHERE id = ?", params)
|
| 127 |
+
except sqlite3.IntegrityError as exc:
|
| 128 |
+
raise ValueError("Another label already uses that name.") from exc
|
| 129 |
+
row = conn.execute("SELECT * FROM labels WHERE id = ?", (label_id,)).fetchone()
|
| 130 |
+
return dict(row) if row else None
|
| 131 |
+
|
| 132 |
+
|
| 133 |
+
def delete_label(label_id: int) -> bool:
|
| 134 |
+
with _conn() as conn:
|
| 135 |
+
cur = conn.execute("DELETE FROM labels WHERE id = ?", (label_id,))
|
| 136 |
+
return cur.rowcount > 0
|
| 137 |
+
|
| 138 |
+
|
| 139 |
+
# ------------------------------------------------------------ complaints ----
|
| 140 |
+
|
| 141 |
+
def save_complaint(record: dict[str, Any]) -> int:
|
| 142 |
+
with _conn() as conn:
|
| 143 |
+
cur = conn.execute(
|
| 144 |
+
"""INSERT INTO complaints
|
| 145 |
+
(original_text, translated_text, source_lang, was_translated,
|
| 146 |
+
predicted_label, confidence, scores_json, engine, took_ms, created_at)
|
| 147 |
+
VALUES (?,?,?,?,?,?,?,?,?,?)""",
|
| 148 |
+
(
|
| 149 |
+
record["original_text"],
|
| 150 |
+
record["translated_text"],
|
| 151 |
+
record["source_lang"],
|
| 152 |
+
int(record["was_translated"]),
|
| 153 |
+
record["predicted_label"],
|
| 154 |
+
float(record["confidence"]),
|
| 155 |
+
json.dumps(record.get("scores", [])),
|
| 156 |
+
record.get("engine", ""),
|
| 157 |
+
int(record.get("took_ms", 0)),
|
| 158 |
+
_now(),
|
| 159 |
+
),
|
| 160 |
+
)
|
| 161 |
+
return int(cur.lastrowid)
|
| 162 |
+
|
| 163 |
+
|
| 164 |
+
def list_complaints(limit: int = 100, label: str | None = None) -> list[dict[str, Any]]:
|
| 165 |
+
sql = "SELECT * FROM complaints"
|
| 166 |
+
params: list[Any] = []
|
| 167 |
+
if label:
|
| 168 |
+
sql += " WHERE predicted_label = ?"
|
| 169 |
+
params.append(label)
|
| 170 |
+
sql += " ORDER BY id DESC LIMIT ?"
|
| 171 |
+
params.append(limit)
|
| 172 |
+
|
| 173 |
+
with _conn() as conn:
|
| 174 |
+
rows = [dict(r) for r in conn.execute(sql, params)]
|
| 175 |
+
for r in rows:
|
| 176 |
+
r["scores"] = json.loads(r.pop("scores_json") or "[]")
|
| 177 |
+
r["was_translated"] = bool(r["was_translated"])
|
| 178 |
+
return rows
|
| 179 |
+
|
| 180 |
+
|
| 181 |
+
def label_counts() -> list[dict[str, Any]]:
|
| 182 |
+
with _conn() as conn:
|
| 183 |
+
rows = conn.execute(
|
| 184 |
+
"SELECT predicted_label AS label, COUNT(*) AS count "
|
| 185 |
+
"FROM complaints GROUP BY predicted_label ORDER BY count DESC"
|
| 186 |
+
).fetchall()
|
| 187 |
+
return [dict(r) for r in rows]
|
| 188 |
+
|
| 189 |
+
|
| 190 |
+
def clear_complaints() -> int:
|
| 191 |
+
with _conn() as conn:
|
| 192 |
+
cur = conn.execute("DELETE FROM complaints")
|
| 193 |
+
return cur.rowcount
|
app/templates/base.html
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<!DOCTYPE html>
|
| 2 |
+
<html lang="en">
|
| 3 |
+
<head>
|
| 4 |
+
<meta charset="utf-8">
|
| 5 |
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
| 6 |
+
<title>{% block title %}Complaint Classifier{% endblock %}</title>
|
| 7 |
+
<meta name="description" content="Multilingual complaint classification with runtime-editable labels.">
|
| 8 |
+
|
| 9 |
+
<link rel="preconnect" href="https://fonts.googleapis.com">
|
| 10 |
+
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
| 11 |
+
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&family=JetBrains+Mono:wght@400;600;700&display=swap" rel="stylesheet">
|
| 12 |
+
<link rel="stylesheet" href="/static/css/style.css">
|
| 13 |
+
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>📝</text></svg>">
|
| 14 |
+
</head>
|
| 15 |
+
<body>
|
| 16 |
+
|
| 17 |
+
<!-- gradient used by the confidence ring -->
|
| 18 |
+
<svg width="0" height="0" style="position:absolute" aria-hidden="true">
|
| 19 |
+
<defs>
|
| 20 |
+
<linearGradient id="ringGrad" x1="0%" y1="0%" x2="100%" y2="100%">
|
| 21 |
+
<stop offset="0%" stop-color="#6366f1"/>
|
| 22 |
+
<stop offset="55%" stop-color="#8b5cf6"/>
|
| 23 |
+
<stop offset="100%" stop-color="#a855f7"/>
|
| 24 |
+
</linearGradient>
|
| 25 |
+
</defs>
|
| 26 |
+
</svg>
|
| 27 |
+
|
| 28 |
+
<header class="topbar">
|
| 29 |
+
<div class="wrap topbar-inner">
|
| 30 |
+
<a class="brand" href="/">
|
| 31 |
+
<span class="brand-mark">CC</span>
|
| 32 |
+
<span>Complaint Classifier</span>
|
| 33 |
+
</a>
|
| 34 |
+
|
| 35 |
+
<nav class="nav">
|
| 36 |
+
<a href="/" class="{{ 'active' if page == 'classify' }}">
|
| 37 |
+
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/></svg>
|
| 38 |
+
Classify
|
| 39 |
+
</a>
|
| 40 |
+
<a href="/labels" class="{{ 'active' if page == 'labels' }}">
|
| 41 |
+
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20.59 13.41l-7.17 7.17a2 2 0 0 1-2.83 0L2 12V2h10l8.59 8.59a2 2 0 0 1 0 2.82z"/><line x1="7" y1="7" x2="7.01" y2="7"/></svg>
|
| 42 |
+
Labels
|
| 43 |
+
</a>
|
| 44 |
+
<a href="/history" class="{{ 'active' if page == 'history' }}">
|
| 45 |
+
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/></svg>
|
| 46 |
+
History
|
| 47 |
+
</a>
|
| 48 |
+
<a href="/docs" target="_blank" rel="noopener">
|
| 49 |
+
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="16 18 22 12 16 6"/><polyline points="8 6 2 12 8 18"/></svg>
|
| 50 |
+
API
|
| 51 |
+
</a>
|
| 52 |
+
</nav>
|
| 53 |
+
|
| 54 |
+
<div class="model-status" id="modelStatus" title="Model status">
|
| 55 |
+
<span class="dot dot-idle"></span><span class="model-status-text">checking…</span>
|
| 56 |
+
</div>
|
| 57 |
+
</div>
|
| 58 |
+
</header>
|
| 59 |
+
|
| 60 |
+
<main class="wrap page">
|
| 61 |
+
{% block content %}{% endblock %}
|
| 62 |
+
</main>
|
| 63 |
+
|
| 64 |
+
<footer class="wrap foot">
|
| 65 |
+
Language detected offline, translated with Google Translate, classified by a small
|
| 66 |
+
zero-shot NLI model.
|
| 67 |
+
<span class="sep">·</span>
|
| 68 |
+
Labels are editable at runtime — no retraining.
|
| 69 |
+
</footer>
|
| 70 |
+
|
| 71 |
+
<div id="toast" class="toast" role="status" aria-live="polite"></div>
|
| 72 |
+
<script src="/static/js/app.js"></script>
|
| 73 |
+
{% block scripts %}{% endblock %}
|
| 74 |
+
</body>
|
| 75 |
+
</html>
|
app/templates/history.html
ADDED
|
@@ -0,0 +1,103 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{% extends "base.html" %}
|
| 2 |
+
{% block title %}History{% endblock %}
|
| 3 |
+
|
| 4 |
+
{% block content %}
|
| 5 |
+
<div class="hero">
|
| 6 |
+
<h1>Classified <span class="grad">complaints</span></h1>
|
| 7 |
+
<p>Everything the classifier has processed, with the language it came in and the
|
| 8 |
+
English text the model actually scored.</p>
|
| 9 |
+
</div>
|
| 10 |
+
|
| 11 |
+
{% if counts %}
|
| 12 |
+
{% set total = counts|sum(attribute='count') %}
|
| 13 |
+
<section class="card">
|
| 14 |
+
<div class="row-between">
|
| 15 |
+
<h2 style="margin:0">
|
| 16 |
+
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="20" x2="18" y2="10"/><line x1="12" y1="20" x2="12" y2="4"/><line x1="6" y1="20" x2="6" y2="14"/></svg>
|
| 17 |
+
Breakdown <span class="badge">{{ total }}</span>
|
| 18 |
+
</h2>
|
| 19 |
+
<button class="btn btn-sm btn-danger" id="btnClearHistory">
|
| 20 |
+
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="3 6 5 6 21 6"/><path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6"/></svg>
|
| 21 |
+
Clear history
|
| 22 |
+
</button>
|
| 23 |
+
</div>
|
| 24 |
+
|
| 25 |
+
<div class="stats">
|
| 26 |
+
{% for c in counts %}
|
| 27 |
+
<div class="stat" style="--h: {{ c.label|hue }}">
|
| 28 |
+
<div class="stat-top">
|
| 29 |
+
<span class="stat-label">{{ c.label }}</span>
|
| 30 |
+
<span class="stat-count">{{ c.count }}</span>
|
| 31 |
+
</div>
|
| 32 |
+
<div class="bar"><span style="width: {{ (100 * c.count / total)|round(1) }}%"></span></div>
|
| 33 |
+
</div>
|
| 34 |
+
{% endfor %}
|
| 35 |
+
</div>
|
| 36 |
+
</section>
|
| 37 |
+
{% endif %}
|
| 38 |
+
|
| 39 |
+
<section class="card">
|
| 40 |
+
{% if complaints %}
|
| 41 |
+
<div class="filter-row">
|
| 42 |
+
<div class="search-wrap">
|
| 43 |
+
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>
|
| 44 |
+
<input type="search" id="filter" placeholder="Filter by text or label…">
|
| 45 |
+
</div>
|
| 46 |
+
<span class="muted small" id="filterCount">{{ complaints|length }} most recent</span>
|
| 47 |
+
</div>
|
| 48 |
+
|
| 49 |
+
<div class="table-scroll">
|
| 50 |
+
<table class="table history">
|
| 51 |
+
<thead>
|
| 52 |
+
<tr>
|
| 53 |
+
<th style="width:52px">#</th>
|
| 54 |
+
<th style="width:180px">Category</th>
|
| 55 |
+
<th style="width:88px">Conf.</th>
|
| 56 |
+
<th style="width:96px">Lang</th>
|
| 57 |
+
<th>Complaint</th>
|
| 58 |
+
<th style="width:150px">When <span style="opacity:.6">(UTC)</span></th>
|
| 59 |
+
</tr>
|
| 60 |
+
</thead>
|
| 61 |
+
<tbody>
|
| 62 |
+
{% for c in complaints %}
|
| 63 |
+
<tr>
|
| 64 |
+
<td class="mono muted">{{ c.id }}</td>
|
| 65 |
+
<td><span class="pill" style="--h: {{ c.predicted_label|hue }}">{{ c.predicted_label }}</span></td>
|
| 66 |
+
<td>
|
| 67 |
+
<span class="conf-cell">
|
| 68 |
+
<span class="conf-dot {{ 'low' if c.confidence < 0.35 }}"></span>
|
| 69 |
+
<span class="mono">{{ '%.2f'|format(c.confidence) }}</span>
|
| 70 |
+
</span>
|
| 71 |
+
</td>
|
| 72 |
+
<td>
|
| 73 |
+
<span class="lang">{{ c.source_lang }}</span>
|
| 74 |
+
{% if c.was_translated %}<span class="tag" title="Translated to English">EN</span>{% endif %}
|
| 75 |
+
</td>
|
| 76 |
+
<td>
|
| 77 |
+
<div class="c-orig">{{ c.original_text }}</div>
|
| 78 |
+
{% if c.was_translated %}<div class="c-trans">{{ c.translated_text }}</div>{% endif %}
|
| 79 |
+
</td>
|
| 80 |
+
<td class="mono muted small">{{ c.created_at.replace('T', ' ')[:16] }}</td>
|
| 81 |
+
</tr>
|
| 82 |
+
{% endfor %}
|
| 83 |
+
</tbody>
|
| 84 |
+
</table>
|
| 85 |
+
</div>
|
| 86 |
+
|
| 87 |
+
{% else %}
|
| 88 |
+
<div class="empty">
|
| 89 |
+
<div class="empty-icon">
|
| 90 |
+
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/></svg>
|
| 91 |
+
</div>
|
| 92 |
+
<strong>No complaints yet</strong>
|
| 93 |
+
<p>Once you classify something it shows up here with its language, translation and score.</p>
|
| 94 |
+
<a class="btn btn-primary" href="/" style="margin-top:18px">
|
| 95 |
+
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M5 3l14 9-14 9V3z"/></svg>
|
| 96 |
+
Submit one
|
| 97 |
+
</a>
|
| 98 |
+
</div>
|
| 99 |
+
{% endif %}
|
| 100 |
+
</section>
|
| 101 |
+
{% endblock %}
|
| 102 |
+
|
| 103 |
+
{% block scripts %}<script src="/static/js/history.js"></script>{% endblock %}
|
app/templates/index.html
ADDED
|
@@ -0,0 +1,144 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{% extends "base.html" %}
|
| 2 |
+
{% block title %}Classify a complaint{% endblock %}
|
| 3 |
+
|
| 4 |
+
{% block content %}
|
| 5 |
+
<div class="hero">
|
| 6 |
+
<h1>Turn any complaint into a <span class="grad">problem type</span></h1>
|
| 7 |
+
<p>Write in any language. The text is detected, translated to English, then scored
|
| 8 |
+
against your active labels in real time.</p>
|
| 9 |
+
</div>
|
| 10 |
+
|
| 11 |
+
<div class="grid-2">
|
| 12 |
+
<!-- ------------------------------------------------------------ input -->
|
| 13 |
+
<section class="card">
|
| 14 |
+
<h2>
|
| 15 |
+
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 20h9"/><path d="M16.5 3.5a2.12 2.12 0 0 1 3 3L7 19l-4 1 1-4z"/></svg>
|
| 16 |
+
Submit a complaint
|
| 17 |
+
</h2>
|
| 18 |
+
<p class="sub">Sinhala, Tamil, Hindi, English — anything Google Translate supports.</p>
|
| 19 |
+
|
| 20 |
+
<textarea id="complaint" rows="7" spellcheck="false"
|
| 21 |
+
placeholder="අපේ ගමේ දින තුනක් තිස්සේ වතුර නැහැ Hamare area me 3 din se bijli nahi hai The street light near the bus stand is broken"></textarea>
|
| 22 |
+
|
| 23 |
+
<div class="samples">
|
| 24 |
+
<span class="samples-label">Try</span>
|
| 25 |
+
<button class="chip" data-sample="අපේ ගමේ දින තුනක් තිස්සේ වතුර නැහැ. කරුණාකර බලන්න."><span class="flag">🇱🇰</span> Sinhala · water</button>
|
| 26 |
+
<button class="chip" data-sample="எங்கள் பகுதியில் மூன்று நாட்களாக மின்சாரம் இல்லை"><span class="flag">🇱🇰</span> Tamil · power</button>
|
| 27 |
+
<button class="chip" data-sample="हमारे मोहल्ले में कचरा एक हफ्ते से नहीं उठाया गया है"><span class="flag">🇮🇳</span> Hindi · waste</button>
|
| 28 |
+
<button class="chip" data-sample="There is a huge pothole on the main road and it is causing accidents."><span class="flag">🇬🇧</span> English · road</button>
|
| 29 |
+
</div>
|
| 30 |
+
|
| 31 |
+
<div class="options">
|
| 32 |
+
<label class="check"><input type="checkbox" id="optTranslate" checked> Translate first</label>
|
| 33 |
+
<label class="check"><input type="checkbox" id="optMulti"> Multi-label</label>
|
| 34 |
+
<label class="check"><input type="checkbox" id="optSave" checked> Save to history</label>
|
| 35 |
+
<label class="range" title="Below this score the result is flagged low-confidence">
|
| 36 |
+
Threshold
|
| 37 |
+
<input type="range" id="optThreshold" min="0" max="0.9" step="0.05" value="{{ threshold }}">
|
| 38 |
+
<output id="thrOut">{{ '%.2f'|format(threshold) }}</output>
|
| 39 |
+
</label>
|
| 40 |
+
</div>
|
| 41 |
+
|
| 42 |
+
<div class="actions">
|
| 43 |
+
<button id="btnClassify" class="btn btn-primary">
|
| 44 |
+
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M5 3l14 9-14 9V3z"/></svg>
|
| 45 |
+
Classify
|
| 46 |
+
</button>
|
| 47 |
+
<button id="btnClear" class="btn btn-ghost">Clear</button>
|
| 48 |
+
<span id="spin" class="spinner hidden"></span>
|
| 49 |
+
<span class="muted small" style="margin-left:auto">⌘/Ctrl + ↵</span>
|
| 50 |
+
</div>
|
| 51 |
+
</section>
|
| 52 |
+
|
| 53 |
+
<!-- ----------------------------------------------------------- result -->
|
| 54 |
+
<section class="card" id="resultCard">
|
| 55 |
+
<h2>
|
| 56 |
+
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M22 11.08V12a10 10 0 1 1-5.93-9.14"/><polyline points="22 4 12 14.01 9 11.01"/></svg>
|
| 57 |
+
Result
|
| 58 |
+
</h2>
|
| 59 |
+
|
| 60 |
+
<div id="resultEmpty" class="empty">
|
| 61 |
+
<div class="empty-icon">
|
| 62 |
+
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>
|
| 63 |
+
</div>
|
| 64 |
+
<strong>Nothing classified yet</strong>
|
| 65 |
+
<p>Submit a complaint and the category, detected language, translation and every
|
| 66 |
+
label score appear here.</p>
|
| 67 |
+
</div>
|
| 68 |
+
|
| 69 |
+
<div id="result" class="hidden">
|
| 70 |
+
<div class="verdict">
|
| 71 |
+
<div class="verdict-main">
|
| 72 |
+
<div class="verdict-eyebrow">Predicted category</div>
|
| 73 |
+
<div class="verdict-label" id="rLabel">—</div>
|
| 74 |
+
</div>
|
| 75 |
+
<div class="ring" id="rRing">
|
| 76 |
+
<svg viewBox="0 0 80 80">
|
| 77 |
+
<circle class="ring-bg" cx="40" cy="40" r="37"/>
|
| 78 |
+
<circle class="ring-fg" cx="40" cy="40" r="37"/>
|
| 79 |
+
</svg>
|
| 80 |
+
<div class="ring-val" id="rConf">—</div>
|
| 81 |
+
</div>
|
| 82 |
+
</div>
|
| 83 |
+
|
| 84 |
+
<div id="rWarn" class="warn hidden">
|
| 85 |
+
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"/><line x1="12" y1="9" x2="12" y2="13"/><line x1="12" y1="17" x2="12.01" y2="17"/></svg>
|
| 86 |
+
<span id="rWarnText"></span>
|
| 87 |
+
</div>
|
| 88 |
+
|
| 89 |
+
<div class="kv">
|
| 90 |
+
<div><div class="kv-k">Language</div><div class="kv-v" id="rLang">—</div></div>
|
| 91 |
+
<div><div class="kv-k">Latency</div><div class="kv-v" id="rTime">—</div></div>
|
| 92 |
+
<div><div class="kv-k">Engine</div><div class="kv-v mono" id="rEngine" style="font-size:11.5px">—</div></div>
|
| 93 |
+
</div>
|
| 94 |
+
|
| 95 |
+
<details id="rTransBox" class="trans">
|
| 96 |
+
<summary>Original & translation</summary>
|
| 97 |
+
<div class="trans-body">
|
| 98 |
+
<div class="q-label">Original</div>
|
| 99 |
+
<p id="rOriginal" class="quote"></p>
|
| 100 |
+
<div class="q-label">English</div>
|
| 101 |
+
<p id="rTranslated" class="quote"></p>
|
| 102 |
+
<p id="rNote" class="note hidden"></p>
|
| 103 |
+
</div>
|
| 104 |
+
</details>
|
| 105 |
+
|
| 106 |
+
<h3>All label scores</h3>
|
| 107 |
+
<div id="rScores" class="scores"></div>
|
| 108 |
+
</div>
|
| 109 |
+
</section>
|
| 110 |
+
</div>
|
| 111 |
+
|
| 112 |
+
<!-- ------------------------------------------------------- active labels -->
|
| 113 |
+
<section class="card">
|
| 114 |
+
<div class="row-between">
|
| 115 |
+
<h2 style="margin:0">
|
| 116 |
+
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20.59 13.41l-7.17 7.17a2 2 0 0 1-2.83 0L2 12V2h10l8.59 8.59a2 2 0 0 1 0 2.82z"/><line x1="7" y1="7" x2="7.01" y2="7"/></svg>
|
| 117 |
+
Active labels <span class="badge" id="labelCount">{{ labels|length }}</span>
|
| 118 |
+
</h2>
|
| 119 |
+
<a class="btn btn-sm" href="/labels">
|
| 120 |
+
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 20h9"/><path d="M16.5 3.5a2.12 2.12 0 0 1 3 3L7 19l-4 1 1-4z"/></svg>
|
| 121 |
+
Edit labels
|
| 122 |
+
</a>
|
| 123 |
+
</div>
|
| 124 |
+
|
| 125 |
+
{% if labels %}
|
| 126 |
+
<div class="label-strip">
|
| 127 |
+
{% for l in labels %}
|
| 128 |
+
<span class="pill" style="--h: {{ l.name|hue }}" title="{{ l.description }}">{{ l.name }}</span>
|
| 129 |
+
{% endfor %}
|
| 130 |
+
</div>
|
| 131 |
+
<p class="muted small" style="margin:0">
|
| 132 |
+
Anything you change on the Labels page applies to the very next classification —
|
| 133 |
+
the model scores against whatever is active at request time.
|
| 134 |
+
</p>
|
| 135 |
+
{% else %}
|
| 136 |
+
<div class="warn" style="margin-top:4px">
|
| 137 |
+
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"/><line x1="12" y1="9" x2="12" y2="13"/><line x1="12" y1="17" x2="12.01" y2="17"/></svg>
|
| 138 |
+
<span>No active labels yet — <a href="/labels">add one</a> before classifying.</span>
|
| 139 |
+
</div>
|
| 140 |
+
{% endif %}
|
| 141 |
+
</section>
|
| 142 |
+
{% endblock %}
|
| 143 |
+
|
| 144 |
+
{% block scripts %}<script src="/static/js/classify.js"></script>{% endblock %}
|
app/templates/labels.html
ADDED
|
@@ -0,0 +1,94 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{% extends "base.html" %}
|
| 2 |
+
{% block title %}Labels{% endblock %}
|
| 3 |
+
|
| 4 |
+
{% block content %}
|
| 5 |
+
<div class="hero">
|
| 6 |
+
<h1>Classification <span class="grad">labels</span></h1>
|
| 7 |
+
<p>The categories the model chooses between. Classification is zero-shot, so anything
|
| 8 |
+
you add here works on the next complaint — no retraining, no restart.</p>
|
| 9 |
+
</div>
|
| 10 |
+
|
| 11 |
+
<section class="card">
|
| 12 |
+
<h2>
|
| 13 |
+
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>
|
| 14 |
+
Add a label
|
| 15 |
+
</h2>
|
| 16 |
+
<p class="sub">
|
| 17 |
+
The name is what the model matches against, so keep it a short, plain category.
|
| 18 |
+
The description is a keyword hint layered on top — list the concrete phrases your
|
| 19 |
+
complainants actually use and it will break ties the model gets wrong.
|
| 20 |
+
</p>
|
| 21 |
+
|
| 22 |
+
<form id="addForm" class="add-form">
|
| 23 |
+
<div class="field">
|
| 24 |
+
<label for="newName">Label name</label>
|
| 25 |
+
<input id="newName" type="text" maxlength="80" required placeholder="Street Lighting">
|
| 26 |
+
</div>
|
| 27 |
+
<div class="field">
|
| 28 |
+
<label for="newDesc">Description / example phrases</label>
|
| 29 |
+
<input id="newDesc" type="text" maxlength="500"
|
| 30 |
+
placeholder="broken street light, dark road at night, lamp post not working">
|
| 31 |
+
</div>
|
| 32 |
+
<button class="btn btn-primary" type="submit">
|
| 33 |
+
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>
|
| 34 |
+
Add label
|
| 35 |
+
</button>
|
| 36 |
+
</form>
|
| 37 |
+
</section>
|
| 38 |
+
|
| 39 |
+
<section class="card">
|
| 40 |
+
<div class="row-between">
|
| 41 |
+
<h2 style="margin:0">
|
| 42 |
+
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="8" y1="6" x2="21" y2="6"/><line x1="8" y1="12" x2="21" y2="12"/><line x1="8" y1="18" x2="21" y2="18"/><line x1="3" y1="6" x2="3.01" y2="6"/><line x1="3" y1="12" x2="3.01" y2="12"/><line x1="3" y1="18" x2="3.01" y2="18"/></svg>
|
| 43 |
+
Configured labels <span class="badge" id="count">{{ labels|length }}</span>
|
| 44 |
+
</h2>
|
| 45 |
+
<span class="muted small">Edit inline, press ↵ or Save. Toggle off to exclude without deleting.</span>
|
| 46 |
+
</div>
|
| 47 |
+
|
| 48 |
+
<div class="table-scroll">
|
| 49 |
+
<table class="table" id="labelTable">
|
| 50 |
+
<thead>
|
| 51 |
+
<tr>
|
| 52 |
+
<th style="width:24%">Name</th>
|
| 53 |
+
<th>Description / example phrases</th>
|
| 54 |
+
<th style="width:80px" class="center">Active</th>
|
| 55 |
+
<th style="width:145px"></th>
|
| 56 |
+
</tr>
|
| 57 |
+
</thead>
|
| 58 |
+
<tbody>
|
| 59 |
+
{% for l in labels %}
|
| 60 |
+
<tr data-id="{{ l.id }}" style="--h: {{ l.name|hue }}">
|
| 61 |
+
<td><input class="cell-input name" value="{{ l.name }}" maxlength="80"></td>
|
| 62 |
+
<td><input class="cell-input desc" value="{{ l.description }}" maxlength="500"
|
| 63 |
+
placeholder="example phrases help the zero-shot model"></td>
|
| 64 |
+
<td class="center">
|
| 65 |
+
<label class="switch">
|
| 66 |
+
<input type="checkbox" class="active" {% if l.active %}checked{% endif %}>
|
| 67 |
+
<span class="slider"></span>
|
| 68 |
+
</label>
|
| 69 |
+
</td>
|
| 70 |
+
<td class="right">
|
| 71 |
+
<button class="btn btn-sm save">Save</button>
|
| 72 |
+
<button class="btn btn-sm btn-danger del" title="Delete label">
|
| 73 |
+
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="3 6 5 6 21 6"/><path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/></svg>
|
| 74 |
+
</button>
|
| 75 |
+
</td>
|
| 76 |
+
</tr>
|
| 77 |
+
{% endfor %}
|
| 78 |
+
</tbody>
|
| 79 |
+
</table>
|
| 80 |
+
</div>
|
| 81 |
+
|
| 82 |
+
{% if not labels %}
|
| 83 |
+
<div class="empty" id="emptyLabels">
|
| 84 |
+
<div class="empty-icon">
|
| 85 |
+
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M20.59 13.41l-7.17 7.17a2 2 0 0 1-2.83 0L2 12V2h10l8.59 8.59a2 2 0 0 1 0 2.82z"/><line x1="7" y1="7" x2="7.01" y2="7"/></svg>
|
| 86 |
+
</div>
|
| 87 |
+
<strong>No labels yet</strong>
|
| 88 |
+
<p>Add your first category above — the classifier needs at least one.</p>
|
| 89 |
+
</div>
|
| 90 |
+
{% endif %}
|
| 91 |
+
</section>
|
| 92 |
+
{% endblock %}
|
| 93 |
+
|
| 94 |
+
{% block scripts %}<script src="/static/js/labels.js"></script>{% endblock %}
|
app/translator.py
ADDED
|
@@ -0,0 +1,194 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Language detection + translation to English.
|
| 2 |
+
|
| 3 |
+
Detection uses `langdetect` (offline, instant). Translation uses
|
| 4 |
+
`deep-translator`'s Google Translate backend, which needs network access.
|
| 5 |
+
Every failure degrades gracefully: the original text is passed through so a
|
| 6 |
+
complaint is never lost just because translation was unavailable.
|
| 7 |
+
"""
|
| 8 |
+
from __future__ import annotations
|
| 9 |
+
|
| 10 |
+
import logging
|
| 11 |
+
from dataclasses import dataclass, asdict
|
| 12 |
+
from typing import Any
|
| 13 |
+
|
| 14 |
+
from .config import TRANSLATE_ENABLED
|
| 15 |
+
|
| 16 |
+
log = logging.getLogger("translator")
|
| 17 |
+
|
| 18 |
+
try:
|
| 19 |
+
from langdetect import DetectorFactory, LangDetectException, detect_langs
|
| 20 |
+
|
| 21 |
+
DetectorFactory.seed = 0 # make detection deterministic
|
| 22 |
+
_LANGDETECT_OK = True
|
| 23 |
+
except Exception: # pragma: no cover - dependency missing
|
| 24 |
+
_LANGDETECT_OK = False
|
| 25 |
+
LangDetectException = Exception # type: ignore[misc,assignment]
|
| 26 |
+
|
| 27 |
+
try:
|
| 28 |
+
from deep_translator import GoogleTranslator
|
| 29 |
+
|
| 30 |
+
_TRANSLATOR_OK = True
|
| 31 |
+
except Exception: # pragma: no cover - dependency missing
|
| 32 |
+
_TRANSLATOR_OK = False
|
| 33 |
+
|
| 34 |
+
# Google Translate rejects payloads above ~5000 characters.
|
| 35 |
+
MAX_CHARS = 4800
|
| 36 |
+
|
| 37 |
+
LANG_NAMES = {
|
| 38 |
+
"en": "English", "si": "Sinhala", "ta": "Tamil", "hi": "Hindi", "ur": "Urdu",
|
| 39 |
+
"ar": "Arabic", "bn": "Bengali", "ml": "Malayalam", "te": "Telugu", "kn": "Kannada",
|
| 40 |
+
"mr": "Marathi", "gu": "Gujarati", "pa": "Punjabi", "ne": "Nepali", "es": "Spanish",
|
| 41 |
+
"fr": "French", "de": "German", "pt": "Portuguese", "ru": "Russian", "zh-cn": "Chinese",
|
| 42 |
+
"ja": "Japanese", "ko": "Korean", "id": "Indonesian", "th": "Thai", "vi": "Vietnamese",
|
| 43 |
+
"tr": "Turkish", "it": "Italian", "nl": "Dutch", "fa": "Persian", "sw": "Swahili",
|
| 44 |
+
}
|
| 45 |
+
|
| 46 |
+
|
| 47 |
+
def language_name(code: str) -> str:
|
| 48 |
+
return LANG_NAMES.get((code or "").lower(), (code or "unknown").upper())
|
| 49 |
+
|
| 50 |
+
|
| 51 |
+
# --------------------------------------------------------- script detection --
|
| 52 |
+
# langdetect only covers 55 languages -- Sinhala is not among them, and it is
|
| 53 |
+
# shaky on short text. Unicode-block detection is exact for non-Latin scripts,
|
| 54 |
+
# so it runs alongside langdetect and wins whenever the two disagree about
|
| 55 |
+
# which script the text is even written in.
|
| 56 |
+
#
|
| 57 |
+
# (start, end, default_lang, other langdetect langs using the same script)
|
| 58 |
+
_SCRIPTS: list[tuple[int, int, str, frozenset[str]]] = [
|
| 59 |
+
(0x0D80, 0x0DFF, "si", frozenset()), # Sinhala
|
| 60 |
+
(0x0B80, 0x0BFF, "ta", frozenset({"ta"})), # Tamil
|
| 61 |
+
(0x0900, 0x097F, "hi", frozenset({"hi", "mr", "ne"})), # Devanagari
|
| 62 |
+
(0x0980, 0x09FF, "bn", frozenset({"bn"})), # Bengali
|
| 63 |
+
(0x0A00, 0x0A7F, "pa", frozenset({"pa"})), # Gurmukhi
|
| 64 |
+
(0x0A80, 0x0AFF, "gu", frozenset({"gu"})), # Gujarati
|
| 65 |
+
(0x0B00, 0x0B7F, "or", frozenset()), # Odia
|
| 66 |
+
(0x0C00, 0x0C7F, "te", frozenset({"te"})), # Telugu
|
| 67 |
+
(0x0C80, 0x0CFF, "kn", frozenset({"kn"})), # Kannada
|
| 68 |
+
(0x0D00, 0x0D7F, "ml", frozenset({"ml"})), # Malayalam
|
| 69 |
+
(0x0E00, 0x0E7F, "th", frozenset({"th"})), # Thai
|
| 70 |
+
(0x0600, 0x06FF, "ar", frozenset({"ar", "fa", "ur"})), # Arabic
|
| 71 |
+
(0x0590, 0x05FF, "he", frozenset({"he"})), # Hebrew
|
| 72 |
+
(0x0370, 0x03FF, "el", frozenset({"el"})), # Greek
|
| 73 |
+
(0x0400, 0x04FF, "ru", frozenset({"ru", "uk", "bg", "mk"})), # Cyrillic
|
| 74 |
+
(0x1200, 0x137F, "am", frozenset()), # Ethiopic
|
| 75 |
+
(0x1780, 0x17FF, "km", frozenset()), # Khmer
|
| 76 |
+
(0x1000, 0x109F, "my", frozenset()), # Myanmar
|
| 77 |
+
(0x3040, 0x30FF, "ja", frozenset({"ja"})), # Kana
|
| 78 |
+
(0xAC00, 0xD7AF, "ko", frozenset({"ko"})), # Hangul
|
| 79 |
+
(0x4E00, 0x9FFF, "zh-cn", frozenset({"zh-cn", "zh-tw", "ja"})), # CJK
|
| 80 |
+
]
|
| 81 |
+
|
| 82 |
+
# A script must cover this share of the letters before we trust it.
|
| 83 |
+
_SCRIPT_MIN_SHARE = 0.25
|
| 84 |
+
|
| 85 |
+
|
| 86 |
+
def detect_script(text: str) -> tuple[str, float, frozenset[str]] | None:
|
| 87 |
+
"""Dominant non-Latin script as (default_lang, share, sibling_langs)."""
|
| 88 |
+
letters = [c for c in text if c.isalpha()]
|
| 89 |
+
if not letters:
|
| 90 |
+
return None
|
| 91 |
+
|
| 92 |
+
counts: dict[int, int] = {}
|
| 93 |
+
for ch in letters:
|
| 94 |
+
cp = ord(ch)
|
| 95 |
+
for idx, (lo, hi, _, _) in enumerate(_SCRIPTS):
|
| 96 |
+
if lo <= cp <= hi:
|
| 97 |
+
counts[idx] = counts.get(idx, 0) + 1
|
| 98 |
+
break
|
| 99 |
+
|
| 100 |
+
if not counts:
|
| 101 |
+
return None # pure Latin (or unmapped) -- leave it to langdetect
|
| 102 |
+
idx, hits = max(counts.items(), key=lambda kv: kv[1])
|
| 103 |
+
share = hits / len(letters)
|
| 104 |
+
if share < _SCRIPT_MIN_SHARE:
|
| 105 |
+
return None
|
| 106 |
+
_, _, default_lang, siblings = _SCRIPTS[idx]
|
| 107 |
+
return default_lang, share, siblings
|
| 108 |
+
|
| 109 |
+
|
| 110 |
+
@dataclass
|
| 111 |
+
class TranslationResult:
|
| 112 |
+
original_text: str
|
| 113 |
+
text: str # English text handed to the classifier
|
| 114 |
+
source_lang: str # ISO code, or "unknown"
|
| 115 |
+
source_lang_name: str
|
| 116 |
+
confidence: float # detector confidence, 0..1
|
| 117 |
+
was_translated: bool
|
| 118 |
+
note: str = "" # populated when something degraded
|
| 119 |
+
|
| 120 |
+
def dict(self) -> dict[str, Any]:
|
| 121 |
+
return asdict(self)
|
| 122 |
+
|
| 123 |
+
|
| 124 |
+
def detect_language(text: str) -> tuple[str, float]:
|
| 125 |
+
"""Return (iso_code, confidence). ("unknown", 0.0) when undetectable."""
|
| 126 |
+
text = (text or "").strip()
|
| 127 |
+
if not text:
|
| 128 |
+
return "unknown", 0.0
|
| 129 |
+
|
| 130 |
+
lang, conf = "unknown", 0.0
|
| 131 |
+
if _LANGDETECT_OK:
|
| 132 |
+
try:
|
| 133 |
+
best = detect_langs(text)[0]
|
| 134 |
+
lang, conf = best.lang, float(best.prob)
|
| 135 |
+
except (LangDetectException, IndexError):
|
| 136 |
+
pass
|
| 137 |
+
|
| 138 |
+
script = detect_script(text)
|
| 139 |
+
if script is None:
|
| 140 |
+
return lang, conf # Latin script -- langdetect is the only signal
|
| 141 |
+
|
| 142 |
+
default_lang, share, siblings = script
|
| 143 |
+
if lang in siblings:
|
| 144 |
+
# langdetect agrees on the script and is more specific (mr vs hi, ur vs ar).
|
| 145 |
+
return lang, conf
|
| 146 |
+
# langdetect is wrong about the script, or has no profile for it (Sinhala).
|
| 147 |
+
return default_lang, round(share, 4)
|
| 148 |
+
|
| 149 |
+
|
| 150 |
+
def to_english(text: str) -> TranslationResult:
|
| 151 |
+
"""Detect the language of `text` and translate it to English when needed."""
|
| 152 |
+
text = (text or "").strip()
|
| 153 |
+
lang, conf = detect_language(text)
|
| 154 |
+
name = language_name(lang)
|
| 155 |
+
|
| 156 |
+
if not text:
|
| 157 |
+
return TranslationResult(text, "", lang, name, conf, False, "Empty input.")
|
| 158 |
+
|
| 159 |
+
# Confidently English already -- skip the network round trip.
|
| 160 |
+
if lang == "en" and conf >= 0.90:
|
| 161 |
+
return TranslationResult(text, text, "en", "English", conf, False)
|
| 162 |
+
|
| 163 |
+
if not TRANSLATE_ENABLED:
|
| 164 |
+
return TranslationResult(text, text, lang, name, conf, False,
|
| 165 |
+
"Translation disabled by configuration.")
|
| 166 |
+
|
| 167 |
+
if not _TRANSLATOR_OK:
|
| 168 |
+
return TranslationResult(text, text, lang, name, conf, False,
|
| 169 |
+
"deep-translator not installed; classified as-is.")
|
| 170 |
+
|
| 171 |
+
payload = text[:MAX_CHARS]
|
| 172 |
+
try:
|
| 173 |
+
# source="auto" lets Google do its own detection, which beats langdetect
|
| 174 |
+
# on short or romanised text.
|
| 175 |
+
translated = GoogleTranslator(source="auto", target="en").translate(payload)
|
| 176 |
+
except Exception as exc: # network down, rate limited, etc.
|
| 177 |
+
log.warning("Translation failed (%s); using original text.", exc)
|
| 178 |
+
return TranslationResult(text, text, lang, name, conf, False,
|
| 179 |
+
f"Translation unavailable ({type(exc).__name__}); classified in original language.")
|
| 180 |
+
|
| 181 |
+
if not translated or not translated.strip():
|
| 182 |
+
return TranslationResult(text, text, lang, name, conf, False,
|
| 183 |
+
"Translator returned nothing; classified as-is.")
|
| 184 |
+
|
| 185 |
+
translated = translated.strip()
|
| 186 |
+
truncated = " (input truncated for translation)" if len(text) > MAX_CHARS else ""
|
| 187 |
+
changed = translated.lower() != text.lower()
|
| 188 |
+
|
| 189 |
+
if not changed:
|
| 190 |
+
return TranslationResult(text, translated, lang if lang != "unknown" else "en",
|
| 191 |
+
language_name(lang if lang != "unknown" else "en"),
|
| 192 |
+
conf, False, "Text was already English." + truncated)
|
| 193 |
+
|
| 194 |
+
return TranslationResult(text, translated, lang, name, conf, True, truncated.strip())
|
data/complaints.db
ADDED
|
Binary file (24.6 kB). View file
|
|
|
requirements.txt
CHANGED
|
@@ -1,6 +1,21 @@
|
|
| 1 |
-
|
| 2 |
-
|
| 3 |
-
uvicorn
|
| 4 |
-
|
| 5 |
-
|
| 6 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# --- web ---
|
| 2 |
+
fastapi>=0.115
|
| 3 |
+
uvicorn[standard]>=0.30
|
| 4 |
+
jinja2>=3.1
|
| 5 |
+
python-multipart>=0.0.9
|
| 6 |
+
pydantic>=2.7
|
| 7 |
+
|
| 8 |
+
# --- language detection + translation ---
|
| 9 |
+
langdetect>=1.0.9
|
| 10 |
+
deep-translator>=1.11.4
|
| 11 |
+
|
| 12 |
+
# --- zero-shot text classification (small NLI model) ---
|
| 13 |
+
# transformers 4.x and 5.x both work.
|
| 14 |
+
transformers>=4.40
|
| 15 |
+
torch>=2.2
|
| 16 |
+
|
| 17 |
+
# Tokeniser backend for DeBERTa-v3 style models. Not needed by the default
|
| 18 |
+
# MODEL_NAME, but required if you switch to one -- without these the pipeline
|
| 19 |
+
# cannot build its tokenizer and the app silently drops to the keyword fallback.
|
| 20 |
+
sentencepiece>=0.2
|
| 21 |
+
protobuf>=4.25
|
run.bat
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
@echo off
|
| 2 |
+
REM Start the complaint classifier on http://127.0.0.1:8000
|
| 3 |
+
python -m uvicorn app.main:app --reload --host 127.0.0.1 --port 8000
|
run.sh
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env bash
|
| 2 |
+
# Start the complaint classifier on http://127.0.0.1:8000
|
| 3 |
+
python -m uvicorn app.main:app --reload --host 127.0.0.1 --port 8000
|
scripts/bench.py
ADDED
|
@@ -0,0 +1,124 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Benchmark the zero-shot classifier against a small labelled set.
|
| 2 |
+
|
| 3 |
+
Run from the project root:
|
| 4 |
+
|
| 5 |
+
python scripts/bench.py # current MODEL_NAME
|
| 6 |
+
python scripts/bench.py MoritzLaurer/deberta-v3-xsmall-zeroshot-v1.1-all-33
|
| 7 |
+
|
| 8 |
+
Prints accuracy for a sweep of LEXICAL_WEIGHT values and hypothesis templates,
|
| 9 |
+
so config changes are made on evidence rather than vibes. The cases are the
|
| 10 |
+
English text as it would arrive *after* translation, since that is what the
|
| 11 |
+
model actually sees.
|
| 12 |
+
|
| 13 |
+
The weight sweep runs the model ONCE per case and re-blends the cached scores:
|
| 14 |
+
LEXICAL_WEIGHT only affects the blend, never the model, so re-running inference
|
| 15 |
+
per weight would just burn time for identical numbers.
|
| 16 |
+
"""
|
| 17 |
+
from __future__ import annotations
|
| 18 |
+
|
| 19 |
+
import os
|
| 20 |
+
import sys
|
| 21 |
+
import time
|
| 22 |
+
from pathlib import Path
|
| 23 |
+
|
| 24 |
+
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
| 25 |
+
os.environ.setdefault("HF_HUB_DISABLE_XET", "1")
|
| 26 |
+
|
| 27 |
+
from app import classifier # noqa: E402
|
| 28 |
+
from app.config import DEFAULT_LABELS # noqa: E402
|
| 29 |
+
|
| 30 |
+
LABELS = [{"name": n, "description": d} for n, d in DEFAULT_LABELS]
|
| 31 |
+
|
| 32 |
+
CASES: list[tuple[str, str]] = [
|
| 33 |
+
("There is no water in our village for three days", "Water Supply"),
|
| 34 |
+
("The water coming from the tap is muddy and smells bad", "Water Supply"),
|
| 35 |
+
("A main pipe has burst and water is flooding the street", "Water Supply"),
|
| 36 |
+
("Our area has been without electricity for three days", "Electricity"),
|
| 37 |
+
("The transformer near our house is damaged and sparking", "Electricity"),
|
| 38 |
+
("Voltage keeps fluctuating and our appliances are getting damaged", "Electricity"),
|
| 39 |
+
("There is a huge pothole on the main road", "Road & Transport"),
|
| 40 |
+
("The traffic signal at the junction is not working", "Road & Transport"),
|
| 41 |
+
("The pavement is broken and people cannot walk safely", "Road & Transport"),
|
| 42 |
+
("Garbage has not been collected in our locality for a week", "Waste & Sanitation"),
|
| 43 |
+
("The drain is blocked and sewage is overflowing on the street", "Waste & Sanitation"),
|
| 44 |
+
("The public bin is overflowing and smells terrible", "Waste & Sanitation"),
|
| 45 |
+
("Mosquitoes are breeding in the stagnant water near the school", "Public Health"),
|
| 46 |
+
("Stray dogs are attacking children in our neighbourhood", "Public Health"),
|
| 47 |
+
("Construction noise starts at 5am every day and nobody can sleep", "Noise & Pollution"),
|
| 48 |
+
("A factory is releasing thick black smoke into the air", "Noise & Pollution"),
|
| 49 |
+
("Someone is putting up an illegal building without permission", "Building & Property"),
|
| 50 |
+
("The old building next door is cracked and looks unsafe", "Building & Property"),
|
| 51 |
+
]
|
| 52 |
+
|
| 53 |
+
CANDIDATES = [l["name"] for l in LABELS]
|
| 54 |
+
|
| 55 |
+
|
| 56 |
+
def model_scores(template: str) -> tuple[list[list[tuple[str, float]]], int]:
|
| 57 |
+
"""Raw model scores for every case under one hypothesis template."""
|
| 58 |
+
out, latencies = [], []
|
| 59 |
+
for text, _ in CASES:
|
| 60 |
+
started = time.perf_counter()
|
| 61 |
+
r = classifier._pipe(
|
| 62 |
+
text, candidate_labels=CANDIDATES,
|
| 63 |
+
hypothesis_template=template, multi_label=False,
|
| 64 |
+
)
|
| 65 |
+
latencies.append((time.perf_counter() - started) * 1000)
|
| 66 |
+
out.append(list(zip(r["labels"], [float(s) for s in r["scores"]])))
|
| 67 |
+
return out, int(sum(latencies) / len(latencies))
|
| 68 |
+
|
| 69 |
+
|
| 70 |
+
def score(cached, weight, avg_ms, title) -> tuple[int, list[str]]:
|
| 71 |
+
hits, misses = 0, []
|
| 72 |
+
for (text, expected), raw in zip(CASES, cached):
|
| 73 |
+
blended = classifier._blend(list(raw), text, LABELS, weight)
|
| 74 |
+
blended.sort(key=lambda p: p[1], reverse=True)
|
| 75 |
+
got, conf = blended[0]
|
| 76 |
+
if got == expected:
|
| 77 |
+
hits += 1
|
| 78 |
+
else:
|
| 79 |
+
misses.append(f" {expected:<20} -> {got:<20} ({conf:.2f}) {text[:46]}")
|
| 80 |
+
print(f" {title:<44} {hits:>2}/{len(CASES)} ({100 * hits / len(CASES):3.0f}%) ~{avg_ms}ms")
|
| 81 |
+
return hits, misses
|
| 82 |
+
|
| 83 |
+
|
| 84 |
+
def main() -> int:
|
| 85 |
+
if len(sys.argv) > 1:
|
| 86 |
+
classifier.MODEL_NAME = sys.argv[1]
|
| 87 |
+
classifier._state["model"] = sys.argv[1]
|
| 88 |
+
|
| 89 |
+
started = time.time()
|
| 90 |
+
state = classifier.load_model(force=True)
|
| 91 |
+
if state["status"] != "ready":
|
| 92 |
+
print("MODEL FAILED TO LOAD:\n", state["error"][:500])
|
| 93 |
+
return 1
|
| 94 |
+
print(f"\nmodel: {classifier._state['model']} (loaded in {time.time() - started:.0f}s)\n")
|
| 95 |
+
|
| 96 |
+
default_template = "This complaint is about {}."
|
| 97 |
+
cached, avg_ms = model_scores(default_template)
|
| 98 |
+
|
| 99 |
+
print("LEXICAL_WEIGHT sweep (model run once, blend re-applied)")
|
| 100 |
+
best = (-1, 0.0, [])
|
| 101 |
+
for weight in (0.0, 0.15, 0.25, 0.3, 0.4, 0.5, 0.6):
|
| 102 |
+
hits, misses = score(cached, weight, avg_ms, f"lexical_weight = {weight}")
|
| 103 |
+
if hits > best[0]:
|
| 104 |
+
best = (hits, weight, misses)
|
| 105 |
+
|
| 106 |
+
print(f"\n best: lexical_weight={best[1]} -> {best[0]}/{len(CASES)}")
|
| 107 |
+
if best[2]:
|
| 108 |
+
print(" remaining misses:")
|
| 109 |
+
print("\n".join(best[2]))
|
| 110 |
+
|
| 111 |
+
print("\nhypothesis template sweep")
|
| 112 |
+
score(cached, best[1], avg_ms, f"{default_template!r}")
|
| 113 |
+
for template in (
|
| 114 |
+
"This is a complaint about {}.",
|
| 115 |
+
"The problem is {}.",
|
| 116 |
+
"This text is about {}.",
|
| 117 |
+
):
|
| 118 |
+
c, ms = model_scores(template)
|
| 119 |
+
score(c, best[1], ms, f"{template!r}")
|
| 120 |
+
return 0
|
| 121 |
+
|
| 122 |
+
|
| 123 |
+
if __name__ == "__main__":
|
| 124 |
+
raise SystemExit(main())
|