| ---
|
| title: Complaint Classifier
|
| emoji: 📝
|
| colorFrom: blue
|
| colorTo: indigo
|
| sdk: docker
|
| app_port: 7860
|
| pinned: false
|
| short_description: Multilingual complaint classifier with runtime labels
|
| ---
|
|
|
| # Complaint Classifier
|
|
|
| A FastAPI app with a web UI that takes a public complaint **in any language**,
|
| detects the language, translates it to English with Google Translate, and
|
| classifies it into a problem type (Water Supply, Electricity, Road, Waste, …)
|
| using a **small pretrained zero-shot model**.
|
|
|
| The labels are **not baked into the model**. You edit them in the UI and the very
|
| next classification uses them — no retraining, no restart.
|
|
|
| ---
|
|
|
| ## Why zero-shot
|
|
|
| A normal fine-tuned text classifier has its labels frozen at training time; adding
|
| "Street Lighting" would mean collecting data and retraining.
|
|
|
| This app uses a **zero-shot NLI classifier** instead. The candidate labels are an
|
| *input* to every inference call. The model checks "does this complaint entail the
|
| hypothesis *This complaint is about broken street lights*?" for each label and
|
| ranks them. That is what makes runtime-editable labels possible.
|
|
|
| Default model: [`typeform/distilbert-base-uncased-mnli`](https://huggingface.co/typeform/distilbert-base-uncased-mnli)
|
| — ~250 MB, CPU-friendly, ~100–400 ms per complaint on a laptop.
|
|
|
| ---
|
|
|
| ## Pipeline
|
|
|
| ```
|
| complaint (any language)
|
| │
|
| ▼
|
| langdetect → ISO code + confidence (offline, instant)
|
| │
|
| ▼
|
| deep-translator → Google Translate → English
|
| (skipped when already confidently English)
|
| │
|
| ▼
|
| zero-shot NLI model → score every ACTIVE label from the database
|
| │
|
| ▼
|
| top label + confidence + full score breakdown → saved to SQLite
|
| ```
|
|
|
| ---
|
|
|
| ## Install & run
|
|
|
| ```bash
|
| pip install -r requirements.txt
|
|
|
| # Windows
|
| run.bat
|
| # macOS / Linux
|
| ./run.sh
|
| # or directly
|
| python -m uvicorn app.main:app --reload
|
| ```
|
|
|
| Then open **http://127.0.0.1:8000**.
|
|
|
| The first start downloads the model (~250 MB) in a background thread, so the UI is
|
| usable immediately — the dot in the top-right shows `loading model…` → `model ready`.
|
|
|
| ---
|
|
|
| ## Pages
|
|
|
| | Page | What it does |
|
| | --- | --- |
|
| | `/` | Submit a complaint, see the predicted category, detected language, English translation and a score bar for **every** label. |
|
| | `/labels` | Add / rename / describe / activate / delete labels. Changes are live instantly. |
|
| | `/history` | Every classified complaint with per-category counts, plus a text filter. |
|
| | `/docs` | Auto-generated OpenAPI docs (Swagger UI). |
|
|
|
| ---
|
|
|
| ## API
|
|
|
| ### `POST /api/classify`
|
|
|
| ```json
|
| {
|
| "text": "අපේ ගමේ දින තුනක් තිස්සේ වතුර නැහැ",
|
| "translate": true,
|
| "multi_label": false,
|
| "threshold": 0.35,
|
| "save": true,
|
| "labels": null
|
| }
|
| ```
|
|
|
| Response:
|
|
|
| ```json
|
| {
|
| "id": 12,
|
| "predicted_label": "Water Supply",
|
| "confidence": 0.8421,
|
| "confident": true,
|
| "threshold": 0.35,
|
| "scores": [
|
| {"label": "Water Supply", "score": 0.8421},
|
| {"label": "Public Health", "score": 0.0612}
|
| ],
|
| "translation": {
|
| "source_lang": "si",
|
| "source_lang_name": "Sinhala",
|
| "detection_confidence": 0.9999,
|
| "was_translated": true,
|
| "original_text": "අපේ ගමේ දින තුනක් තිස්සේ වතුර නැහැ",
|
| "translated_text": "There is no water in our village for three days",
|
| "note": ""
|
| },
|
| "engine": "zero-shot:typeform/distilbert-base-uncased-mnli",
|
| "multi_label": false,
|
| "took_ms": 187
|
| }
|
| ```
|
|
|
| Pass `"labels": ["Water", "Electricity"]` to classify against an ad-hoc set for one
|
| request only, without touching the saved labels.
|
|
|
| ### Labels
|
|
|
| | Method | Path | Purpose |
|
| | --- | --- | --- |
|
| | `GET` | `/api/labels?active_only=true` | List labels |
|
| | `POST` | `/api/labels` | Add `{name, description, active}` |
|
| | `PATCH` | `/api/labels/{id}` | Update any subset of fields |
|
| | `DELETE` | `/api/labels/{id}` | Remove a label |
|
|
|
| ### Other
|
|
|
| `GET /api/history` · `GET /api/stats` · `DELETE /api/history` ·
|
| `GET /api/health` · `POST /api/model/reload`
|
|
|
| ---
|
|
|
| ## Configuration
|
|
|
| All optional, via environment variables:
|
|
|
| | Variable | Default | Meaning |
|
| | --- | --- | --- |
|
| | `MODEL_NAME` | `typeform/distilbert-base-uncased-mnli` | Any HF zero-shot/NLI model |
|
| | `HYPOTHESIS_TEMPLATE` | `This complaint is about {}.` | Steers the NLI model |
|
| | `LEXICAL_WEIGHT` | `0.6` | Weight of the description keyword prior (0 = pure model) |
|
| | `CONFIDENCE_THRESHOLD` | `0.35` | Below this the result is flagged low-confidence |
|
| | `PRELOAD_MODEL` | `1` | Load at startup instead of first request |
|
| | `TRANSLATE_ENABLED` | `1` | Set `0` to skip Google Translate entirely |
|
| | `DATA_DIR` | `./data` | Where `complaints.db` lives |
|
| | `DISABLE_XET` | `0` | Set `1` if model downloads hang at 0 bytes (see below) |
|
| | `MODEL_OFFLINE` | `0` | Set `1` to use only the local HF cache, never the network |
|
| | `MODEL_DOWNLOAD_TIMEOUT` | `30` | Seconds a download chunk may stall before failing |
|
|
|
| Alternatives for `MODEL_NAME` — **benchmark before switching**:
|
|
|
| - `MoritzLaurer/deberta-v3-xsmall-zeroshot-v1.1-all-33` (~146 MB). Far more
|
| confident (0.80 vs 0.24 on the same complaint) but measured at **~41 s per
|
| inference** on the development CPU against distilbert's **~0.4 s**. That is
|
| 100× slower and unusable for real-time intake, so it is not the default
|
| despite the better scores. Needs `sentencepiece` + `protobuf`.
|
| - `valhalla/distilbart-mnli-12-1` (~890 MB)
|
| - `facebook/bart-large-mnli` (~1.6 GB, the reference model)
|
|
|
| Run `python scripts/bench.py <model>` to check both accuracy and latency on your
|
| own hardware — the latency gap above may be specific to this CPU/torch build.
|
|
|
| ---
|
|
|
| ## Getting good accuracy
|
|
|
| **Keep label names short and plain; put the vocabulary in the description.**
|
|
|
| The label *name* is the model's hypothesis ("This complaint is about **Electricity**.")
|
| — NLI models want a clean noun phrase there, so `Electricity` beats
|
| `Electricity / power / outages etc`.
|
|
|
| The *description* is a separate keyword prior blended into the score
|
| (`LEXICAL_WEIGHT`, default `0.6`). This is where the domain vocabulary belongs:
|
|
|
| > `Electricity` → *"power cut, outage, voltage fluctuation, broken street light,
|
| > damaged electric pole or hanging wire"*
|
|
|
| This split matters. Measured on the bundled 18-case benchmark:
|
|
|
| | candidates | accuracy |
|
| | --- | --- |
|
| | descriptions as the hypothesis (the obvious-looking choice) | 0/18 |
|
| | label names, no blend (`LEXICAL_WEIGHT=0`) | 10/18 |
|
| | label names + description prior at `0.6` | **16/18 (89%)** |
|
|
|
| Reproduce with `python scripts/bench.py`.
|
|
|
| Other levers: raise `CONFIDENCE_THRESHOLD` if you would rather route ambiguous
|
| complaints to a human than mislabel them; keep an `Other` label active so
|
| off-topic complaints have somewhere to land; use `multi_label` when a single
|
| complaint can legitimately belong to two categories.
|
|
|
| ---
|
|
|
| ## Troubleshooting
|
|
|
| **The model download hangs at 0 bytes.** Some corporate and ISP networks block
|
| Hugging Face's Xet storage backend while ordinary HTTPS to `huggingface.co` still
|
| works, so the download never starts and never errors. Force the classic CDN path:
|
|
|
| ```bash
|
| # Windows
|
| set DISABLE_XET=1
|
| # macOS / Linux
|
| export DISABLE_XET=1
|
| ```
|
|
|
| This was hit on the development machine — 0 bytes after 15 minutes with Xet, normal
|
| throughput immediately after disabling it.
|
|
|
| **The status dot says `keyword fallback`.** The model failed to load. `GET /api/health`
|
| returns the full error under `model.error`. The app keeps working on the keyword
|
| scorer until you fix it and call `POST /api/model/reload`.
|
|
|
| ---
|
|
|
| ## Behaviour when things are offline
|
|
|
| - **No network for Google Translate** → the complaint is classified in its original
|
| language and the response carries a `note` explaining why. Nothing is lost.
|
| - **Model can't be downloaded** → a keyword-overlap fallback scorer keeps the app
|
| working, and `engine` reports `keyword-fallback` so you always know which path
|
| produced a result.
|
|
|
| ---
|
|
|
| ## Deploying to Hugging Face Spaces
|
|
|
| The repo ships a `Dockerfile` for a Space with `sdk: docker` (set in the README
|
| frontmatter above, together with `app_port: 7860`).
|
|
|
| ```bash
|
| git clone https://huggingface.co/spaces/Arafath10/textclsy
|
| cd textclsy
|
| # copy this project in, then
|
| git add -A && git commit -m "Complaint classifier" && git push
|
| ```
|
|
|
| Pushing needs an HF token with **write** access:
|
|
|
| ```bash
|
| huggingface-cli login # or:
|
| git remote set-url origin https://<user>:<hf_token>@huggingface.co/spaces/Arafath10/textclsy
|
| ```
|
|
|
| Three things differ from the stock HF Docker template, all deliberate:
|
|
|
| 1. **`python:3.11-slim`, not `python:3.9`.** FastAPI and Pydantic evaluate
|
| annotations such as `str | None` at runtime; that syntax is a `TypeError` on 3.9.
|
| 2. **CPU-only torch.** The default PyPI wheel drags in ~2.5 GB of CUDA libraries a
|
| CPU Space can never use, so torch is installed from the PyTorch CPU index first.
|
| 3. **The model is baked into the image** at build time, so a cold start serves the
|
| first request immediately instead of downloading ~250 MB.
|
|
|
| Note that a Space's filesystem is ephemeral — `data/complaints.db` resets when the
|
| Space restarts or rebuilds. Attach a persistent volume, or point `DATA_DIR` at one,
|
| if the complaint history has to survive.
|
|
|
| ---
|
|
|
| ## Layout
|
|
|
| ```
|
| app/
|
| main.py FastAPI routes (pages + JSON API)
|
| classifier.py zero-shot pipeline, keyword fallback, model state
|
| translator.py langdetect + Google Translate, degrades gracefully
|
| store.py SQLite: labels + complaint history
|
| config.py env-var configuration and seed labels
|
| schemas.py Pydantic request/response models
|
| templates/ Jinja2 pages
|
| static/ CSS + vanilla JS (no build step)
|
| data/
|
| complaints.db created on first run
|
| ```
|
|
|