Arafath10 commited on
Commit
dad80ae
·
verified ·
1 Parent(s): 43fcc56

Upload 33 files

Browse files
.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
- # Use the official Python 3.9 image
2
- FROM python:3.9
 
 
 
 
3
 
4
- # Set the working directory to /code
5
  WORKDIR /code
6
 
7
- # Copy the current directory contents into the container at /code
8
- COPY ./requirements.txt /code/requirements.txt
 
 
9
 
10
- # Install requirements.txt
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
- PATH=/home/user/.local/bin:$PATH
 
 
 
 
 
 
 
20
 
21
- # Set the working directory to the user's home directory
22
  WORKDIR $HOME/app
23
 
24
- # Copy the current directory contents into the container at $HOME/app setting the owner to the user
 
 
 
 
 
 
 
25
  COPY --chown=user . $HOME/app
26
 
27
- CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "7860"]
 
 
 
 
 
 
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: curfox_kpi_api
3
- emoji: 🐠
4
- colorFrom: red
5
- colorTo: red
6
- sdk: docker
7
- pinned: false
8
- ---
9
-
10
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
+ ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" }[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'>&#128221;</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&nbsp;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="අපේ ගමේ දින තුනක් තිස්සේ වතුර නැහැ&#10;Hamare area me 3 din se bijli nahi hai&#10;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 &amp; 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
- fastapi
2
- requests
3
- uvicorn
4
- pydantic
5
- pandas
6
- aiohttp
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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())