Spaces:
Sleeping
Sleeping
Upload 28 files
Browse files- .dockerignore +5 -0
- .gitignore +5 -0
- APPLY_V3.md +58 -0
- Dockerfile +38 -24
- README.md +104 -3
- db.py +218 -158
- docs/class_diagram.md +90 -0
- evaluate.py +119 -0
- gitattributes +35 -0
- ingest.py +113 -0
- main.py +226 -92
- pipeline.py +91 -0
- prepare_dataset.py +130 -0
- processors/__init__.py +1 -0
- processors/anonymizer.py +162 -0
- processors/model_registry.py +72 -0
- processors/ner.py +85 -0
- processors/sentiment.py +80 -0
- processors/topics.py +99 -0
- requirements-dev.txt +5 -0
- requirements.txt +8 -4
- sample_chats.csv +11 -0
- static/app.js +111 -17
- static/index.html +49 -13
- static/records.html +62 -0
- static/records.js +114 -0
- static/styles.css +108 -0
- upload.py +5 -5
.dockerignore
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
__pycache__/
|
| 2 |
+
*.pyc
|
| 3 |
+
*.db
|
| 4 |
+
.git/
|
| 5 |
+
.venv/
|
.gitignore
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
__pycache__/
|
| 2 |
+
*.pyc
|
| 3 |
+
*.db
|
| 4 |
+
.cache/
|
| 5 |
+
.venv/
|
APPLY_V3.md
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Jak aplikovat V3 do repozitáře `sentiment_analysis_api`
|
| 2 |
+
|
| 3 |
+
Tento balíček je kompletní snapshot projektu ve stavu V3 - staví na V2 a
|
| 4 |
+
přidává dynamický výběr sentiment modelu z Hugging Face Hubu a XML export
|
| 5 |
+
uložených záznamů. Aplikuje se **po** V2 (tj. repozitář by měl už mít commit
|
| 6 |
+
a tag `v2.0`).
|
| 7 |
+
|
| 8 |
+
## Co je nové oproti V2
|
| 9 |
+
|
| 10 |
+
- `processors/model_registry.py` - loader/cache pro libovolný HF
|
| 11 |
+
`text-classification` model (max 3 modely v paměti, `trust_remote_code`
|
| 12 |
+
nikdy zapnuto).
|
| 13 |
+
- `processors/sentiment.py`, `pipeline.py`, `main.py` - `sentiment_model`
|
| 14 |
+
parametr v `/analyze`, `/predict`, `/ingest` (form pole).
|
| 15 |
+
- Nový endpoint `GET /models` (výchozí/doporučené/cachované modely).
|
| 16 |
+
- Nový endpoint `GET /records/export.xml` a `db.export_xml()` (stdlib
|
| 17 |
+
`xml.etree.ElementTree`, žádná nová závislost).
|
| 18 |
+
- `static/index.html` + `app.js` - pole pro zadání modelu (s návrhy z
|
| 19 |
+
`/models`).
|
| 20 |
+
- `static/records.html` - tlačítko „Export XML".
|
| 21 |
+
- Aktualizovaný `README.md` a `docs/class_diagram.md`.
|
| 22 |
+
|
| 23 |
+
## Postup
|
| 24 |
+
|
| 25 |
+
1. Zkopíruj **veškerý obsah** této složky do kořene repozitáře (přepiš
|
| 26 |
+
`main.py`, `db.py`, `pipeline.py`, `README.md`, `docs/class_diagram.md`,
|
| 27 |
+
`static/*`; přidej nový `processors/model_registry.py`).
|
| 28 |
+
2. `git status` a commit:
|
| 29 |
+
|
| 30 |
+
```bash
|
| 31 |
+
git add -A
|
| 32 |
+
git commit -m "V3: dynamic Hugging Face model selection for sentiment analysis + XML export of stored records"
|
| 33 |
+
git tag -a v3.0 -m "V3: dynamic HF model selection + XML export"
|
| 34 |
+
```
|
| 35 |
+
|
| 36 |
+
3. Push, až budeš chtít:
|
| 37 |
+
|
| 38 |
+
```bash
|
| 39 |
+
git push origin main
|
| 40 |
+
git push origin v3.0
|
| 41 |
+
```
|
| 42 |
+
|
| 43 |
+
## Lokální ověření před commitem (doporučeno)
|
| 44 |
+
|
| 45 |
+
```bash
|
| 46 |
+
pip install -r requirements.txt
|
| 47 |
+
python -m spacy download en_core_web_lg
|
| 48 |
+
uvicorn main:app --reload --port 8000
|
| 49 |
+
```
|
| 50 |
+
|
| 51 |
+
Pak vyzkoušej:
|
| 52 |
+
- `GET /models` - měl by vrátit výchozí + doporučené modely
|
| 53 |
+
- `POST /analyze` s `"sentiment_model": "distilbert-base-uncased-finetuned-sst-2-english"`
|
| 54 |
+
(první request tento model stáhne, může chvíli trvat)
|
| 55 |
+
- `POST /analyze` s neplatným `sentiment_model` (např. `"neexistujici/model"`)
|
| 56 |
+
- očekávej HTTP 400 se srozumitelnou chybou, ne pád serveru
|
| 57 |
+
- `GET /records/export.xml` - stažení/zobrazení platného XML se záznamy
|
| 58 |
+
- na stránce `/static/records.html` tlačítko „Export XML"
|
Dockerfile
CHANGED
|
@@ -1,24 +1,38 @@
|
|
| 1 |
-
FROM python:3.11-slim
|
| 2 |
-
|
| 3 |
-
ENV PYTHONUNBUFFERED=1 \
|
| 4 |
-
PYTHONDONTWRITEBYTECODE=1 \
|
| 5 |
-
PIP_NO_CACHE_DIR=1 \
|
| 6 |
-
|
| 7 |
-
|
| 8 |
-
|
| 9 |
-
|
| 10 |
-
|
| 11 |
-
|
| 12 |
-
|
| 13 |
-
|
| 14 |
-
|
| 15 |
-
|
| 16 |
-
|
| 17 |
-
|
| 18 |
-
|
| 19 |
-
|
| 20 |
-
|
| 21 |
-
|
| 22 |
-
|
| 23 |
-
|
| 24 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
FROM python:3.11-slim
|
| 2 |
+
|
| 3 |
+
ENV PYTHONUNBUFFERED=1 \
|
| 4 |
+
PYTHONDONTWRITEBYTECODE=1 \
|
| 5 |
+
PIP_NO_CACHE_DIR=1 \
|
| 6 |
+
HF_HOME=/app/.cache \
|
| 7 |
+
TRANSFORMERS_CACHE=/app/.cache \
|
| 8 |
+
CONV_DB_PATH=/tmp/conversation_logs.db
|
| 9 |
+
|
| 10 |
+
WORKDIR /app
|
| 11 |
+
|
| 12 |
+
# System dependencies
|
| 13 |
+
RUN apt-get update && apt-get install -y --no-install-recommends \
|
| 14 |
+
git curl build-essential \
|
| 15 |
+
&& rm -rf /var/lib/apt/lists/*
|
| 16 |
+
|
| 17 |
+
# Python dependencies
|
| 18 |
+
COPY requirements.txt ./
|
| 19 |
+
RUN pip install --upgrade pip && pip install -r requirements.txt
|
| 20 |
+
|
| 21 |
+
# spaCy model required by Presidio for name/location detection
|
| 22 |
+
RUN python -m spacy download en_core_web_lg
|
| 23 |
+
|
| 24 |
+
# Pre-download the Hugging Face models at build time so the first request is fast.
|
| 25 |
+
# (Comment out to download lazily on first use instead.)
|
| 26 |
+
RUN python - <<'PY'
|
| 27 |
+
from transformers import pipeline
|
| 28 |
+
pipeline("token-classification", model="dslim/bert-base-NER", aggregation_strategy="simple")
|
| 29 |
+
pipeline("zero-shot-classification", model="facebook/bart-large-mnli")
|
| 30 |
+
print("Models cached.")
|
| 31 |
+
PY
|
| 32 |
+
|
| 33 |
+
# Application code
|
| 34 |
+
COPY . .
|
| 35 |
+
|
| 36 |
+
EXPOSE 8000
|
| 37 |
+
|
| 38 |
+
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"]
|
README.md
CHANGED
|
@@ -1,8 +1,109 @@
|
|
| 1 |
---
|
| 2 |
-
title:
|
| 3 |
sdk: docker
|
| 4 |
-
emoji:
|
| 5 |
colorFrom: indigo
|
| 6 |
colorTo: purple
|
| 7 |
app_port: 8000
|
| 8 |
-
---
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
---
|
| 2 |
+
title: Conversation Data Extraction System
|
| 3 |
sdk: docker
|
| 4 |
+
emoji: 🗂️
|
| 5 |
colorFrom: indigo
|
| 6 |
colorTo: purple
|
| 7 |
app_port: 8000
|
| 8 |
+
---
|
| 9 |
+
|
| 10 |
+
# Conversation Data Extraction System
|
| 11 |
+
|
| 12 |
+
A system that extracts structured information from chat conversations:
|
| 13 |
+
**named entities (NER), topics and sentiment**, then **pseudonymizes** personal
|
| 14 |
+
data (GDPR) and stores the structured result.
|
| 15 |
+
|
| 16 |
+
## Project versions
|
| 17 |
+
|
| 18 |
+
This repository is developed incrementally as part of a diploma thesis. Each
|
| 19 |
+
version is tagged so the progression is visible in the git history.
|
| 20 |
+
|
| 21 |
+
| Version | Tag | Description |
|
| 22 |
+
|---------|-----|--------------|
|
| 23 |
+
| V1 | `v1.0` | Single fine-tuned RoBERTa model, `/predict` endpoint, simple SQLite logging with regex anonymization. |
|
| 24 |
+
| V2 | `v2.0` | Full extraction pipeline: batch `/ingest`, NER, zero-shot topic classification, Presidio-based anonymization, dashboard with stored records and statistics. |
|
| 25 |
+
| V3 | `v3.0` | Dynamic sentiment model selection from the Hugging Face Hub at request time, plus XML export of stored records. |
|
| 26 |
+
|
| 27 |
+
See [`docs/class_diagram.md`](docs/class_diagram.md) for the current architecture.
|
| 28 |
+
|
| 29 |
+
## Pipeline
|
| 30 |
+
|
| 31 |
+
```
|
| 32 |
+
ingest → NER → topic classification → sentiment → anonymization → storage
|
| 33 |
+
```
|
| 34 |
+
|
| 35 |
+
| Step | Method / model |
|
| 36 |
+
|------|----------------|
|
| 37 |
+
| NER | `dslim/bert-base-NER` (inference) |
|
| 38 |
+
| Topics | `facebook/bart-large-mnli` (zero-shot) |
|
| 39 |
+
| Sentiment | fine-tuned RoBERTa (`vojmahdal/roberta-sentiment-3labels`) |
|
| 40 |
+
| Anonymization | Microsoft Presidio (spaCy NER + regex), regex fallback |
|
| 41 |
+
| Storage | SQLite (hash of original + anonymized text) |
|
| 42 |
+
|
| 43 |
+
## Endpoints
|
| 44 |
+
|
| 45 |
+
| Method | Path | Description |
|
| 46 |
+
|--------|------|-------------|
|
| 47 |
+
| GET | `/` | Web dashboard |
|
| 48 |
+
| GET | `/health` | Service + model status |
|
| 49 |
+
| GET | `/models` | Default, suggested and currently cached sentiment models |
|
| 50 |
+
| POST | `/analyze` | Full pipeline on a single message |
|
| 51 |
+
| POST | `/predict` | Sentiment only (backward compatible) |
|
| 52 |
+
| POST | `/ingest` | Batch ingest of a CSV/JSON file |
|
| 53 |
+
| GET | `/records` | Recent stored (anonymized) records |
|
| 54 |
+
| GET | `/records/export.xml` | Stored records exported as XML |
|
| 55 |
+
| GET | `/stats` | Aggregate statistics |
|
| 56 |
+
|
| 57 |
+
### Example
|
| 58 |
+
|
| 59 |
+
```bash
|
| 60 |
+
curl -X POST https://<space-url>/analyze \
|
| 61 |
+
-H "Content-Type: application/json" \
|
| 62 |
+
-d '{"text": "Hi, John Smith here, my order never arrived. Email john@example.com"}'
|
| 63 |
+
```
|
| 64 |
+
|
| 65 |
+
```bash
|
| 66 |
+
curl -X POST https://<space-url>/ingest -F "file=@sample_chats.csv"
|
| 67 |
+
```
|
| 68 |
+
|
| 69 |
+
```bash
|
| 70 |
+
curl https://<space-url>/records/export.xml -o records.xml
|
| 71 |
+
```
|
| 72 |
+
|
| 73 |
+
## Choosing a sentiment model from the Hugging Face Hub
|
| 74 |
+
|
| 75 |
+
Since V3, `/analyze`, `/predict` and `/ingest` accept an optional
|
| 76 |
+
`sentiment_model` field (a Hugging Face repo id, e.g.
|
| 77 |
+
`cardiffnlp/twitter-roberta-base-sentiment-latest`). If omitted, the
|
| 78 |
+
author's fine-tuned model (`vojmahdal/roberta-sentiment-3labels`) is used.
|
| 79 |
+
Requested models are downloaded and cached in memory on first use
|
| 80 |
+
(`processors/model_registry.py`), with a small FIFO cache (3 models) to
|
| 81 |
+
bound memory usage. `GET /models` lists the default model, a few suggested
|
| 82 |
+
models, and which ones are currently cached. The web dashboard exposes this
|
| 83 |
+
as an editable field with suggestions.
|
| 84 |
+
|
| 85 |
+
**Security note:** loaded pipelines never use `trust_remote_code=True`, so an
|
| 86 |
+
arbitrary/untrusted model id supplied by a caller cannot execute custom
|
| 87 |
+
Python code inside the server process - it is limited to standard
|
| 88 |
+
`transformers` text-classification inference.
|
| 89 |
+
|
| 90 |
+
## Data protection
|
| 91 |
+
|
| 92 |
+
The original message text is **never stored in readable form**. Only a SHA-256
|
| 93 |
+
hash (for deduplication) and the anonymized text are persisted. Because the
|
| 94 |
+
transformation is reversible in principle and re-identification could occur with
|
| 95 |
+
additional information, the approach is **pseudonymization** under the GDPR
|
| 96 |
+
(Art. 4(5)); stored data therefore remains personal data and is handled with
|
| 97 |
+
data minimization in mind.
|
| 98 |
+
|
| 99 |
+
## Local run
|
| 100 |
+
|
| 101 |
+
```bash
|
| 102 |
+
pip install -r requirements.txt
|
| 103 |
+
python -m spacy download en_core_web_lg
|
| 104 |
+
uvicorn main:app --reload --port 8000
|
| 105 |
+
```
|
| 106 |
+
|
| 107 |
+
`requirements-dev.txt` holds extra dependencies (`pandas`, `seqeval`) needed
|
| 108 |
+
only by the offline helper scripts `prepare_dataset.py` and `evaluate.py` -
|
| 109 |
+
not required to run the API itself.
|
db.py
CHANGED
|
@@ -1,158 +1,218 @@
|
|
| 1 |
-
|
| 2 |
-
|
| 3 |
-
|
| 4 |
-
|
| 5 |
-
|
| 6 |
-
|
| 7 |
-
|
| 8 |
-
|
| 9 |
-
|
| 10 |
-
|
| 11 |
-
|
| 12 |
-
|
| 13 |
-
|
| 14 |
-
|
| 15 |
-
|
| 16 |
-
|
| 17 |
-
|
| 18 |
-
|
| 19 |
-
|
| 20 |
-
|
| 21 |
-
|
| 22 |
-
|
| 23 |
-
|
| 24 |
-
|
| 25 |
-
|
| 26 |
-
|
| 27 |
-
|
| 28 |
-
|
| 29 |
-
|
| 30 |
-
|
| 31 |
-
|
| 32 |
-
|
| 33 |
-
|
| 34 |
-
|
| 35 |
-
|
| 36 |
-
|
| 37 |
-
|
| 38 |
-
|
| 39 |
-
|
| 40 |
-
|
| 41 |
-
|
| 42 |
-
|
| 43 |
-
|
| 44 |
-
|
| 45 |
-
"""
|
| 46 |
-
|
| 47 |
-
|
| 48 |
-
|
| 49 |
-
|
| 50 |
-
|
| 51 |
-
|
| 52 |
-
|
| 53 |
-
|
| 54 |
-
|
| 55 |
-
|
| 56 |
-
|
| 57 |
-
|
| 58 |
-
|
| 59 |
-
|
| 60 |
-
)
|
| 61 |
-
|
| 62 |
-
|
| 63 |
-
|
| 64 |
-
|
| 65 |
-
|
| 66 |
-
|
| 67 |
-
|
| 68 |
-
|
| 69 |
-
|
| 70 |
-
|
| 71 |
-
|
| 72 |
-
|
| 73 |
-
|
| 74 |
-
|
| 75 |
-
|
| 76 |
-
|
| 77 |
-
|
| 78 |
-
|
| 79 |
-
|
| 80 |
-
|
| 81 |
-
|
| 82 |
-
|
| 83 |
-
|
| 84 |
-
|
| 85 |
-
|
| 86 |
-
|
| 87 |
-
|
| 88 |
-
|
| 89 |
-
|
| 90 |
-
|
| 91 |
-
|
| 92 |
-
|
| 93 |
-
|
| 94 |
-
|
| 95 |
-
|
| 96 |
-
|
| 97 |
-
|
| 98 |
-
|
| 99 |
-
|
| 100 |
-
|
| 101 |
-
|
| 102 |
-
|
| 103 |
-
|
| 104 |
-
|
| 105 |
-
|
| 106 |
-
|
| 107 |
-
|
| 108 |
-
|
| 109 |
-
|
| 110 |
-
|
| 111 |
-
|
| 112 |
-
|
| 113 |
-
|
| 114 |
-
|
| 115 |
-
|
| 116 |
-
|
| 117 |
-
|
| 118 |
-
|
| 119 |
-
|
| 120 |
-
|
| 121 |
-
|
| 122 |
-
|
| 123 |
-
|
| 124 |
-
|
| 125 |
-
|
| 126 |
-
|
| 127 |
-
|
| 128 |
-
|
| 129 |
-
|
| 130 |
-
|
| 131 |
-
|
| 132 |
-
|
| 133 |
-
|
| 134 |
-
|
| 135 |
-
|
| 136 |
-
|
| 137 |
-
|
| 138 |
-
|
| 139 |
-
|
| 140 |
-
|
| 141 |
-
|
| 142 |
-
|
| 143 |
-
|
| 144 |
-
)
|
| 145 |
-
|
| 146 |
-
|
| 147 |
-
|
| 148 |
-
|
| 149 |
-
|
| 150 |
-
|
| 151 |
-
|
| 152 |
-
|
| 153 |
-
"
|
| 154 |
-
|
| 155 |
-
|
| 156 |
-
|
| 157 |
-
|
| 158 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Database layer (SQLite).
|
| 3 |
+
|
| 4 |
+
Stores the structured output of the pipeline in a pseudonymized form. The
|
| 5 |
+
original text is never stored in readable form: only a SHA-256 hash (for
|
| 6 |
+
deduplication) and the anonymized text are persisted, together with the
|
| 7 |
+
extracted entities, topic and sentiment.
|
| 8 |
+
|
| 9 |
+
SQLite was chosen for the prototype (serverless, zero-config, portable). The
|
| 10 |
+
layer is intentionally small so it can be swapped for PostgreSQL in a
|
| 11 |
+
production deployment without changing the rest of the application.
|
| 12 |
+
"""
|
| 13 |
+
|
| 14 |
+
from __future__ import annotations
|
| 15 |
+
|
| 16 |
+
import hashlib
|
| 17 |
+
import json
|
| 18 |
+
import os
|
| 19 |
+
import sqlite3
|
| 20 |
+
import threading
|
| 21 |
+
from datetime import datetime, timezone
|
| 22 |
+
from pathlib import Path
|
| 23 |
+
from typing import Any
|
| 24 |
+
from xml.etree import ElementTree as ET
|
| 25 |
+
|
| 26 |
+
from processors import anonymizer
|
| 27 |
+
|
| 28 |
+
# ---------------------------------------------------------------------------
|
| 29 |
+
# Database location: project folder on Windows, /tmp on Linux (HF Spaces).
|
| 30 |
+
# ---------------------------------------------------------------------------
|
| 31 |
+
if os.name == "nt":
|
| 32 |
+
DB_PATH = os.getenv(
|
| 33 |
+
"CONV_DB_PATH",
|
| 34 |
+
str((Path(__file__).resolve().parent / "conversation_logs.db")),
|
| 35 |
+
)
|
| 36 |
+
else:
|
| 37 |
+
DB_PATH = os.getenv("CONV_DB_PATH", "/tmp/conversation_logs.db")
|
| 38 |
+
|
| 39 |
+
_db_lock = threading.Lock()
|
| 40 |
+
|
| 41 |
+
|
| 42 |
+
def _get_connection() -> sqlite3.Connection:
|
| 43 |
+
conn = sqlite3.connect(DB_PATH, check_same_thread=False)
|
| 44 |
+
conn.execute(
|
| 45 |
+
"""
|
| 46 |
+
CREATE TABLE IF NOT EXISTS records (
|
| 47 |
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
| 48 |
+
created_at TEXT NOT NULL,
|
| 49 |
+
original_hash TEXT NOT NULL,
|
| 50 |
+
anonymized_text TEXT NOT NULL,
|
| 51 |
+
entities TEXT, -- JSON array
|
| 52 |
+
topic TEXT,
|
| 53 |
+
topic_score REAL,
|
| 54 |
+
sentiment TEXT,
|
| 55 |
+
sentiment_score REAL,
|
| 56 |
+
conversation_id TEXT,
|
| 57 |
+
source TEXT -- 'single' or 'ingest'
|
| 58 |
+
)
|
| 59 |
+
"""
|
| 60 |
+
)
|
| 61 |
+
conn.commit()
|
| 62 |
+
return conn
|
| 63 |
+
|
| 64 |
+
|
| 65 |
+
_db_conn = _get_connection()
|
| 66 |
+
|
| 67 |
+
|
| 68 |
+
def _utcnow() -> str:
|
| 69 |
+
return datetime.now(timezone.utc).isoformat(timespec="seconds")
|
| 70 |
+
|
| 71 |
+
|
| 72 |
+
def save_record(result: dict[str, Any], source: str = "single") -> None:
|
| 73 |
+
"""
|
| 74 |
+
Persist one pipeline result. The original text is hashed (not stored);
|
| 75 |
+
the anonymized text is stored. If the result does not already contain an
|
| 76 |
+
anonymized text, it is anonymized here as a safeguard.
|
| 77 |
+
"""
|
| 78 |
+
original_text = result.get("text", "") or ""
|
| 79 |
+
anonymized = result.get("anonymized_text") or anonymizer.anonymize_text(original_text)
|
| 80 |
+
original_hash = hashlib.sha256(original_text.encode("utf-8")).hexdigest()
|
| 81 |
+
|
| 82 |
+
entities_json = json.dumps(result.get("entities", []), ensure_ascii=False)
|
| 83 |
+
|
| 84 |
+
with _db_lock:
|
| 85 |
+
_db_conn.execute(
|
| 86 |
+
"""
|
| 87 |
+
INSERT INTO records (
|
| 88 |
+
created_at, original_hash, anonymized_text, entities,
|
| 89 |
+
topic, topic_score, sentiment, sentiment_score,
|
| 90 |
+
conversation_id, source
|
| 91 |
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
| 92 |
+
""",
|
| 93 |
+
(
|
| 94 |
+
_utcnow(),
|
| 95 |
+
original_hash,
|
| 96 |
+
anonymized,
|
| 97 |
+
entities_json,
|
| 98 |
+
result.get("topic"),
|
| 99 |
+
result.get("topic_score"),
|
| 100 |
+
result.get("sentiment"),
|
| 101 |
+
result.get("sentiment_score"),
|
| 102 |
+
result.get("conversation_id"),
|
| 103 |
+
source,
|
| 104 |
+
),
|
| 105 |
+
)
|
| 106 |
+
_db_conn.commit()
|
| 107 |
+
|
| 108 |
+
|
| 109 |
+
def get_records(limit: int = 100) -> list[dict[str, Any]]:
|
| 110 |
+
"""Return the most recent records (anonymized only)."""
|
| 111 |
+
with _db_lock:
|
| 112 |
+
cur = _db_conn.execute(
|
| 113 |
+
"""
|
| 114 |
+
SELECT id, created_at, anonymized_text, entities,
|
| 115 |
+
topic, topic_score, sentiment, sentiment_score,
|
| 116 |
+
conversation_id, source
|
| 117 |
+
FROM records
|
| 118 |
+
ORDER BY id DESC
|
| 119 |
+
LIMIT ?
|
| 120 |
+
""",
|
| 121 |
+
(limit,),
|
| 122 |
+
)
|
| 123 |
+
rows = cur.fetchall()
|
| 124 |
+
|
| 125 |
+
records = []
|
| 126 |
+
for r in rows:
|
| 127 |
+
try:
|
| 128 |
+
entities = json.loads(r[3]) if r[3] else []
|
| 129 |
+
except Exception:
|
| 130 |
+
entities = []
|
| 131 |
+
records.append(
|
| 132 |
+
{
|
| 133 |
+
"id": r[0],
|
| 134 |
+
"created_at": r[1],
|
| 135 |
+
"anonymized_text": r[2],
|
| 136 |
+
"entities": entities,
|
| 137 |
+
"topic": r[4],
|
| 138 |
+
"topic_score": r[5],
|
| 139 |
+
"sentiment": r[6],
|
| 140 |
+
"sentiment_score": r[7],
|
| 141 |
+
"conversation_id": r[8],
|
| 142 |
+
"source": r[9],
|
| 143 |
+
}
|
| 144 |
+
)
|
| 145 |
+
return records
|
| 146 |
+
|
| 147 |
+
|
| 148 |
+
def stats() -> dict[str, Any]:
|
| 149 |
+
"""Aggregate statistics for the dashboard (counts by sentiment / topic)."""
|
| 150 |
+
with _db_lock:
|
| 151 |
+
total = _db_conn.execute("SELECT COUNT(*) FROM records").fetchone()[0]
|
| 152 |
+
by_sentiment = _db_conn.execute(
|
| 153 |
+
"SELECT sentiment, COUNT(*) FROM records GROUP BY sentiment"
|
| 154 |
+
).fetchall()
|
| 155 |
+
by_topic = _db_conn.execute(
|
| 156 |
+
"SELECT topic, COUNT(*) FROM records GROUP BY topic ORDER BY COUNT(*) DESC LIMIT 10"
|
| 157 |
+
).fetchall()
|
| 158 |
+
|
| 159 |
+
return {
|
| 160 |
+
"total": total,
|
| 161 |
+
"by_sentiment": {(s or "unknown"): c for s, c in by_sentiment},
|
| 162 |
+
"by_topic": {(t or "unknown"): c for t, c in by_topic},
|
| 163 |
+
}
|
| 164 |
+
|
| 165 |
+
|
| 166 |
+
def export_xml(limit: int | None = None) -> bytes:
|
| 167 |
+
"""
|
| 168 |
+
Export stored (anonymized) records as XML.
|
| 169 |
+
|
| 170 |
+
Mirrors the fields returned by ``get_records``. ``limit`` caps the number
|
| 171 |
+
of most recent records exported; ``None`` exports everything.
|
| 172 |
+
"""
|
| 173 |
+
query = """
|
| 174 |
+
SELECT id, created_at, anonymized_text, entities,
|
| 175 |
+
topic, topic_score, sentiment, sentiment_score,
|
| 176 |
+
conversation_id, source
|
| 177 |
+
FROM records
|
| 178 |
+
ORDER BY id DESC
|
| 179 |
+
"""
|
| 180 |
+
if limit is not None:
|
| 181 |
+
query += " LIMIT ?"
|
| 182 |
+
params: tuple[Any, ...] = (limit,)
|
| 183 |
+
else:
|
| 184 |
+
params = ()
|
| 185 |
+
|
| 186 |
+
with _db_lock:
|
| 187 |
+
rows = _db_conn.execute(query, params).fetchall()
|
| 188 |
+
|
| 189 |
+
root = ET.Element("records")
|
| 190 |
+
for r in rows:
|
| 191 |
+
record_el = ET.SubElement(root, "record", id=str(r[0]))
|
| 192 |
+
ET.SubElement(record_el, "created_at").text = r[1]
|
| 193 |
+
ET.SubElement(record_el, "anonymized_text").text = r[2]
|
| 194 |
+
ET.SubElement(record_el, "topic").text = r[4]
|
| 195 |
+
ET.SubElement(record_el, "topic_score").text = (
|
| 196 |
+
str(r[5]) if r[5] is not None else None
|
| 197 |
+
)
|
| 198 |
+
ET.SubElement(record_el, "sentiment").text = r[6]
|
| 199 |
+
ET.SubElement(record_el, "sentiment_score").text = (
|
| 200 |
+
str(r[7]) if r[7] is not None else None
|
| 201 |
+
)
|
| 202 |
+
ET.SubElement(record_el, "conversation_id").text = r[8]
|
| 203 |
+
ET.SubElement(record_el, "source").text = r[9]
|
| 204 |
+
|
| 205 |
+
entities_el = ET.SubElement(record_el, "entities")
|
| 206 |
+
try:
|
| 207 |
+
entities = json.loads(r[3]) if r[3] else []
|
| 208 |
+
except Exception:
|
| 209 |
+
entities = []
|
| 210 |
+
for ent in entities:
|
| 211 |
+
ET.SubElement(
|
| 212 |
+
entities_el,
|
| 213 |
+
"entity",
|
| 214 |
+
type=str(ent.get("type", "")),
|
| 215 |
+
score=str(ent.get("score", "")),
|
| 216 |
+
).text = ent.get("text", "")
|
| 217 |
+
|
| 218 |
+
return ET.tostring(root, encoding="utf-8", xml_declaration=True)
|
docs/class_diagram.md
ADDED
|
@@ -0,0 +1,90 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Class diagram - V3
|
| 2 |
+
|
| 3 |
+
Modules are shown as facade classes over their public functions. GitHub
|
| 4 |
+
renders this Mermaid diagram directly. Compared to V2, this adds
|
| 5 |
+
`ModelRegistry` (dynamic Hugging Face model loading/caching for sentiment
|
| 6 |
+
analysis) and `export_xml()` on `Database`.
|
| 7 |
+
|
| 8 |
+
```mermaid
|
| 9 |
+
classDiagram
|
| 10 |
+
class API {
|
| 11 |
+
<<main.py>>
|
| 12 |
+
+home() FileResponse
|
| 13 |
+
+health() dict
|
| 14 |
+
+list_models() dict
|
| 15 |
+
+analyze(payload) dict
|
| 16 |
+
+predict(payload) dict
|
| 17 |
+
+ingest_file(file, sentiment_model) dict
|
| 18 |
+
+records(limit) list
|
| 19 |
+
+export_records_xml(limit) Response
|
| 20 |
+
+get_stats() dict
|
| 21 |
+
}
|
| 22 |
+
|
| 23 |
+
class IngestService {
|
| 24 |
+
<<ingest.py>>
|
| 25 |
+
+parse_csv(raw) list
|
| 26 |
+
+parse_json(raw) list
|
| 27 |
+
+parse_upload(filename, raw) list
|
| 28 |
+
}
|
| 29 |
+
|
| 30 |
+
class Pipeline {
|
| 31 |
+
<<pipeline.py>>
|
| 32 |
+
+process_message(text, topic_labels, sentiment_model) dict
|
| 33 |
+
+process_batch(messages, topic_labels, sentiment_model) list
|
| 34 |
+
}
|
| 35 |
+
|
| 36 |
+
class NERProcessor {
|
| 37 |
+
<<processors/ner.py>>
|
| 38 |
+
-model_name: str = "dslim/bert-base-NER"
|
| 39 |
+
+extract_entities(text) list
|
| 40 |
+
+is_ready() bool
|
| 41 |
+
}
|
| 42 |
+
|
| 43 |
+
class TopicClassifier {
|
| 44 |
+
<<processors/topics.py>>
|
| 45 |
+
-model_name: str = "facebook/bart-large-mnli"
|
| 46 |
+
+classify_topic(text, labels) dict
|
| 47 |
+
+is_ready() bool
|
| 48 |
+
}
|
| 49 |
+
|
| 50 |
+
class SentimentAnalyzer {
|
| 51 |
+
<<processors/sentiment.py>>
|
| 52 |
+
-default_model_name: str = "vojmahdal/roberta-sentiment-3labels"
|
| 53 |
+
+analyze_sentiment(text, model_id) dict
|
| 54 |
+
+is_ready() bool
|
| 55 |
+
}
|
| 56 |
+
|
| 57 |
+
class ModelRegistry {
|
| 58 |
+
<<processors/model_registry.py>>
|
| 59 |
+
-cache: dict~str, Pipeline~
|
| 60 |
+
-max_cached_models: int = 3
|
| 61 |
+
+get_pipeline(model_id, task) Pipeline
|
| 62 |
+
+cached_models() list
|
| 63 |
+
}
|
| 64 |
+
|
| 65 |
+
class Anonymizer {
|
| 66 |
+
<<processors/anonymizer.py>>
|
| 67 |
+
-backend: presidio | regex-fallback
|
| 68 |
+
+anonymize_text(text) str
|
| 69 |
+
+detect_pii(text) list
|
| 70 |
+
+backend_name() str
|
| 71 |
+
}
|
| 72 |
+
|
| 73 |
+
class Database {
|
| 74 |
+
<<db.py>>
|
| 75 |
+
+save_record(result, source) void
|
| 76 |
+
+get_records(limit) list
|
| 77 |
+
+stats() dict
|
| 78 |
+
+export_xml(limit) bytes
|
| 79 |
+
}
|
| 80 |
+
|
| 81 |
+
API --> IngestService : parses uploaded files
|
| 82 |
+
API --> Pipeline : runs analysis
|
| 83 |
+
API --> Database : reads/writes/export records
|
| 84 |
+
API --> ModelRegistry : lists cached models
|
| 85 |
+
Pipeline --> NERProcessor
|
| 86 |
+
Pipeline --> TopicClassifier
|
| 87 |
+
Pipeline --> SentimentAnalyzer
|
| 88 |
+
Pipeline --> Anonymizer
|
| 89 |
+
SentimentAnalyzer --> ModelRegistry : loads non-default HF models
|
| 90 |
+
```
|
evaluate.py
ADDED
|
@@ -0,0 +1,119 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Evaluation scripts for the extraction system.
|
| 3 |
+
|
| 4 |
+
Provides entity-level evaluation of NER (precision / recall / F1 via seqeval)
|
| 5 |
+
and recall-oriented evaluation of the anonymizer. These scripts produce the
|
| 6 |
+
numbers used in the "Testing" chapter of the thesis.
|
| 7 |
+
|
| 8 |
+
Usage:
|
| 9 |
+
python evaluate.py ner # evaluate NER on a small annotated sample
|
| 10 |
+
python evaluate.py anonymize # evaluate PII recall (NER vs regex)
|
| 11 |
+
|
| 12 |
+
The samples here are tiny illustrative examples. For the thesis, replace them
|
| 13 |
+
with a real annotated dataset (e.g. a subset of CoNLL-2003 for NER, or
|
| 14 |
+
ai4privacy/pii-masking-200k for anonymization).
|
| 15 |
+
"""
|
| 16 |
+
|
| 17 |
+
from __future__ import annotations
|
| 18 |
+
|
| 19 |
+
import sys
|
| 20 |
+
|
| 21 |
+
|
| 22 |
+
# ---------------------------------------------------------------------------
|
| 23 |
+
# NER evaluation (entity level, seqeval)
|
| 24 |
+
# ---------------------------------------------------------------------------
|
| 25 |
+
def evaluate_ner():
|
| 26 |
+
from seqeval.metrics import classification_report, f1_score, precision_score, recall_score
|
| 27 |
+
from processors import ner
|
| 28 |
+
|
| 29 |
+
# Each example: (tokens, gold BIO tags). Illustrative only.
|
| 30 |
+
samples = [
|
| 31 |
+
(
|
| 32 |
+
["My", "name", "is", "John", "Smith", "from", "London"],
|
| 33 |
+
["O", "O", "O", "B-PER", "I-PER", "O", "B-LOC"],
|
| 34 |
+
),
|
| 35 |
+
(
|
| 36 |
+
["Sarah", "works", "at", "Google", "in", "Berlin"],
|
| 37 |
+
["B-PER", "O", "O", "B-ORG", "O", "B-LOC"],
|
| 38 |
+
),
|
| 39 |
+
]
|
| 40 |
+
|
| 41 |
+
y_true, y_pred = [], []
|
| 42 |
+
for tokens, gold in samples:
|
| 43 |
+
text = " ".join(tokens)
|
| 44 |
+
ents = ner.extract_entities(text)
|
| 45 |
+
|
| 46 |
+
# Build predicted BIO tags aligned to whitespace tokens.
|
| 47 |
+
pred = ["O"] * len(tokens)
|
| 48 |
+
# character offset of each token
|
| 49 |
+
offsets = []
|
| 50 |
+
pos = 0
|
| 51 |
+
for tok in tokens:
|
| 52 |
+
start = text.index(tok, pos)
|
| 53 |
+
offsets.append((start, start + len(tok)))
|
| 54 |
+
pos = start + len(tok)
|
| 55 |
+
|
| 56 |
+
for ent in ents:
|
| 57 |
+
etype = ent["type"]
|
| 58 |
+
first = True
|
| 59 |
+
for i, (s, e) in enumerate(offsets):
|
| 60 |
+
# token overlaps the entity span
|
| 61 |
+
if s >= ent["start"] and e <= ent["end"] + 1:
|
| 62 |
+
pred[i] = ("B-" if first else "I-") + etype
|
| 63 |
+
first = False
|
| 64 |
+
|
| 65 |
+
y_true.append(gold)
|
| 66 |
+
y_pred.append(pred)
|
| 67 |
+
|
| 68 |
+
print("=== NER evaluation (entity level) ===")
|
| 69 |
+
print(classification_report(y_true, y_pred))
|
| 70 |
+
print(f"Precision: {precision_score(y_true, y_pred):.4f}")
|
| 71 |
+
print(f"Recall: {recall_score(y_true, y_pred):.4f}")
|
| 72 |
+
print(f"F1: {f1_score(y_true, y_pred):.4f}")
|
| 73 |
+
|
| 74 |
+
|
| 75 |
+
# ---------------------------------------------------------------------------
|
| 76 |
+
# Anonymization evaluation (PII recall: NER+Presidio vs regex only)
|
| 77 |
+
# ---------------------------------------------------------------------------
|
| 78 |
+
def evaluate_anonymize():
|
| 79 |
+
from processors import anonymizer
|
| 80 |
+
import re
|
| 81 |
+
|
| 82 |
+
# (text, list of PII substrings that MUST be removed)
|
| 83 |
+
samples = [
|
| 84 |
+
("My name is John Smith, email john@example.com", ["John Smith", "john@example.com"]),
|
| 85 |
+
("Call Sarah at +1 202 555 0143", ["Sarah", "+1 202 555 0143"]),
|
| 86 |
+
("I live in Berlin and work at Google", ["Berlin", "Google"]),
|
| 87 |
+
]
|
| 88 |
+
|
| 89 |
+
def recall(anon_fn):
|
| 90 |
+
found, total = 0, 0
|
| 91 |
+
for text, pii_list in samples:
|
| 92 |
+
anon = anon_fn(text)
|
| 93 |
+
for pii in pii_list:
|
| 94 |
+
total += 1
|
| 95 |
+
# PII counts as removed if it no longer appears verbatim
|
| 96 |
+
if pii.lower() not in anon.lower():
|
| 97 |
+
found += 1
|
| 98 |
+
return found / total if total else 0.0
|
| 99 |
+
|
| 100 |
+
# regex-only baseline (the original approach)
|
| 101 |
+
def regex_only(text):
|
| 102 |
+
from processors.anonymizer import _regex_anonymize
|
| 103 |
+
return _regex_anonymize(text)
|
| 104 |
+
|
| 105 |
+
print("=== Anonymization evaluation (PII recall) ===")
|
| 106 |
+
print(f"Active backend: {anonymizer.backend_name()}")
|
| 107 |
+
print(f"Recall (regex only): {recall(regex_only):.2%}")
|
| 108 |
+
print(f"Recall (full system): {recall(anonymizer.anonymize_text):.2%}")
|
| 109 |
+
print("\nNote: full system requires Presidio + spaCy model for name/location recall.")
|
| 110 |
+
|
| 111 |
+
|
| 112 |
+
if __name__ == "__main__":
|
| 113 |
+
mode = sys.argv[1] if len(sys.argv) > 1 else "ner"
|
| 114 |
+
if mode == "ner":
|
| 115 |
+
evaluate_ner()
|
| 116 |
+
elif mode == "anonymize":
|
| 117 |
+
evaluate_anonymize()
|
| 118 |
+
else:
|
| 119 |
+
print("Usage: python evaluate.py [ner|anonymize]")
|
gitattributes
ADDED
|
@@ -0,0 +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
|
ingest.py
ADDED
|
@@ -0,0 +1,113 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Ingest component.
|
| 3 |
+
|
| 4 |
+
Accepts conversation data as a batch (CSV or JSON), normalizes it into a unified
|
| 5 |
+
structure, and hands it over to the NLP pipeline. This is the "ingest" module
|
| 6 |
+
required by the assignment.
|
| 7 |
+
|
| 8 |
+
Unified message schema:
|
| 9 |
+
{
|
| 10 |
+
"conversation_id": <str | None>,
|
| 11 |
+
"speaker": <str | None>,
|
| 12 |
+
"timestamp": <str | None>,
|
| 13 |
+
"text": <str> # required
|
| 14 |
+
}
|
| 15 |
+
"""
|
| 16 |
+
|
| 17 |
+
from __future__ import annotations
|
| 18 |
+
|
| 19 |
+
import csv
|
| 20 |
+
import io
|
| 21 |
+
import json
|
| 22 |
+
from typing import Any
|
| 23 |
+
|
| 24 |
+
# Column / key names we accept for the message text, in priority order.
|
| 25 |
+
_TEXT_KEYS = ["text", "message", "content", "body", "utterance"]
|
| 26 |
+
_ID_KEYS = ["conversation_id", "conv_id", "id", "dialog_id"]
|
| 27 |
+
_SPEAKER_KEYS = ["speaker", "author", "role", "user"]
|
| 28 |
+
_TIME_KEYS = ["timestamp", "time", "created_at", "date"]
|
| 29 |
+
|
| 30 |
+
|
| 31 |
+
def _first_present(row: dict[str, Any], keys: list[str]) -> Any:
|
| 32 |
+
for k in keys:
|
| 33 |
+
if k in row and row[k] not in (None, ""):
|
| 34 |
+
return row[k]
|
| 35 |
+
# case-insensitive match
|
| 36 |
+
for rk in row:
|
| 37 |
+
if rk.lower() == k and row[rk] not in (None, ""):
|
| 38 |
+
return row[rk]
|
| 39 |
+
return None
|
| 40 |
+
|
| 41 |
+
|
| 42 |
+
def _normalize_row(row: dict[str, Any]) -> dict[str, Any] | None:
|
| 43 |
+
text = _first_present(row, _TEXT_KEYS)
|
| 44 |
+
if text is None or str(text).strip() == "":
|
| 45 |
+
return None
|
| 46 |
+
return {
|
| 47 |
+
"conversation_id": _first_present(row, _ID_KEYS),
|
| 48 |
+
"speaker": _first_present(row, _SPEAKER_KEYS),
|
| 49 |
+
"timestamp": _first_present(row, _TIME_KEYS),
|
| 50 |
+
"text": str(text).strip(),
|
| 51 |
+
}
|
| 52 |
+
|
| 53 |
+
|
| 54 |
+
def parse_csv(raw: bytes | str) -> list[dict[str, Any]]:
|
| 55 |
+
"""Parse CSV content into a list of normalized messages."""
|
| 56 |
+
if isinstance(raw, bytes):
|
| 57 |
+
raw = raw.decode("utf-8", errors="replace")
|
| 58 |
+
reader = csv.DictReader(io.StringIO(raw))
|
| 59 |
+
messages = []
|
| 60 |
+
for row in reader:
|
| 61 |
+
norm = _normalize_row(row)
|
| 62 |
+
if norm:
|
| 63 |
+
messages.append(norm)
|
| 64 |
+
return messages
|
| 65 |
+
|
| 66 |
+
|
| 67 |
+
def parse_json(raw: bytes | str) -> list[dict[str, Any]]:
|
| 68 |
+
"""
|
| 69 |
+
Parse JSON content into a list of normalized messages.
|
| 70 |
+
|
| 71 |
+
Accepts either a list of objects, or an object with a "messages"/"data" list,
|
| 72 |
+
or a single object.
|
| 73 |
+
"""
|
| 74 |
+
if isinstance(raw, bytes):
|
| 75 |
+
raw = raw.decode("utf-8", errors="replace")
|
| 76 |
+
data = json.loads(raw)
|
| 77 |
+
|
| 78 |
+
if isinstance(data, dict):
|
| 79 |
+
for key in ("messages", "data", "conversations", "items"):
|
| 80 |
+
if key in data and isinstance(data[key], list):
|
| 81 |
+
data = data[key]
|
| 82 |
+
break
|
| 83 |
+
else:
|
| 84 |
+
data = [data]
|
| 85 |
+
|
| 86 |
+
if not isinstance(data, list):
|
| 87 |
+
return []
|
| 88 |
+
|
| 89 |
+
messages = []
|
| 90 |
+
for row in data:
|
| 91 |
+
if isinstance(row, dict):
|
| 92 |
+
norm = _normalize_row(row)
|
| 93 |
+
if norm:
|
| 94 |
+
messages.append(norm)
|
| 95 |
+
elif isinstance(row, str) and row.strip():
|
| 96 |
+
messages.append(
|
| 97 |
+
{"conversation_id": None, "speaker": None, "timestamp": None, "text": row.strip()}
|
| 98 |
+
)
|
| 99 |
+
return messages
|
| 100 |
+
|
| 101 |
+
|
| 102 |
+
def parse_upload(filename: str, raw: bytes) -> list[dict[str, Any]]:
|
| 103 |
+
"""Dispatch to the right parser based on the file extension."""
|
| 104 |
+
name = (filename or "").lower()
|
| 105 |
+
if name.endswith(".json"):
|
| 106 |
+
return parse_json(raw)
|
| 107 |
+
if name.endswith(".csv") or name.endswith(".tsv") or name.endswith(".txt"):
|
| 108 |
+
return parse_csv(raw)
|
| 109 |
+
# try JSON first, then CSV, as a last resort
|
| 110 |
+
try:
|
| 111 |
+
return parse_json(raw)
|
| 112 |
+
except Exception:
|
| 113 |
+
return parse_csv(raw)
|
main.py
CHANGED
|
@@ -1,92 +1,226 @@
|
|
| 1 |
-
|
| 2 |
-
|
| 3 |
-
|
| 4 |
-
|
| 5 |
-
|
| 6 |
-
|
| 7 |
-
|
| 8 |
-
|
| 9 |
-
|
| 10 |
-
|
| 11 |
-
|
| 12 |
-
|
| 13 |
-
|
| 14 |
-
|
| 15 |
-
|
| 16 |
-
|
| 17 |
-
|
| 18 |
-
|
| 19 |
-
|
| 20 |
-
|
| 21 |
-
|
| 22 |
-
|
| 23 |
-
|
| 24 |
-
|
| 25 |
-
|
| 26 |
-
|
| 27 |
-
|
| 28 |
-
|
| 29 |
-
|
| 30 |
-
|
| 31 |
-
|
| 32 |
-
|
| 33 |
-
|
| 34 |
-
|
| 35 |
-
|
| 36 |
-
|
| 37 |
-
|
| 38 |
-
|
| 39 |
-
|
| 40 |
-
|
| 41 |
-
|
| 42 |
-
|
| 43 |
-
|
| 44 |
-
|
| 45 |
-
|
| 46 |
-
|
| 47 |
-
|
| 48 |
-
|
| 49 |
-
|
| 50 |
-
|
| 51 |
-
|
| 52 |
-
|
| 53 |
-
|
| 54 |
-
""
|
| 55 |
-
|
| 56 |
-
|
| 57 |
-
|
| 58 |
-
|
| 59 |
-
|
| 60 |
-
|
| 61 |
-
|
| 62 |
-
|
| 63 |
-
|
| 64 |
-
|
| 65 |
-
|
| 66 |
-
|
| 67 |
-
|
| 68 |
-
|
| 69 |
-
|
| 70 |
-
|
| 71 |
-
|
| 72 |
-
|
| 73 |
-
|
| 74 |
-
|
| 75 |
-
|
| 76 |
-
|
| 77 |
-
|
| 78 |
-
|
| 79 |
-
|
| 80 |
-
|
| 81 |
-
|
| 82 |
-
|
| 83 |
-
|
| 84 |
-
|
| 85 |
-
|
| 86 |
-
|
| 87 |
-
|
| 88 |
-
|
| 89 |
-
|
| 90 |
-
|
| 91 |
-
|
| 92 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Conversation Data Extraction System - REST API.
|
| 3 |
+
|
| 4 |
+
Endpoints:
|
| 5 |
+
GET / -> web dashboard
|
| 6 |
+
GET /health -> service + model status
|
| 7 |
+
POST /analyze -> run full pipeline on a single message
|
| 8 |
+
POST /predict -> sentiment only (backward compatible)
|
| 9 |
+
POST /ingest -> batch ingest of a CSV/JSON file
|
| 10 |
+
GET /records -> recent stored (anonymized) records
|
| 11 |
+
GET /records/export.xml -> stored records exported as XML
|
| 12 |
+
GET /stats -> aggregate statistics
|
| 13 |
+
GET /models -> default + suggested + currently loaded HF sentiment models
|
| 14 |
+
|
| 15 |
+
The full pipeline extracts named entities, classifies the topic, evaluates
|
| 16 |
+
sentiment, pseudonymizes the text and stores the structured result. Since V3,
|
| 17 |
+
the sentiment step can use any Hugging Face Hub text-classification model
|
| 18 |
+
selected by the caller, instead of only the fine-tuned default model.
|
| 19 |
+
"""
|
| 20 |
+
|
| 21 |
+
from __future__ import annotations
|
| 22 |
+
|
| 23 |
+
from pathlib import Path
|
| 24 |
+
from typing import Any
|
| 25 |
+
|
| 26 |
+
from fastapi import FastAPI, Form, HTTPException, UploadFile, File
|
| 27 |
+
from fastapi.responses import FileResponse, Response
|
| 28 |
+
from fastapi.staticfiles import StaticFiles
|
| 29 |
+
from pydantic import BaseModel
|
| 30 |
+
|
| 31 |
+
import db
|
| 32 |
+
import ingest
|
| 33 |
+
from pipeline import process_message, process_batch
|
| 34 |
+
from processors import ner, topics, sentiment, anonymizer, model_registry
|
| 35 |
+
|
| 36 |
+
app = FastAPI(
|
| 37 |
+
title="Conversation Data Extraction System",
|
| 38 |
+
description=(
|
| 39 |
+
"Extracts named entities, topics and sentiment from chat conversations, "
|
| 40 |
+
"pseudonymizes personal data (GDPR) and stores structured results."
|
| 41 |
+
),
|
| 42 |
+
version="3.0.0",
|
| 43 |
+
)
|
| 44 |
+
|
| 45 |
+
BASE_DIR = Path(__file__).resolve().parent
|
| 46 |
+
STATIC_DIR = BASE_DIR / "static"
|
| 47 |
+
app.mount("/static", StaticFiles(directory=str(STATIC_DIR)), name="static")
|
| 48 |
+
|
| 49 |
+
# Curated list of well-known Hugging Face sentiment/text-classification
|
| 50 |
+
# models offered as suggestions in the UI. Any other model id can still be
|
| 51 |
+
# supplied manually - this list is not a whitelist.
|
| 52 |
+
SUGGESTED_SENTIMENT_MODELS = [
|
| 53 |
+
sentiment.DEFAULT_MODEL_NAME,
|
| 54 |
+
"cardiffnlp/twitter-roberta-base-sentiment-latest",
|
| 55 |
+
"distilbert-base-uncased-finetuned-sst-2-english",
|
| 56 |
+
"nlptown/bert-base-multilingual-uncased-sentiment",
|
| 57 |
+
]
|
| 58 |
+
|
| 59 |
+
|
| 60 |
+
# ---------------------------------------------------------------------------
|
| 61 |
+
# Request models
|
| 62 |
+
# ---------------------------------------------------------------------------
|
| 63 |
+
class TextRequest(BaseModel):
|
| 64 |
+
text: str
|
| 65 |
+
topic_labels: list[str] | None = None
|
| 66 |
+
sentiment_model: str | None = None
|
| 67 |
+
|
| 68 |
+
|
| 69 |
+
# ---------------------------------------------------------------------------
|
| 70 |
+
# Pages
|
| 71 |
+
# ---------------------------------------------------------------------------
|
| 72 |
+
@app.get("/")
|
| 73 |
+
def home():
|
| 74 |
+
index_file = STATIC_DIR / "index.html"
|
| 75 |
+
if index_file.exists():
|
| 76 |
+
return FileResponse(str(index_file))
|
| 77 |
+
return {"message": "Conversation Data Extraction System is running. See /docs."}
|
| 78 |
+
|
| 79 |
+
|
| 80 |
+
@app.get("/health")
|
| 81 |
+
def health():
|
| 82 |
+
return {
|
| 83 |
+
"status": "ok",
|
| 84 |
+
"models": {
|
| 85 |
+
"ner": {"name": ner.model_name(), "ready": ner.is_ready()},
|
| 86 |
+
"topics": {"name": topics.model_name(), "ready": topics.is_ready()},
|
| 87 |
+
"sentiment": {"name": sentiment.model_name(), "ready": sentiment.is_ready()},
|
| 88 |
+
"anonymizer": {"backend": anonymizer.backend_name()},
|
| 89 |
+
},
|
| 90 |
+
}
|
| 91 |
+
|
| 92 |
+
|
| 93 |
+
@app.get("/models")
|
| 94 |
+
def list_models():
|
| 95 |
+
"""Default, suggested and currently warm-cached sentiment models."""
|
| 96 |
+
return {
|
| 97 |
+
"default": sentiment.DEFAULT_MODEL_NAME,
|
| 98 |
+
"suggested": SUGGESTED_SENTIMENT_MODELS,
|
| 99 |
+
"cached": model_registry.cached_models(),
|
| 100 |
+
}
|
| 101 |
+
|
| 102 |
+
|
| 103 |
+
# ---------------------------------------------------------------------------
|
| 104 |
+
# Core analysis
|
| 105 |
+
# ---------------------------------------------------------------------------
|
| 106 |
+
@app.post("/analyze")
|
| 107 |
+
def analyze(payload: TextRequest):
|
| 108 |
+
"""Run the full pipeline on a single message and store the result."""
|
| 109 |
+
if not payload.text or not payload.text.strip():
|
| 110 |
+
raise HTTPException(status_code=400, detail="Text cannot be empty.")
|
| 111 |
+
|
| 112 |
+
try:
|
| 113 |
+
result = process_message(
|
| 114 |
+
payload.text,
|
| 115 |
+
topic_labels=payload.topic_labels,
|
| 116 |
+
sentiment_model=payload.sentiment_model,
|
| 117 |
+
)
|
| 118 |
+
except RuntimeError as e:
|
| 119 |
+
raise HTTPException(status_code=400, detail=str(e))
|
| 120 |
+
|
| 121 |
+
try:
|
| 122 |
+
db.save_record(result, source="single")
|
| 123 |
+
except Exception as e:
|
| 124 |
+
print(f"[main] Failed to store record: {e}")
|
| 125 |
+
|
| 126 |
+
# do not return the raw text in a way that encourages storing it client-side;
|
| 127 |
+
# we return both for the immediate UI, but only anonymized is persisted.
|
| 128 |
+
return result
|
| 129 |
+
|
| 130 |
+
|
| 131 |
+
@app.post("/predict")
|
| 132 |
+
def predict(payload: TextRequest):
|
| 133 |
+
"""
|
| 134 |
+
Backward-compatible sentiment-only endpoint.
|
| 135 |
+
Kept so existing clients of the original API keep working.
|
| 136 |
+
"""
|
| 137 |
+
if not payload.text or not payload.text.strip():
|
| 138 |
+
raise HTTPException(status_code=400, detail="Text cannot be empty.")
|
| 139 |
+
|
| 140 |
+
try:
|
| 141 |
+
s = sentiment.analyze_sentiment(payload.text, model_id=payload.sentiment_model)
|
| 142 |
+
except RuntimeError as e:
|
| 143 |
+
raise HTTPException(status_code=400, detail=str(e))
|
| 144 |
+
return {"text": payload.text, "label": s["label"], "score": s["score"]}
|
| 145 |
+
|
| 146 |
+
|
| 147 |
+
# ---------------------------------------------------------------------------
|
| 148 |
+
# Ingest (batch)
|
| 149 |
+
# ---------------------------------------------------------------------------
|
| 150 |
+
@app.post("/ingest")
|
| 151 |
+
async def ingest_file(
|
| 152 |
+
file: UploadFile = File(...),
|
| 153 |
+
sentiment_model: str | None = Form(None),
|
| 154 |
+
):
|
| 155 |
+
"""
|
| 156 |
+
Ingest a CSV or JSON file of conversations, run the full pipeline on each
|
| 157 |
+
message, store the results and return a summary.
|
| 158 |
+
"""
|
| 159 |
+
raw = await file.read()
|
| 160 |
+
if not raw:
|
| 161 |
+
raise HTTPException(status_code=400, detail="Uploaded file is empty.")
|
| 162 |
+
|
| 163 |
+
try:
|
| 164 |
+
messages = ingest.parse_upload(file.filename or "", raw)
|
| 165 |
+
except Exception as e:
|
| 166 |
+
raise HTTPException(status_code=400, detail=f"Could not parse file: {e}")
|
| 167 |
+
|
| 168 |
+
if not messages:
|
| 169 |
+
raise HTTPException(
|
| 170 |
+
status_code=400,
|
| 171 |
+
detail="No messages found. Expected a 'text'/'message' column or field.",
|
| 172 |
+
)
|
| 173 |
+
|
| 174 |
+
# Cap batch size to keep the demo responsive on limited hardware.
|
| 175 |
+
MAX_BATCH = 200
|
| 176 |
+
truncated = len(messages) > MAX_BATCH
|
| 177 |
+
messages = messages[:MAX_BATCH]
|
| 178 |
+
|
| 179 |
+
try:
|
| 180 |
+
results = process_batch(messages, sentiment_model=sentiment_model)
|
| 181 |
+
except RuntimeError as e:
|
| 182 |
+
raise HTTPException(status_code=400, detail=str(e))
|
| 183 |
+
|
| 184 |
+
stored = 0
|
| 185 |
+
for r in results:
|
| 186 |
+
try:
|
| 187 |
+
db.save_record(r, source="ingest")
|
| 188 |
+
stored += 1
|
| 189 |
+
except Exception as e:
|
| 190 |
+
print(f"[main] Failed to store ingest record: {e}")
|
| 191 |
+
|
| 192 |
+
return {
|
| 193 |
+
"received": len(messages),
|
| 194 |
+
"processed": len(results),
|
| 195 |
+
"stored": stored,
|
| 196 |
+
"truncated": truncated,
|
| 197 |
+
"items": results,
|
| 198 |
+
}
|
| 199 |
+
|
| 200 |
+
|
| 201 |
+
# ---------------------------------------------------------------------------
|
| 202 |
+
# Stored data
|
| 203 |
+
# ---------------------------------------------------------------------------
|
| 204 |
+
@app.get("/records")
|
| 205 |
+
def records(limit: int = 100):
|
| 206 |
+
"""Return recent stored records (anonymized only)."""
|
| 207 |
+
return db.get_records(limit=limit)
|
| 208 |
+
|
| 209 |
+
|
| 210 |
+
@app.get("/records/export.xml")
|
| 211 |
+
def export_records_xml(limit: int | None = None):
|
| 212 |
+
"""Export stored (anonymized) records as XML."""
|
| 213 |
+
xml_bytes = db.export_xml(limit=limit)
|
| 214 |
+
return Response(content=xml_bytes, media_type="application/xml")
|
| 215 |
+
|
| 216 |
+
|
| 217 |
+
@app.get("/stats")
|
| 218 |
+
def get_stats():
|
| 219 |
+
"""Aggregate statistics for the dashboard."""
|
| 220 |
+
return db.stats()
|
| 221 |
+
|
| 222 |
+
|
| 223 |
+
if __name__ == "__main__":
|
| 224 |
+
import uvicorn
|
| 225 |
+
|
| 226 |
+
uvicorn.run(app, host="0.0.0.0", port=8000)
|
pipeline.py
ADDED
|
@@ -0,0 +1,91 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
NLP pipeline orchestration.
|
| 3 |
+
|
| 4 |
+
Applies the full processing chain to a single message:
|
| 5 |
+
|
| 6 |
+
raw text
|
| 7 |
+
-> named entity recognition (NER)
|
| 8 |
+
-> topic classification
|
| 9 |
+
-> sentiment analysis
|
| 10 |
+
-> anonymization (pseudonymization) of the text
|
| 11 |
+
-> structured result
|
| 12 |
+
|
| 13 |
+
This is the core component referenced in the methodology. Each step is a
|
| 14 |
+
separate processor module so the components can be developed, replaced or
|
| 15 |
+
scaled independently.
|
| 16 |
+
"""
|
| 17 |
+
|
| 18 |
+
from __future__ import annotations
|
| 19 |
+
|
| 20 |
+
from typing import Any
|
| 21 |
+
|
| 22 |
+
from processors import ner, topics, sentiment, anonymizer
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
def process_message(
|
| 26 |
+
text: str,
|
| 27 |
+
topic_labels: list[str] | None = None,
|
| 28 |
+
sentiment_model: str | None = None,
|
| 29 |
+
) -> dict[str, Any]:
|
| 30 |
+
"""
|
| 31 |
+
Run the full pipeline on a single message and return a structured result.
|
| 32 |
+
|
| 33 |
+
The returned ``anonymized_text`` is safe to store / display; the original
|
| 34 |
+
``text`` is returned only for the immediate response and is never persisted
|
| 35 |
+
in readable form (see db.py). ``sentiment_model`` optionally selects a
|
| 36 |
+
Hugging Face Hub model id to use instead of the default fine-tuned model.
|
| 37 |
+
"""
|
| 38 |
+
text = (text or "").strip()
|
| 39 |
+
if not text:
|
| 40 |
+
return {
|
| 41 |
+
"text": "",
|
| 42 |
+
"anonymized_text": "",
|
| 43 |
+
"entities": [],
|
| 44 |
+
"topic": None,
|
| 45 |
+
"topic_score": 0.0,
|
| 46 |
+
"sentiment": None,
|
| 47 |
+
"sentiment_score": 0.0,
|
| 48 |
+
}
|
| 49 |
+
|
| 50 |
+
entities = ner.extract_entities(text)
|
| 51 |
+
topic_result = topics.classify_topic(text, labels=topic_labels)
|
| 52 |
+
sentiment_result = sentiment.analyze_sentiment(text, model_id=sentiment_model)
|
| 53 |
+
anonymized = anonymizer.anonymize_text(text)
|
| 54 |
+
|
| 55 |
+
return {
|
| 56 |
+
"text": text,
|
| 57 |
+
"anonymized_text": anonymized,
|
| 58 |
+
"entities": entities,
|
| 59 |
+
"topic": topic_result["topic"],
|
| 60 |
+
"topic_score": topic_result["score"],
|
| 61 |
+
"topic_candidates": topic_result["all"],
|
| 62 |
+
"sentiment": sentiment_result["label"],
|
| 63 |
+
"sentiment_score": sentiment_result["score"],
|
| 64 |
+
}
|
| 65 |
+
|
| 66 |
+
|
| 67 |
+
def process_batch(
|
| 68 |
+
messages: list[dict[str, Any]],
|
| 69 |
+
topic_labels: list[str] | None = None,
|
| 70 |
+
sentiment_model: str | None = None,
|
| 71 |
+
) -> list[dict[str, Any]]:
|
| 72 |
+
"""
|
| 73 |
+
Process a list of normalized messages (from ingest).
|
| 74 |
+
|
| 75 |
+
Each input item is expected to contain at least a ``text`` field, plus
|
| 76 |
+
optional metadata (conversation_id, speaker, timestamp) which is passed
|
| 77 |
+
through to the result.
|
| 78 |
+
"""
|
| 79 |
+
results: list[dict[str, Any]] = []
|
| 80 |
+
for msg in messages:
|
| 81 |
+
processed = process_message(
|
| 82 |
+
msg.get("text", ""),
|
| 83 |
+
topic_labels=topic_labels,
|
| 84 |
+
sentiment_model=sentiment_model,
|
| 85 |
+
)
|
| 86 |
+
# carry over ingest metadata
|
| 87 |
+
for key in ("conversation_id", "speaker", "timestamp"):
|
| 88 |
+
if key in msg:
|
| 89 |
+
processed[key] = msg[key]
|
| 90 |
+
results.append(processed)
|
| 91 |
+
return results
|
prepare_dataset.py
ADDED
|
@@ -0,0 +1,130 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Prepare a real customer-support dataset for the /ingest endpoint.
|
| 3 |
+
|
| 4 |
+
The ingest endpoint expects a CSV (or JSON) with a `text` (or `message`) column.
|
| 5 |
+
This script takes one of the recommended public datasets and produces a clean
|
| 6 |
+
`ingest_ready.csv` with the columns: conversation_id, speaker, text.
|
| 7 |
+
|
| 8 |
+
It auto-detects the dataset by its columns, so you can point it at any of:
|
| 9 |
+
|
| 10 |
+
* Customer Support on Twitter (Kaggle: thoughtvector/customer-support-on-twitter)
|
| 11 |
+
columns include: tweet_id, author_id, inbound, text, ...
|
| 12 |
+
* Customer Support Tickets (HF: Tobi-Bueck/customer-support-tickets)
|
| 13 |
+
columns include: subject, body, queue, priority, language, ...
|
| 14 |
+
* Tech Support Conversations (Kaggle: steve1215rogg/...)
|
| 15 |
+
columns include: Conversation_ID, Customer_Issue, ...
|
| 16 |
+
* Generic ticket dataset (Customer Name / Customer Email / Ticket Description)
|
| 17 |
+
|
| 18 |
+
Usage:
|
| 19 |
+
python prepare_dataset.py <input.csv> [--limit 200] [--lang en]
|
| 20 |
+
|
| 21 |
+
Then upload `ingest_ready.csv` on the dashboard (Batch ingest) or via:
|
| 22 |
+
curl -X POST <space-url>/ingest -F "file=@ingest_ready.csv"
|
| 23 |
+
"""
|
| 24 |
+
|
| 25 |
+
from __future__ import annotations
|
| 26 |
+
|
| 27 |
+
import argparse
|
| 28 |
+
import sys
|
| 29 |
+
|
| 30 |
+
import pandas as pd
|
| 31 |
+
|
| 32 |
+
|
| 33 |
+
def detect_and_extract(df: pd.DataFrame, lang: str | None) -> pd.DataFrame:
|
| 34 |
+
cols = {c.lower(): c for c in df.columns}
|
| 35 |
+
|
| 36 |
+
# --- Customer Support on Twitter ---
|
| 37 |
+
if "text" in cols and "author_id" in cols and "inbound" in cols:
|
| 38 |
+
# keep only inbound = customer messages (the real user-generated text)
|
| 39 |
+
inbound_col = cols["inbound"]
|
| 40 |
+
df = df[df[inbound_col].astype(str).str.lower().isin(["true", "1"])]
|
| 41 |
+
out = pd.DataFrame(
|
| 42 |
+
{
|
| 43 |
+
"conversation_id": df[cols.get("tweet_id", cols["text"])].astype(str),
|
| 44 |
+
"speaker": "customer",
|
| 45 |
+
"text": df[cols["text"]].astype(str),
|
| 46 |
+
}
|
| 47 |
+
)
|
| 48 |
+
return out
|
| 49 |
+
|
| 50 |
+
# --- Tobi-Bueck customer-support-tickets (subject + body) ---
|
| 51 |
+
if "body" in cols:
|
| 52 |
+
if lang and "language" in cols:
|
| 53 |
+
df = df[df[cols["language"]].astype(str).str.lower() == lang.lower()]
|
| 54 |
+
subject = df[cols["subject"]].fillna("") if "subject" in cols else ""
|
| 55 |
+
body = df[cols["body"]].fillna("")
|
| 56 |
+
text = (subject + ". " + body).str.strip(". ") if "subject" in cols else body
|
| 57 |
+
out = pd.DataFrame(
|
| 58 |
+
{
|
| 59 |
+
"conversation_id": range(1, len(df) + 1),
|
| 60 |
+
"speaker": "customer",
|
| 61 |
+
"text": text.astype(str),
|
| 62 |
+
}
|
| 63 |
+
)
|
| 64 |
+
return out
|
| 65 |
+
|
| 66 |
+
# --- Tech Support Conversations ---
|
| 67 |
+
if "customer_issue" in cols:
|
| 68 |
+
out = pd.DataFrame(
|
| 69 |
+
{
|
| 70 |
+
"conversation_id": df[cols.get("conversation_id", cols["customer_issue"])].astype(str),
|
| 71 |
+
"speaker": "customer",
|
| 72 |
+
"text": df[cols["customer_issue"]].astype(str),
|
| 73 |
+
}
|
| 74 |
+
)
|
| 75 |
+
return out
|
| 76 |
+
|
| 77 |
+
# --- Generic ticket dataset (Ticket Description) ---
|
| 78 |
+
if "ticket description" in cols or "ticket_description" in cols:
|
| 79 |
+
desc = cols.get("ticket description", cols.get("ticket_description"))
|
| 80 |
+
out = pd.DataFrame(
|
| 81 |
+
{
|
| 82 |
+
"conversation_id": df[cols.get("ticket id", cols.get("ticket_id", desc))].astype(str),
|
| 83 |
+
"speaker": "customer",
|
| 84 |
+
"text": df[desc].astype(str),
|
| 85 |
+
}
|
| 86 |
+
)
|
| 87 |
+
return out
|
| 88 |
+
|
| 89 |
+
# --- Fallback: first text-like column ---
|
| 90 |
+
for key in ("text", "message", "content", "body", "utterance"):
|
| 91 |
+
if key in cols:
|
| 92 |
+
out = pd.DataFrame(
|
| 93 |
+
{
|
| 94 |
+
"conversation_id": range(1, len(df) + 1),
|
| 95 |
+
"speaker": "customer",
|
| 96 |
+
"text": df[cols[key]].astype(str),
|
| 97 |
+
}
|
| 98 |
+
)
|
| 99 |
+
return out
|
| 100 |
+
|
| 101 |
+
raise SystemExit(
|
| 102 |
+
f"Could not find a usable text column. Columns present: {list(df.columns)}"
|
| 103 |
+
)
|
| 104 |
+
|
| 105 |
+
|
| 106 |
+
def main():
|
| 107 |
+
ap = argparse.ArgumentParser()
|
| 108 |
+
ap.add_argument("input", help="Path to the downloaded dataset CSV")
|
| 109 |
+
ap.add_argument("--limit", type=int, default=200, help="Max rows to keep")
|
| 110 |
+
ap.add_argument("--lang", default="en", help="Language filter where supported")
|
| 111 |
+
ap.add_argument("--out", default="ingest_ready.csv", help="Output CSV path")
|
| 112 |
+
args = ap.parse_args()
|
| 113 |
+
|
| 114 |
+
df = pd.read_csv(args.input)
|
| 115 |
+
out = detect_and_extract(df, args.lang)
|
| 116 |
+
|
| 117 |
+
# clean up: drop empties, dedupe, trim, limit
|
| 118 |
+
out = out[out["text"].str.strip().astype(bool)]
|
| 119 |
+
out = out.drop_duplicates(subset=["text"])
|
| 120 |
+
out["text"] = out["text"].str.slice(0, 1000) # keep messages reasonable
|
| 121 |
+
out = out.head(args.limit)
|
| 122 |
+
|
| 123 |
+
out.to_csv(args.out, index=False)
|
| 124 |
+
print(f"Wrote {len(out)} messages to {args.out}")
|
| 125 |
+
print("Preview:")
|
| 126 |
+
print(out.head(5).to_string(index=False))
|
| 127 |
+
|
| 128 |
+
|
| 129 |
+
if __name__ == "__main__":
|
| 130 |
+
main()
|
processors/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
"""NLP processors: NER, topic classification, sentiment, anonymization."""
|
processors/anonymizer.py
ADDED
|
@@ -0,0 +1,162 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Anonymization / pseudonymization of personal data (PII).
|
| 3 |
+
|
| 4 |
+
Primary strategy: Microsoft Presidio (NER via spaCy + regex recognizers + context).
|
| 5 |
+
Fallback strategy: regular expressions only (used if Presidio/spaCy are unavailable).
|
| 6 |
+
|
| 7 |
+
Per GDPR terminology this is *pseudonymization*: identifiers are replaced by
|
| 8 |
+
placeholders and the original text is not stored in readable form (only a hash
|
| 9 |
+
is kept for deduplication). The data therefore remains personal data and must be
|
| 10 |
+
handled accordingly.
|
| 11 |
+
"""
|
| 12 |
+
|
| 13 |
+
from __future__ import annotations
|
| 14 |
+
|
| 15 |
+
import re
|
| 16 |
+
from typing import Any
|
| 17 |
+
|
| 18 |
+
# ---------------------------------------------------------------------------
|
| 19 |
+
# Try to load Presidio. If it is not installed (or the spaCy model is missing),
|
| 20 |
+
# the module gracefully degrades to a regex-only anonymizer so the system never
|
| 21 |
+
# crashes on startup.
|
| 22 |
+
# ---------------------------------------------------------------------------
|
| 23 |
+
_PRESIDIO_AVAILABLE = False
|
| 24 |
+
_analyzer = None
|
| 25 |
+
_anonymizer = None
|
| 26 |
+
|
| 27 |
+
# spaCy models to try, from most to least accurate. The Docker image installs
|
| 28 |
+
# en_core_web_lg; the smaller ones are accepted as fallbacks.
|
| 29 |
+
_SPACY_MODELS = ["en_core_web_lg", "en_core_web_md", "en_core_web_sm"]
|
| 30 |
+
|
| 31 |
+
|
| 32 |
+
def _find_spacy_model() -> str | None:
|
| 33 |
+
try:
|
| 34 |
+
import spacy
|
| 35 |
+
except Exception:
|
| 36 |
+
return None
|
| 37 |
+
for name in _SPACY_MODELS:
|
| 38 |
+
try:
|
| 39 |
+
spacy.load(name)
|
| 40 |
+
return name
|
| 41 |
+
except Exception:
|
| 42 |
+
continue
|
| 43 |
+
return None
|
| 44 |
+
|
| 45 |
+
|
| 46 |
+
try:
|
| 47 |
+
from presidio_analyzer import AnalyzerEngine
|
| 48 |
+
from presidio_analyzer.nlp_engine import NlpEngineProvider
|
| 49 |
+
from presidio_anonymizer import AnonymizerEngine
|
| 50 |
+
from presidio_anonymizer.entities import OperatorConfig
|
| 51 |
+
|
| 52 |
+
_model = _find_spacy_model()
|
| 53 |
+
if _model is None:
|
| 54 |
+
raise RuntimeError("no spaCy model available")
|
| 55 |
+
|
| 56 |
+
_provider = NlpEngineProvider(
|
| 57 |
+
nlp_configuration={
|
| 58 |
+
"nlp_engine_name": "spacy",
|
| 59 |
+
"models": [{"lang_code": "en", "model_name": _model}],
|
| 60 |
+
}
|
| 61 |
+
)
|
| 62 |
+
_analyzer = AnalyzerEngine(nlp_engine=_provider.create_engine())
|
| 63 |
+
_anonymizer = AnonymizerEngine()
|
| 64 |
+
_PRESIDIO_AVAILABLE = True
|
| 65 |
+
print(f"[anonymizer] Presidio loaded (spaCy model: {_model}).")
|
| 66 |
+
except Exception as e: # pragma: no cover - depends on runtime environment
|
| 67 |
+
print(f"[anonymizer] Presidio unavailable, falling back to regex only: {e}")
|
| 68 |
+
|
| 69 |
+
|
| 70 |
+
# ---------------------------------------------------------------------------
|
| 71 |
+
# Regex fallback (also used to enrich Presidio for strictly formatted IDs)
|
| 72 |
+
# ---------------------------------------------------------------------------
|
| 73 |
+
_EMAIL_RE = re.compile(r"[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}")
|
| 74 |
+
_PHONE_RE = re.compile(r"\+?\d[\d\s\-]{7,}\d")
|
| 75 |
+
_URL_RE = re.compile(r"https?://\S+|www\.\S+")
|
| 76 |
+
_CREDIT_CARD_RE = re.compile(r"\b(?:\d[ -]*?){13,16}\b")
|
| 77 |
+
|
| 78 |
+
# Map Presidio entity types -> placeholder labels we expose to users.
|
| 79 |
+
_PLACEHOLDERS = {
|
| 80 |
+
"PERSON": "[NAME]",
|
| 81 |
+
"EMAIL_ADDRESS": "[EMAIL]",
|
| 82 |
+
"PHONE_NUMBER": "[PHONE]",
|
| 83 |
+
"LOCATION": "[LOCATION]",
|
| 84 |
+
"CREDIT_CARD": "[CARD]",
|
| 85 |
+
"IBAN_CODE": "[IBAN]",
|
| 86 |
+
"IP_ADDRESS": "[IP]",
|
| 87 |
+
"URL": "[URL]",
|
| 88 |
+
"DATE_TIME": "[DATE]",
|
| 89 |
+
"NRP": "[ID]",
|
| 90 |
+
"US_SSN": "[ID]",
|
| 91 |
+
}
|
| 92 |
+
|
| 93 |
+
|
| 94 |
+
def _regex_anonymize(text: str) -> str:
|
| 95 |
+
"""Pure-regex anonymization for the formatted identifiers we can match safely."""
|
| 96 |
+
text = _EMAIL_RE.sub("[EMAIL]", text)
|
| 97 |
+
text = _URL_RE.sub("[URL]", text)
|
| 98 |
+
text = _CREDIT_CARD_RE.sub("[CARD]", text)
|
| 99 |
+
text = _PHONE_RE.sub("[PHONE]", text)
|
| 100 |
+
return text
|
| 101 |
+
|
| 102 |
+
|
| 103 |
+
def anonymize_text(text: str, language: str = "en") -> str:
|
| 104 |
+
"""
|
| 105 |
+
Return a pseudonymized version of ``text``.
|
| 106 |
+
|
| 107 |
+
With Presidio: NER-based detection of names/locations + built-in regex
|
| 108 |
+
recognizers for emails, phones, cards, IBANs, etc.
|
| 109 |
+
Without Presidio: regex-only fallback (emails, phones, URLs, cards).
|
| 110 |
+
"""
|
| 111 |
+
if not isinstance(text, str) or not text.strip():
|
| 112 |
+
return ""
|
| 113 |
+
|
| 114 |
+
if not _PRESIDIO_AVAILABLE:
|
| 115 |
+
return _regex_anonymize(text)
|
| 116 |
+
|
| 117 |
+
try:
|
| 118 |
+
results = _analyzer.analyze(text=text, language=language)
|
| 119 |
+
operators = {
|
| 120 |
+
entity: OperatorConfig("replace", {"new_value": placeholder})
|
| 121 |
+
for entity, placeholder in _PLACEHOLDERS.items()
|
| 122 |
+
}
|
| 123 |
+
# default operator for anything detected but not explicitly mapped
|
| 124 |
+
operators["DEFAULT"] = OperatorConfig("replace", {"new_value": "[REDACTED]"})
|
| 125 |
+
|
| 126 |
+
anonymized = _anonymizer.anonymize(
|
| 127 |
+
text=text,
|
| 128 |
+
analyzer_results=results,
|
| 129 |
+
operators=operators,
|
| 130 |
+
)
|
| 131 |
+
return anonymized.text
|
| 132 |
+
except Exception as e: # pragma: no cover
|
| 133 |
+
print(f"[anonymizer] Presidio failed at runtime, using regex fallback: {e}")
|
| 134 |
+
return _regex_anonymize(text)
|
| 135 |
+
|
| 136 |
+
|
| 137 |
+
def detect_pii(text: str, language: str = "en") -> list[dict[str, Any]]:
|
| 138 |
+
"""
|
| 139 |
+
Return the list of detected PII spans (for evaluation / debugging).
|
| 140 |
+
Empty list if Presidio is unavailable.
|
| 141 |
+
"""
|
| 142 |
+
if not _PRESIDIO_AVAILABLE or not isinstance(text, str) or not text.strip():
|
| 143 |
+
return []
|
| 144 |
+
try:
|
| 145 |
+
results = _analyzer.analyze(text=text, language=language)
|
| 146 |
+
return [
|
| 147 |
+
{
|
| 148 |
+
"type": r.entity_type,
|
| 149 |
+
"start": r.start,
|
| 150 |
+
"end": r.end,
|
| 151 |
+
"score": float(r.score),
|
| 152 |
+
"text": text[r.start : r.end],
|
| 153 |
+
}
|
| 154 |
+
for r in results
|
| 155 |
+
]
|
| 156 |
+
except Exception:
|
| 157 |
+
return []
|
| 158 |
+
|
| 159 |
+
|
| 160 |
+
def backend_name() -> str:
|
| 161 |
+
"""Report which anonymization backend is active (for /health and the UI)."""
|
| 162 |
+
return "presidio" if _PRESIDIO_AVAILABLE else "regex-fallback"
|
processors/model_registry.py
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Generic loader/cache for text-classification models pulled from the
|
| 3 |
+
Hugging Face Hub at request time.
|
| 4 |
+
|
| 5 |
+
This lets a caller pick *any* HF model id for sentiment analysis instead of
|
| 6 |
+
being limited to the single fine-tuned model. Pipelines are expensive to
|
| 7 |
+
instantiate (they download weights on first use), so loaded pipelines are
|
| 8 |
+
kept in a small in-memory cache.
|
| 9 |
+
|
| 10 |
+
Security note: ``trust_remote_code`` is never enabled. Enabling it would let
|
| 11 |
+
an arbitrary Hub repository execute Python code inside this process, which
|
| 12 |
+
is not acceptable for a model id supplied by an API caller.
|
| 13 |
+
"""
|
| 14 |
+
|
| 15 |
+
from __future__ import annotations
|
| 16 |
+
|
| 17 |
+
import threading
|
| 18 |
+
from typing import Any
|
| 19 |
+
|
| 20 |
+
# Maximum number of distinct models kept warm in memory at once. Oldest
|
| 21 |
+
# (first loaded) is evicted when the cache is full - simple FIFO, adequate
|
| 22 |
+
# for a demo/thesis system that isn't serving many concurrent model ids.
|
| 23 |
+
_MAX_CACHED_MODELS = 3
|
| 24 |
+
|
| 25 |
+
_cache: dict[str, Any] = {}
|
| 26 |
+
_cache_order: list[str] = []
|
| 27 |
+
_load_errors: dict[str, str] = {}
|
| 28 |
+
_lock = threading.Lock()
|
| 29 |
+
|
| 30 |
+
|
| 31 |
+
def get_pipeline(model_id: str, task: str = "sentiment-analysis"):
|
| 32 |
+
"""
|
| 33 |
+
Return a cached (or newly loaded) Hugging Face pipeline for ``model_id``.
|
| 34 |
+
|
| 35 |
+
Raises ``RuntimeError`` if the model cannot be loaded (unknown repo,
|
| 36 |
+
not a classification model, network error, ...) so callers can turn it
|
| 37 |
+
into a clean HTTP error instead of crashing.
|
| 38 |
+
"""
|
| 39 |
+
with _lock:
|
| 40 |
+
if model_id in _cache:
|
| 41 |
+
return _cache[model_id]
|
| 42 |
+
if model_id in _load_errors:
|
| 43 |
+
raise RuntimeError(_load_errors[model_id])
|
| 44 |
+
|
| 45 |
+
try:
|
| 46 |
+
from transformers import pipeline
|
| 47 |
+
|
| 48 |
+
clf = pipeline(
|
| 49 |
+
task,
|
| 50 |
+
model=model_id,
|
| 51 |
+
tokenizer=model_id,
|
| 52 |
+
trust_remote_code=False,
|
| 53 |
+
)
|
| 54 |
+
except Exception as e: # pragma: no cover - depends on network/model
|
| 55 |
+
with _lock:
|
| 56 |
+
_load_errors[model_id] = str(e)
|
| 57 |
+
raise RuntimeError(f"Could not load model '{model_id}': {e}") from e
|
| 58 |
+
|
| 59 |
+
with _lock:
|
| 60 |
+
_cache[model_id] = clf
|
| 61 |
+
_cache_order.append(model_id)
|
| 62 |
+
while len(_cache_order) > _MAX_CACHED_MODELS:
|
| 63 |
+
oldest = _cache_order.pop(0)
|
| 64 |
+
_cache.pop(oldest, None)
|
| 65 |
+
|
| 66 |
+
return clf
|
| 67 |
+
|
| 68 |
+
|
| 69 |
+
def cached_models() -> list[str]:
|
| 70 |
+
"""Model ids currently kept warm in memory."""
|
| 71 |
+
with _lock:
|
| 72 |
+
return list(_cache_order)
|
processors/ner.py
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Named Entity Recognition (NER).
|
| 3 |
+
|
| 4 |
+
Uses the pre-trained model ``dslim/bert-base-NER`` (fine-tuned on CoNLL-2003,
|
| 5 |
+
~92.6% F1) via the Hugging Face ``token-classification`` pipeline.
|
| 6 |
+
|
| 7 |
+
No training is required here - this is direct inference on a pre-trained model,
|
| 8 |
+
in line with the methodology. The model is loaded lazily on first use so the
|
| 9 |
+
application starts quickly and only pays the memory cost when NER is actually
|
| 10 |
+
needed.
|
| 11 |
+
"""
|
| 12 |
+
|
| 13 |
+
from __future__ import annotations
|
| 14 |
+
|
| 15 |
+
from typing import Any
|
| 16 |
+
|
| 17 |
+
_MODEL_NAME = "dslim/bert-base-NER"
|
| 18 |
+
_pipeline = None
|
| 19 |
+
_load_error: str | None = None
|
| 20 |
+
|
| 21 |
+
|
| 22 |
+
def _get_pipeline():
|
| 23 |
+
"""Lazy-load the NER pipeline on first call."""
|
| 24 |
+
global _pipeline, _load_error
|
| 25 |
+
if _pipeline is not None or _load_error is not None:
|
| 26 |
+
return _pipeline
|
| 27 |
+
|
| 28 |
+
try:
|
| 29 |
+
from transformers import pipeline
|
| 30 |
+
|
| 31 |
+
_pipeline = pipeline(
|
| 32 |
+
"token-classification",
|
| 33 |
+
model=_MODEL_NAME,
|
| 34 |
+
tokenizer=_MODEL_NAME,
|
| 35 |
+
aggregation_strategy="simple", # merge sub-word tokens into whole entities
|
| 36 |
+
)
|
| 37 |
+
print(f"[ner] Loaded model {_MODEL_NAME}.")
|
| 38 |
+
except Exception as e: # pragma: no cover
|
| 39 |
+
_load_error = str(e)
|
| 40 |
+
print(f"[ner] Failed to load model: {e}")
|
| 41 |
+
return _pipeline
|
| 42 |
+
|
| 43 |
+
|
| 44 |
+
def extract_entities(text: str) -> list[dict[str, Any]]:
|
| 45 |
+
"""
|
| 46 |
+
Extract named entities from ``text``.
|
| 47 |
+
|
| 48 |
+
Returns a list of dicts: {"text", "type", "start", "end", "score"}.
|
| 49 |
+
Entity types follow CoNLL-2003: PER (person), LOC (location),
|
| 50 |
+
ORG (organization), MISC (miscellaneous).
|
| 51 |
+
"""
|
| 52 |
+
if not isinstance(text, str) or not text.strip():
|
| 53 |
+
return []
|
| 54 |
+
|
| 55 |
+
nlp = _get_pipeline()
|
| 56 |
+
if nlp is None:
|
| 57 |
+
return []
|
| 58 |
+
|
| 59 |
+
try:
|
| 60 |
+
raw = nlp(text)
|
| 61 |
+
except Exception as e: # pragma: no cover
|
| 62 |
+
print(f"[ner] Inference failed: {e}")
|
| 63 |
+
return []
|
| 64 |
+
|
| 65 |
+
entities: list[dict[str, Any]] = []
|
| 66 |
+
for ent in raw:
|
| 67 |
+
entities.append(
|
| 68 |
+
{
|
| 69 |
+
"text": ent.get("word", ""),
|
| 70 |
+
"type": ent.get("entity_group", ent.get("entity", "")),
|
| 71 |
+
"start": int(ent.get("start", 0)),
|
| 72 |
+
"end": int(ent.get("end", 0)),
|
| 73 |
+
"score": round(float(ent.get("score", 0.0)), 4),
|
| 74 |
+
}
|
| 75 |
+
)
|
| 76 |
+
return entities
|
| 77 |
+
|
| 78 |
+
|
| 79 |
+
def is_ready() -> bool:
|
| 80 |
+
"""True if the model is loaded or can be loaded (no fatal error)."""
|
| 81 |
+
return _load_error is None
|
| 82 |
+
|
| 83 |
+
|
| 84 |
+
def model_name() -> str:
|
| 85 |
+
return _MODEL_NAME
|
processors/sentiment.py
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Sentiment analysis.
|
| 3 |
+
|
| 4 |
+
Uses the fine-tuned RoBERTa model published on the Hugging Face Hub
|
| 5 |
+
(``vojmahdal/roberta-sentiment-3labels``) by default - this is the only model
|
| 6 |
+
in the system that was trained (fine-tuned) by the author, as described in
|
| 7 |
+
the methodology. Since V3, callers may instead pick any other text
|
| 8 |
+
classification model from the Hugging Face Hub at request time; that model is
|
| 9 |
+
loaded and cached via ``processors.model_registry``.
|
| 10 |
+
"""
|
| 11 |
+
|
| 12 |
+
from __future__ import annotations
|
| 13 |
+
|
| 14 |
+
from typing import Any
|
| 15 |
+
|
| 16 |
+
from processors import model_registry
|
| 17 |
+
|
| 18 |
+
DEFAULT_MODEL_NAME = "vojmahdal/roberta-sentiment-3labels"
|
| 19 |
+
_pipeline = None
|
| 20 |
+
_load_error: str | None = None
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
def _get_default_pipeline():
|
| 24 |
+
"""Lazy-load the default sentiment pipeline on first call."""
|
| 25 |
+
global _pipeline, _load_error
|
| 26 |
+
if _pipeline is not None or _load_error is not None:
|
| 27 |
+
return _pipeline
|
| 28 |
+
|
| 29 |
+
try:
|
| 30 |
+
from transformers import pipeline
|
| 31 |
+
|
| 32 |
+
_pipeline = pipeline(
|
| 33 |
+
"sentiment-analysis",
|
| 34 |
+
model=DEFAULT_MODEL_NAME,
|
| 35 |
+
tokenizer=DEFAULT_MODEL_NAME,
|
| 36 |
+
)
|
| 37 |
+
print(f"[sentiment] Loaded model {DEFAULT_MODEL_NAME}.")
|
| 38 |
+
except Exception as e: # pragma: no cover
|
| 39 |
+
_load_error = str(e)
|
| 40 |
+
print(f"[sentiment] Failed to load model: {e}")
|
| 41 |
+
return _pipeline
|
| 42 |
+
|
| 43 |
+
|
| 44 |
+
def analyze_sentiment(text: str, model_id: str | None = None) -> dict[str, Any]:
|
| 45 |
+
"""
|
| 46 |
+
Return {"label": <positive|neutral|negative>, "score": <confidence>}.
|
| 47 |
+
|
| 48 |
+
``model_id`` optionally selects a different Hugging Face Hub model
|
| 49 |
+
(loaded/cached on demand via ``model_registry``) instead of the default
|
| 50 |
+
fine-tuned model. Raises ``RuntimeError`` if that model cannot be loaded,
|
| 51 |
+
so the API layer can turn it into a clean 400 response.
|
| 52 |
+
"""
|
| 53 |
+
if not isinstance(text, str) or not text.strip():
|
| 54 |
+
return {"label": None, "score": 0.0}
|
| 55 |
+
|
| 56 |
+
if model_id and model_id != DEFAULT_MODEL_NAME:
|
| 57 |
+
clf = model_registry.get_pipeline(model_id, task="sentiment-analysis")
|
| 58 |
+
else:
|
| 59 |
+
clf = _get_default_pipeline()
|
| 60 |
+
|
| 61 |
+
if clf is None:
|
| 62 |
+
return {"label": None, "score": 0.0}
|
| 63 |
+
|
| 64 |
+
try:
|
| 65 |
+
result = clf(text)[0]
|
| 66 |
+
return {
|
| 67 |
+
"label": result["label"],
|
| 68 |
+
"score": round(float(result["score"]), 4),
|
| 69 |
+
}
|
| 70 |
+
except Exception as e: # pragma: no cover
|
| 71 |
+
print(f"[sentiment] Inference failed: {e}")
|
| 72 |
+
return {"label": None, "score": 0.0}
|
| 73 |
+
|
| 74 |
+
|
| 75 |
+
def is_ready() -> bool:
|
| 76 |
+
return _load_error is None
|
| 77 |
+
|
| 78 |
+
|
| 79 |
+
def model_name() -> str:
|
| 80 |
+
return DEFAULT_MODEL_NAME
|
processors/topics.py
ADDED
|
@@ -0,0 +1,99 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Topic classification (zero-shot).
|
| 3 |
+
|
| 4 |
+
Uses ``facebook/bart-large-mnli`` via the Hugging Face
|
| 5 |
+
``zero-shot-classification`` pipeline. The task is framed as Natural Language
|
| 6 |
+
Inference: each candidate label becomes a hypothesis ("This text is about X.")
|
| 7 |
+
and the model scores entailment.
|
| 8 |
+
|
| 9 |
+
No training data is required and the candidate labels can be changed at runtime,
|
| 10 |
+
which suits customer-support conversations where annotated topic datasets are
|
| 11 |
+
usually not available.
|
| 12 |
+
"""
|
| 13 |
+
|
| 14 |
+
from __future__ import annotations
|
| 15 |
+
|
| 16 |
+
from typing import Any
|
| 17 |
+
|
| 18 |
+
_MODEL_NAME = "facebook/bart-large-mnli"
|
| 19 |
+
|
| 20 |
+
# Default candidate topics for a customer-support domain.
|
| 21 |
+
DEFAULT_LABELS = [
|
| 22 |
+
"billing and payments",
|
| 23 |
+
"technical issue",
|
| 24 |
+
"complaint",
|
| 25 |
+
"product question",
|
| 26 |
+
"account management",
|
| 27 |
+
"cancellation",
|
| 28 |
+
"delivery and shipping",
|
| 29 |
+
"praise and positive feedback",
|
| 30 |
+
]
|
| 31 |
+
|
| 32 |
+
_pipeline = None
|
| 33 |
+
_load_error: str | None = None
|
| 34 |
+
|
| 35 |
+
|
| 36 |
+
def _get_pipeline():
|
| 37 |
+
"""Lazy-load the zero-shot pipeline on first call."""
|
| 38 |
+
global _pipeline, _load_error
|
| 39 |
+
if _pipeline is not None or _load_error is not None:
|
| 40 |
+
return _pipeline
|
| 41 |
+
|
| 42 |
+
try:
|
| 43 |
+
from transformers import pipeline
|
| 44 |
+
|
| 45 |
+
_pipeline = pipeline(
|
| 46 |
+
"zero-shot-classification",
|
| 47 |
+
model=_MODEL_NAME,
|
| 48 |
+
)
|
| 49 |
+
print(f"[topics] Loaded model {_MODEL_NAME}.")
|
| 50 |
+
except Exception as e: # pragma: no cover
|
| 51 |
+
_load_error = str(e)
|
| 52 |
+
print(f"[topics] Failed to load model: {e}")
|
| 53 |
+
return _pipeline
|
| 54 |
+
|
| 55 |
+
|
| 56 |
+
def classify_topic(
|
| 57 |
+
text: str,
|
| 58 |
+
labels: list[str] | None = None,
|
| 59 |
+
top_k: int = 3,
|
| 60 |
+
) -> dict[str, Any]:
|
| 61 |
+
"""
|
| 62 |
+
Classify ``text`` into one of ``labels``.
|
| 63 |
+
|
| 64 |
+
Returns:
|
| 65 |
+
{
|
| 66 |
+
"topic": <best label or None>,
|
| 67 |
+
"score": <confidence of best label>,
|
| 68 |
+
"all": [{"label", "score"}, ...] # top_k candidates
|
| 69 |
+
}
|
| 70 |
+
"""
|
| 71 |
+
if not isinstance(text, str) or not text.strip():
|
| 72 |
+
return {"topic": None, "score": 0.0, "all": []}
|
| 73 |
+
|
| 74 |
+
candidate_labels = labels or DEFAULT_LABELS
|
| 75 |
+
clf = _get_pipeline()
|
| 76 |
+
if clf is None:
|
| 77 |
+
return {"topic": None, "score": 0.0, "all": []}
|
| 78 |
+
|
| 79 |
+
try:
|
| 80 |
+
result = clf(text, candidate_labels, multi_label=False)
|
| 81 |
+
except Exception as e: # pragma: no cover
|
| 82 |
+
print(f"[topics] Inference failed: {e}")
|
| 83 |
+
return {"topic": None, "score": 0.0, "all": []}
|
| 84 |
+
|
| 85 |
+
pairs = list(zip(result["labels"], result["scores"]))
|
| 86 |
+
top = pairs[:top_k]
|
| 87 |
+
return {
|
| 88 |
+
"topic": pairs[0][0] if pairs else None,
|
| 89 |
+
"score": round(float(pairs[0][1]), 4) if pairs else 0.0,
|
| 90 |
+
"all": [{"label": l, "score": round(float(s), 4)} for l, s in top],
|
| 91 |
+
}
|
| 92 |
+
|
| 93 |
+
|
| 94 |
+
def is_ready() -> bool:
|
| 95 |
+
return _load_error is None
|
| 96 |
+
|
| 97 |
+
|
| 98 |
+
def model_name() -> str:
|
| 99 |
+
return _MODEL_NAME
|
requirements-dev.txt
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Extra dependencies for offline/dev scripts only (not needed to run the API).
|
| 2 |
+
# prepare_dataset.py
|
| 3 |
+
pandas
|
| 4 |
+
# evaluate.py (NER metrics)
|
| 5 |
+
seqeval
|
requirements.txt
CHANGED
|
@@ -1,4 +1,8 @@
|
|
| 1 |
-
fastapi
|
| 2 |
-
uvicorn[standard]
|
| 3 |
-
|
| 4 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
fastapi
|
| 2 |
+
uvicorn[standard]
|
| 3 |
+
python-multipart
|
| 4 |
+
transformers
|
| 5 |
+
torch
|
| 6 |
+
presidio-analyzer
|
| 7 |
+
presidio-anonymizer
|
| 8 |
+
spacy
|
sample_chats.csv
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
conversation_id,speaker,text
|
| 2 |
+
1,user,"Hi, my name is Sarah Johnson and my order #4521 never arrived. This is unacceptable!"
|
| 3 |
+
1,agent,"I'm very sorry about that Sarah. Can you confirm your email so I can look into it?"
|
| 4 |
+
1,user,"Sure, it's sarah.johnson@example.com and my phone is +1 202 555 0143"
|
| 5 |
+
2,user,"The new app update is fantastic, everything works so smoothly now. Great job!"
|
| 6 |
+
3,user,"I want to cancel my subscription. I've been charged twice this month."
|
| 7 |
+
3,agent,"I understand, let me check the billing for your account."
|
| 8 |
+
4,user,"Can you tell me if the Pro plan supports more than 5 users?"
|
| 9 |
+
5,user,"My internet keeps disconnecting every few minutes since yesterday."
|
| 10 |
+
6,user,"Thank you so much for the quick refund, Michael was very helpful."
|
| 11 |
+
7,user,"Where is my package? It was supposed to arrive in London three days ago."
|
static/app.js
CHANGED
|
@@ -1,25 +1,85 @@
|
|
| 1 |
const textEl = document.getElementById("text");
|
|
|
|
|
|
|
| 2 |
const analyzeBtn = document.getElementById("analyzeBtn");
|
| 3 |
const clearBtn = document.getElementById("clearBtn");
|
| 4 |
const statusEl = document.getElementById("status");
|
| 5 |
const resultEl = document.getElementById("result");
|
| 6 |
-
const
|
| 7 |
-
const
|
|
|
|
|
|
|
| 8 |
const errorEl = document.getElementById("error");
|
| 9 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 10 |
function setStatus(msg) {
|
| 11 |
statusEl.textContent = msg || "";
|
| 12 |
}
|
| 13 |
-
|
| 14 |
function showError(msg) {
|
| 15 |
errorEl.textContent = msg;
|
| 16 |
errorEl.hidden = !msg;
|
| 17 |
}
|
| 18 |
|
| 19 |
-
function
|
| 20 |
-
|
| 21 |
-
|
| 22 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 23 |
resultEl.hidden = false;
|
| 24 |
}
|
| 25 |
|
|
@@ -31,31 +91,29 @@ function resetOutput() {
|
|
| 31 |
|
| 32 |
async function analyze() {
|
| 33 |
resetOutput();
|
| 34 |
-
|
| 35 |
const text = (textEl.value || "").trim();
|
| 36 |
if (!text) {
|
| 37 |
-
showError("
|
| 38 |
return;
|
| 39 |
}
|
| 40 |
|
| 41 |
analyzeBtn.disabled = true;
|
| 42 |
setStatus("Analyzing…");
|
| 43 |
|
|
|
|
|
|
|
| 44 |
try {
|
| 45 |
-
const res = await fetch("/
|
| 46 |
method: "POST",
|
| 47 |
headers: { "Content-Type": "application/json" },
|
| 48 |
-
body: JSON.stringify({ text }),
|
| 49 |
});
|
| 50 |
-
|
| 51 |
const data = await res.json().catch(() => null);
|
| 52 |
-
|
| 53 |
if (!res.ok) {
|
| 54 |
const detail = data?.detail ? `\n\n${JSON.stringify(data.detail)}` : "";
|
| 55 |
-
throw new Error(`
|
| 56 |
}
|
| 57 |
-
|
| 58 |
-
showResult(data);
|
| 59 |
setStatus("Done.");
|
| 60 |
} catch (e) {
|
| 61 |
showError(e?.message || String(e));
|
|
@@ -65,13 +123,49 @@ async function analyze() {
|
|
| 65 |
}
|
| 66 |
}
|
| 67 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 68 |
analyzeBtn.addEventListener("click", analyze);
|
| 69 |
clearBtn.addEventListener("click", () => {
|
| 70 |
textEl.value = "";
|
| 71 |
resetOutput();
|
| 72 |
textEl.focus();
|
| 73 |
});
|
| 74 |
-
|
| 75 |
textEl.addEventListener("keydown", (e) => {
|
| 76 |
if ((e.ctrlKey || e.metaKey) && e.key === "Enter") analyze();
|
| 77 |
});
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
const textEl = document.getElementById("text");
|
| 2 |
+
const sentimentModelEl = document.getElementById("sentimentModel");
|
| 3 |
+
const modelSuggestionsEl = document.getElementById("modelSuggestions");
|
| 4 |
const analyzeBtn = document.getElementById("analyzeBtn");
|
| 5 |
const clearBtn = document.getElementById("clearBtn");
|
| 6 |
const statusEl = document.getElementById("status");
|
| 7 |
const resultEl = document.getElementById("result");
|
| 8 |
+
const sentimentEl = document.getElementById("sentiment");
|
| 9 |
+
const topicEl = document.getElementById("topic");
|
| 10 |
+
const entitiesEl = document.getElementById("entities");
|
| 11 |
+
const anonEl = document.getElementById("anon");
|
| 12 |
const errorEl = document.getElementById("error");
|
| 13 |
|
| 14 |
+
const fileEl = document.getElementById("file");
|
| 15 |
+
const ingestBtn = document.getElementById("ingestBtn");
|
| 16 |
+
const ingestStatusEl = document.getElementById("ingestStatus");
|
| 17 |
+
const ingestErrorEl = document.getElementById("ingestError");
|
| 18 |
+
|
| 19 |
+
async function loadModelSuggestions() {
|
| 20 |
+
try {
|
| 21 |
+
const res = await fetch("/models");
|
| 22 |
+
if (!res.ok) return;
|
| 23 |
+
const data = await res.json();
|
| 24 |
+
modelSuggestionsEl.innerHTML = "";
|
| 25 |
+
for (const modelId of data.suggested || []) {
|
| 26 |
+
const opt = document.createElement("option");
|
| 27 |
+
opt.value = modelId;
|
| 28 |
+
modelSuggestionsEl.appendChild(opt);
|
| 29 |
+
}
|
| 30 |
+
if (!sentimentModelEl.value) {
|
| 31 |
+
sentimentModelEl.value = data.default || "";
|
| 32 |
+
}
|
| 33 |
+
} catch (e) {
|
| 34 |
+
// suggestions are a nicety, not required for the app to work
|
| 35 |
+
}
|
| 36 |
+
}
|
| 37 |
+
|
| 38 |
function setStatus(msg) {
|
| 39 |
statusEl.textContent = msg || "";
|
| 40 |
}
|
|
|
|
| 41 |
function showError(msg) {
|
| 42 |
errorEl.textContent = msg;
|
| 43 |
errorEl.hidden = !msg;
|
| 44 |
}
|
| 45 |
|
| 46 |
+
function sentimentClass(label) {
|
| 47 |
+
const l = (label || "").toLowerCase();
|
| 48 |
+
if (l.includes("pos")) return "pill pos";
|
| 49 |
+
if (l.includes("neg")) return "pill neg";
|
| 50 |
+
return "pill neu";
|
| 51 |
+
}
|
| 52 |
+
|
| 53 |
+
function renderResult(data) {
|
| 54 |
+
// sentiment
|
| 55 |
+
const sLabel = data.sentiment ?? "—";
|
| 56 |
+
const sScore =
|
| 57 |
+
typeof data.sentiment_score === "number"
|
| 58 |
+
? ` (${data.sentiment_score.toFixed(2)})`
|
| 59 |
+
: "";
|
| 60 |
+
sentimentEl.innerHTML = `<span class="${sentimentClass(sLabel)}">${sLabel}${sScore}</span>`;
|
| 61 |
+
|
| 62 |
+
// topic
|
| 63 |
+
const tScore =
|
| 64 |
+
typeof data.topic_score === "number" ? ` (${data.topic_score.toFixed(2)})` : "";
|
| 65 |
+
topicEl.textContent = (data.topic ?? "—") + (data.topic ? tScore : "");
|
| 66 |
+
|
| 67 |
+
// entities
|
| 68 |
+
entitiesEl.innerHTML = "";
|
| 69 |
+
if (Array.isArray(data.entities) && data.entities.length) {
|
| 70 |
+
for (const ent of data.entities) {
|
| 71 |
+
const chip = document.createElement("span");
|
| 72 |
+
chip.className = "chip";
|
| 73 |
+
chip.innerHTML = `<span class="chipType">${ent.type}</span>${ent.text}`;
|
| 74 |
+
entitiesEl.appendChild(chip);
|
| 75 |
+
}
|
| 76 |
+
} else {
|
| 77 |
+
entitiesEl.innerHTML = `<span class="muted">No entities detected</span>`;
|
| 78 |
+
}
|
| 79 |
+
|
| 80 |
+
// anonymized text
|
| 81 |
+
anonEl.textContent = data.anonymized_text ?? "";
|
| 82 |
+
|
| 83 |
resultEl.hidden = false;
|
| 84 |
}
|
| 85 |
|
|
|
|
| 91 |
|
| 92 |
async function analyze() {
|
| 93 |
resetOutput();
|
|
|
|
| 94 |
const text = (textEl.value || "").trim();
|
| 95 |
if (!text) {
|
| 96 |
+
showError("Message cannot be empty.");
|
| 97 |
return;
|
| 98 |
}
|
| 99 |
|
| 100 |
analyzeBtn.disabled = true;
|
| 101 |
setStatus("Analyzing…");
|
| 102 |
|
| 103 |
+
const sentimentModel = (sentimentModelEl.value || "").trim() || null;
|
| 104 |
+
|
| 105 |
try {
|
| 106 |
+
const res = await fetch("/analyze", {
|
| 107 |
method: "POST",
|
| 108 |
headers: { "Content-Type": "application/json" },
|
| 109 |
+
body: JSON.stringify({ text, sentiment_model: sentimentModel }),
|
| 110 |
});
|
|
|
|
| 111 |
const data = await res.json().catch(() => null);
|
|
|
|
| 112 |
if (!res.ok) {
|
| 113 |
const detail = data?.detail ? `\n\n${JSON.stringify(data.detail)}` : "";
|
| 114 |
+
throw new Error(`API error (${res.status} ${res.statusText}).${detail}`);
|
| 115 |
}
|
| 116 |
+
renderResult(data);
|
|
|
|
| 117 |
setStatus("Done.");
|
| 118 |
} catch (e) {
|
| 119 |
showError(e?.message || String(e));
|
|
|
|
| 123 |
}
|
| 124 |
}
|
| 125 |
|
| 126 |
+
async function ingest() {
|
| 127 |
+
ingestErrorEl.hidden = true;
|
| 128 |
+
const file = fileEl.files?.[0];
|
| 129 |
+
if (!file) {
|
| 130 |
+
ingestErrorEl.textContent = "Please choose a CSV or JSON file first.";
|
| 131 |
+
ingestErrorEl.hidden = false;
|
| 132 |
+
return;
|
| 133 |
+
}
|
| 134 |
+
|
| 135 |
+
ingestBtn.disabled = true;
|
| 136 |
+
ingestStatusEl.textContent = "Uploading and processing…";
|
| 137 |
+
|
| 138 |
+
try {
|
| 139 |
+
const form = new FormData();
|
| 140 |
+
form.append("file", file);
|
| 141 |
+
const sentimentModel = (sentimentModelEl.value || "").trim();
|
| 142 |
+
if (sentimentModel) form.append("sentiment_model", sentimentModel);
|
| 143 |
+
const res = await fetch("/ingest", { method: "POST", body: form });
|
| 144 |
+
const data = await res.json().catch(() => null);
|
| 145 |
+
if (!res.ok) {
|
| 146 |
+
const detail = data?.detail ? `: ${JSON.stringify(data.detail)}` : "";
|
| 147 |
+
throw new Error(`API error (${res.status})${detail}`);
|
| 148 |
+
}
|
| 149 |
+
const note = data.truncated ? " (batch truncated to 200)" : "";
|
| 150 |
+
ingestStatusEl.textContent = `Processed ${data.processed}, stored ${data.stored}${note}. See Records.`;
|
| 151 |
+
} catch (e) {
|
| 152 |
+
ingestErrorEl.textContent = e?.message || String(e);
|
| 153 |
+
ingestErrorEl.hidden = false;
|
| 154 |
+
ingestStatusEl.textContent = "";
|
| 155 |
+
} finally {
|
| 156 |
+
ingestBtn.disabled = false;
|
| 157 |
+
}
|
| 158 |
+
}
|
| 159 |
+
|
| 160 |
analyzeBtn.addEventListener("click", analyze);
|
| 161 |
clearBtn.addEventListener("click", () => {
|
| 162 |
textEl.value = "";
|
| 163 |
resetOutput();
|
| 164 |
textEl.focus();
|
| 165 |
});
|
|
|
|
| 166 |
textEl.addEventListener("keydown", (e) => {
|
| 167 |
if ((e.ctrlKey || e.metaKey) && e.key === "Enter") analyze();
|
| 168 |
});
|
| 169 |
+
ingestBtn.addEventListener("click", ingest);
|
| 170 |
+
|
| 171 |
+
loadModelSuggestions();
|
static/index.html
CHANGED
|
@@ -1,27 +1,39 @@
|
|
| 1 |
<!doctype html>
|
| 2 |
-
<html lang="
|
| 3 |
<head>
|
| 4 |
<meta charset="utf-8" />
|
| 5 |
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
| 6 |
-
<title>
|
| 7 |
<link rel="stylesheet" href="/static/styles.css" />
|
| 8 |
</head>
|
| 9 |
<body>
|
| 10 |
<main class="container">
|
| 11 |
<header class="header">
|
| 12 |
-
<h1>
|
| 13 |
<p class="sub">
|
| 14 |
-
|
|
|
|
| 15 |
</p>
|
| 16 |
</header>
|
| 17 |
|
|
|
|
| 18 |
<section class="card">
|
| 19 |
-
<label class="label" for="text">
|
| 20 |
<textarea
|
| 21 |
id="text"
|
| 22 |
-
rows="
|
| 23 |
-
placeholder="Write
|
| 24 |
-
>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 25 |
|
| 26 |
<div class="row">
|
| 27 |
<button id="analyzeBtn" class="btn">Analyze</button>
|
|
@@ -31,24 +43,48 @@
|
|
| 31 |
|
| 32 |
<div id="result" class="result" hidden>
|
| 33 |
<div class="resultRow">
|
| 34 |
-
<span class="k">
|
| 35 |
-
<span id="
|
| 36 |
</div>
|
| 37 |
<div class="resultRow">
|
| 38 |
-
<span class="k">
|
| 39 |
-
<span id="
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 40 |
</div>
|
| 41 |
</div>
|
| 42 |
|
| 43 |
<pre id="error" class="error" hidden></pre>
|
| 44 |
</section>
|
| 45 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 46 |
<footer class="footer">
|
| 47 |
<a href="/docs">API documentation</a>
|
| 48 |
<span class="dot">•</span>
|
| 49 |
<a href="/health">Health</a>
|
| 50 |
<span class="dot">•</span>
|
| 51 |
-
<a href="/static/
|
| 52 |
</footer>
|
| 53 |
</main>
|
| 54 |
|
|
|
|
| 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>Conversation Data Extraction</title>
|
| 7 |
<link rel="stylesheet" href="/static/styles.css" />
|
| 8 |
</head>
|
| 9 |
<body>
|
| 10 |
<main class="container">
|
| 11 |
<header class="header">
|
| 12 |
+
<h1>Conversation Data Extraction</h1>
|
| 13 |
<p class="sub">
|
| 14 |
+
Extracts named entities, topics and sentiment from chat messages,
|
| 15 |
+
pseudonymizes personal data (GDPR) and stores structured results.
|
| 16 |
</p>
|
| 17 |
</header>
|
| 18 |
|
| 19 |
+
<!-- Single message analysis -->
|
| 20 |
<section class="card">
|
| 21 |
+
<label class="label" for="text">Message</label>
|
| 22 |
<textarea
|
| 23 |
id="text"
|
| 24 |
+
rows="5"
|
| 25 |
+
placeholder="Write a message to analyze…"
|
| 26 |
+
>Hi, my name is John Smith and I'm really unhappy. My order never arrived. Email me at john.smith@example.com</textarea>
|
| 27 |
+
|
| 28 |
+
<label class="label" for="sentimentModel">Sentiment model (Hugging Face Hub)</label>
|
| 29 |
+
<input
|
| 30 |
+
id="sentimentModel"
|
| 31 |
+
class="modelInput"
|
| 32 |
+
type="text"
|
| 33 |
+
list="modelSuggestions"
|
| 34 |
+
placeholder="vojmahdal/roberta-sentiment-3labels"
|
| 35 |
+
/>
|
| 36 |
+
<datalist id="modelSuggestions"></datalist>
|
| 37 |
|
| 38 |
<div class="row">
|
| 39 |
<button id="analyzeBtn" class="btn">Analyze</button>
|
|
|
|
| 43 |
|
| 44 |
<div id="result" class="result" hidden>
|
| 45 |
<div class="resultRow">
|
| 46 |
+
<span class="k">Sentiment</span>
|
| 47 |
+
<span id="sentiment" class="v"></span>
|
| 48 |
</div>
|
| 49 |
<div class="resultRow">
|
| 50 |
+
<span class="k">Topic</span>
|
| 51 |
+
<span id="topic" class="v"></span>
|
| 52 |
+
</div>
|
| 53 |
+
<div class="resultRow column">
|
| 54 |
+
<span class="k">Entities</span>
|
| 55 |
+
<div id="entities" class="chips"></div>
|
| 56 |
+
</div>
|
| 57 |
+
<div class="resultRow column">
|
| 58 |
+
<span class="k">Anonymized text</span>
|
| 59 |
+
<code id="anon" class="anon"></code>
|
| 60 |
</div>
|
| 61 |
</div>
|
| 62 |
|
| 63 |
<pre id="error" class="error" hidden></pre>
|
| 64 |
</section>
|
| 65 |
|
| 66 |
+
<!-- Batch ingest -->
|
| 67 |
+
<section class="card">
|
| 68 |
+
<h2 class="cardTitle">Batch ingest</h2>
|
| 69 |
+
<p class="sub small">
|
| 70 |
+
Upload a CSV or JSON file of conversations. Each message is processed by
|
| 71 |
+
the full pipeline and stored (anonymized). CSV should have a
|
| 72 |
+
<code>text</code> or <code>message</code> column.
|
| 73 |
+
</p>
|
| 74 |
+
<div class="row">
|
| 75 |
+
<input type="file" id="file" accept=".csv,.json,.tsv,.txt" class="file" />
|
| 76 |
+
<button id="ingestBtn" class="btn" type="button">Ingest file</button>
|
| 77 |
+
<span id="ingestStatus" class="status" aria-live="polite"></span>
|
| 78 |
+
</div>
|
| 79 |
+
<pre id="ingestError" class="error" hidden></pre>
|
| 80 |
+
</section>
|
| 81 |
+
|
| 82 |
<footer class="footer">
|
| 83 |
<a href="/docs">API documentation</a>
|
| 84 |
<span class="dot">•</span>
|
| 85 |
<a href="/health">Health</a>
|
| 86 |
<span class="dot">•</span>
|
| 87 |
+
<a href="/static/records.html">Records</a>
|
| 88 |
</footer>
|
| 89 |
</main>
|
| 90 |
|
static/records.html
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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>Stored Records</title>
|
| 7 |
+
<link rel="stylesheet" href="/static/styles.css" />
|
| 8 |
+
</head>
|
| 9 |
+
<body>
|
| 10 |
+
<main class="container">
|
| 11 |
+
<header class="header">
|
| 12 |
+
<h1>Stored Records</h1>
|
| 13 |
+
<p class="sub">
|
| 14 |
+
Anonymized records produced by the pipeline. Original text is never
|
| 15 |
+
stored, only its hash and the anonymized version.
|
| 16 |
+
</p>
|
| 17 |
+
</header>
|
| 18 |
+
|
| 19 |
+
<section class="card">
|
| 20 |
+
<div id="stats" class="stats"></div>
|
| 21 |
+
</section>
|
| 22 |
+
|
| 23 |
+
<section class="card">
|
| 24 |
+
<div class="row">
|
| 25 |
+
<button id="reloadBtn" class="btn" type="button">Reload</button>
|
| 26 |
+
<a id="exportXmlBtn" class="btn secondary" href="/records/export.xml" download="records.xml">Export XML</a>
|
| 27 |
+
<span id="status" class="status" aria-live="polite"></span>
|
| 28 |
+
</div>
|
| 29 |
+
|
| 30 |
+
<div id="empty" class="sub" style="margin-top: 12px">
|
| 31 |
+
No records yet. Analyze a message or ingest a file first.
|
| 32 |
+
</div>
|
| 33 |
+
|
| 34 |
+
<div id="tableWrapper" class="result" hidden>
|
| 35 |
+
<table class="log-table">
|
| 36 |
+
<thead>
|
| 37 |
+
<tr>
|
| 38 |
+
<th>Time (UTC)</th>
|
| 39 |
+
<th>Anonymized text</th>
|
| 40 |
+
<th>Topic</th>
|
| 41 |
+
<th>Sentiment</th>
|
| 42 |
+
<th>Entities</th>
|
| 43 |
+
<th>Source</th>
|
| 44 |
+
</tr>
|
| 45 |
+
</thead>
|
| 46 |
+
<tbody id="logBody"></tbody>
|
| 47 |
+
</table>
|
| 48 |
+
</div>
|
| 49 |
+
|
| 50 |
+
<pre id="error" class="error" hidden></pre>
|
| 51 |
+
</section>
|
| 52 |
+
|
| 53 |
+
<footer class="footer">
|
| 54 |
+
<a href="/">Back to main page</a>
|
| 55 |
+
<span class="dot">•</span>
|
| 56 |
+
<a href="/docs">API documentation</a>
|
| 57 |
+
</footer>
|
| 58 |
+
</main>
|
| 59 |
+
|
| 60 |
+
<script src="/static/records.js"></script>
|
| 61 |
+
</body>
|
| 62 |
+
</html>
|
static/records.js
ADDED
|
@@ -0,0 +1,114 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
const statusEl = document.getElementById("status");
|
| 2 |
+
const errorEl = document.getElementById("error");
|
| 3 |
+
const emptyEl = document.getElementById("empty");
|
| 4 |
+
const tableWrapper = document.getElementById("tableWrapper");
|
| 5 |
+
const bodyEl = document.getElementById("logBody");
|
| 6 |
+
const reloadBtn = document.getElementById("reloadBtn");
|
| 7 |
+
const statsEl = document.getElementById("stats");
|
| 8 |
+
|
| 9 |
+
function setStatus(msg) {
|
| 10 |
+
statusEl.textContent = msg || "";
|
| 11 |
+
}
|
| 12 |
+
function showError(msg) {
|
| 13 |
+
errorEl.textContent = msg;
|
| 14 |
+
errorEl.hidden = !msg;
|
| 15 |
+
}
|
| 16 |
+
|
| 17 |
+
function sentimentClass(label) {
|
| 18 |
+
const l = (label || "").toLowerCase();
|
| 19 |
+
if (l.includes("pos")) return "pill pos";
|
| 20 |
+
if (l.includes("neg")) return "pill neg";
|
| 21 |
+
return "pill neu";
|
| 22 |
+
}
|
| 23 |
+
|
| 24 |
+
function renderStats(stats) {
|
| 25 |
+
if (!stats || !stats.total) {
|
| 26 |
+
statsEl.innerHTML = `<span class="muted">No statistics yet.</span>`;
|
| 27 |
+
return;
|
| 28 |
+
}
|
| 29 |
+
const sentiments = Object.entries(stats.by_sentiment || {})
|
| 30 |
+
.map(([k, v]) => `<span class="${sentimentClass(k)}">${k}: ${v}</span>`)
|
| 31 |
+
.join(" ");
|
| 32 |
+
const topics = Object.entries(stats.by_topic || {})
|
| 33 |
+
.map(([k, v]) => `<span class="chip">${k}: ${v}</span>`)
|
| 34 |
+
.join(" ");
|
| 35 |
+
statsEl.innerHTML = `
|
| 36 |
+
<div class="statsRow"><span class="k">Total records</span><span class="v">${stats.total}</span></div>
|
| 37 |
+
<div class="statsRow column"><span class="k">By sentiment</span><div class="chips">${sentiments}</div></div>
|
| 38 |
+
<div class="statsRow column"><span class="k">Top topics</span><div class="chips">${topics}</div></div>
|
| 39 |
+
`;
|
| 40 |
+
}
|
| 41 |
+
|
| 42 |
+
function renderRows(items) {
|
| 43 |
+
bodyEl.innerHTML = "";
|
| 44 |
+
for (const item of items) {
|
| 45 |
+
const tr = document.createElement("tr");
|
| 46 |
+
|
| 47 |
+
const tdTime = document.createElement("td");
|
| 48 |
+
tdTime.textContent = item.created_at || "";
|
| 49 |
+
tr.appendChild(tdTime);
|
| 50 |
+
|
| 51 |
+
const tdText = document.createElement("td");
|
| 52 |
+
tdText.textContent = item.anonymized_text || "";
|
| 53 |
+
tr.appendChild(tdText);
|
| 54 |
+
|
| 55 |
+
const tdTopic = document.createElement("td");
|
| 56 |
+
tdTopic.textContent = item.topic || "—";
|
| 57 |
+
tr.appendChild(tdTopic);
|
| 58 |
+
|
| 59 |
+
const tdSent = document.createElement("td");
|
| 60 |
+
const sLabel = item.sentiment || "—";
|
| 61 |
+
tdSent.innerHTML = `<span class="${sentimentClass(sLabel)}">${sLabel}</span>`;
|
| 62 |
+
tr.appendChild(tdSent);
|
| 63 |
+
|
| 64 |
+
const tdEnt = document.createElement("td");
|
| 65 |
+
const ents = Array.isArray(item.entities) ? item.entities : [];
|
| 66 |
+
tdEnt.textContent = ents.length
|
| 67 |
+
? ents.map((e) => `${e.type}:${e.text}`).join(", ")
|
| 68 |
+
: "—";
|
| 69 |
+
tr.appendChild(tdEnt);
|
| 70 |
+
|
| 71 |
+
const tdSrc = document.createElement("td");
|
| 72 |
+
tdSrc.textContent = item.source || "";
|
| 73 |
+
tr.appendChild(tdSrc);
|
| 74 |
+
|
| 75 |
+
bodyEl.appendChild(tr);
|
| 76 |
+
}
|
| 77 |
+
}
|
| 78 |
+
|
| 79 |
+
async function load() {
|
| 80 |
+
showError("");
|
| 81 |
+
setStatus("Loading…");
|
| 82 |
+
try {
|
| 83 |
+
const [recRes, statRes] = await Promise.all([
|
| 84 |
+
fetch("/records?limit=100"),
|
| 85 |
+
fetch("/stats"),
|
| 86 |
+
]);
|
| 87 |
+
const records = await recRes.json().catch(() => null);
|
| 88 |
+
const stats = await statRes.json().catch(() => null);
|
| 89 |
+
|
| 90 |
+
if (!recRes.ok) {
|
| 91 |
+
throw new Error(`API error (${recRes.status} ${recRes.statusText})`);
|
| 92 |
+
}
|
| 93 |
+
|
| 94 |
+
renderStats(stats);
|
| 95 |
+
|
| 96 |
+
if (!Array.isArray(records) || records.length === 0) {
|
| 97 |
+
emptyEl.hidden = false;
|
| 98 |
+
tableWrapper.hidden = true;
|
| 99 |
+
setStatus("No records yet.");
|
| 100 |
+
return;
|
| 101 |
+
}
|
| 102 |
+
|
| 103 |
+
renderRows(records);
|
| 104 |
+
emptyEl.hidden = true;
|
| 105 |
+
tableWrapper.hidden = false;
|
| 106 |
+
setStatus(`Loaded ${records.length} records.`);
|
| 107 |
+
} catch (e) {
|
| 108 |
+
showError(e?.message || String(e));
|
| 109 |
+
setStatus("");
|
| 110 |
+
}
|
| 111 |
+
}
|
| 112 |
+
|
| 113 |
+
reloadBtn.addEventListener("click", load);
|
| 114 |
+
load();
|
static/styles.css
CHANGED
|
@@ -190,3 +190,111 @@ textarea:focus {
|
|
| 190 |
.log-table tr:last-child td {
|
| 191 |
border-bottom: none;
|
| 192 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 190 |
.log-table tr:last-child td {
|
| 191 |
border-bottom: none;
|
| 192 |
}
|
| 193 |
+
|
| 194 |
+
/* ---- Extended components for the extraction dashboard ---- */
|
| 195 |
+
.cardTitle {
|
| 196 |
+
margin: 0 0 6px;
|
| 197 |
+
font-size: 18px;
|
| 198 |
+
}
|
| 199 |
+
.sub.small {
|
| 200 |
+
font-size: 13px;
|
| 201 |
+
margin-bottom: 12px;
|
| 202 |
+
}
|
| 203 |
+
.card + .card {
|
| 204 |
+
margin-top: 16px;
|
| 205 |
+
}
|
| 206 |
+
.resultRow.column,
|
| 207 |
+
.statsRow.column {
|
| 208 |
+
flex-direction: column;
|
| 209 |
+
align-items: flex-start;
|
| 210 |
+
gap: 8px;
|
| 211 |
+
}
|
| 212 |
+
.chips {
|
| 213 |
+
display: flex;
|
| 214 |
+
flex-wrap: wrap;
|
| 215 |
+
gap: 8px;
|
| 216 |
+
}
|
| 217 |
+
.chip {
|
| 218 |
+
display: inline-flex;
|
| 219 |
+
align-items: center;
|
| 220 |
+
gap: 6px;
|
| 221 |
+
padding: 4px 10px;
|
| 222 |
+
border-radius: 999px;
|
| 223 |
+
background: rgba(255, 255, 255, 0.08);
|
| 224 |
+
border: 1px solid var(--border);
|
| 225 |
+
font-size: 13px;
|
| 226 |
+
}
|
| 227 |
+
.chipType {
|
| 228 |
+
font-size: 11px;
|
| 229 |
+
font-weight: 700;
|
| 230 |
+
color: var(--accent2);
|
| 231 |
+
text-transform: uppercase;
|
| 232 |
+
letter-spacing: 0.3px;
|
| 233 |
+
}
|
| 234 |
+
.pill {
|
| 235 |
+
display: inline-block;
|
| 236 |
+
padding: 3px 12px;
|
| 237 |
+
border-radius: 999px;
|
| 238 |
+
font-weight: 700;
|
| 239 |
+
font-size: 13px;
|
| 240 |
+
}
|
| 241 |
+
.pill.pos {
|
| 242 |
+
background: rgba(34, 197, 94, 0.18);
|
| 243 |
+
color: #4ade80;
|
| 244 |
+
border: 1px solid rgba(34, 197, 94, 0.4);
|
| 245 |
+
}
|
| 246 |
+
.pill.neg {
|
| 247 |
+
background: rgba(239, 68, 68, 0.18);
|
| 248 |
+
color: #f87171;
|
| 249 |
+
border: 1px solid rgba(239, 68, 68, 0.4);
|
| 250 |
+
}
|
| 251 |
+
.pill.neu {
|
| 252 |
+
background: rgba(148, 163, 184, 0.18);
|
| 253 |
+
color: #cbd5e1;
|
| 254 |
+
border: 1px solid rgba(148, 163, 184, 0.4);
|
| 255 |
+
}
|
| 256 |
+
.anon {
|
| 257 |
+
display: block;
|
| 258 |
+
width: 100%;
|
| 259 |
+
padding: 10px 12px;
|
| 260 |
+
border-radius: 10px;
|
| 261 |
+
background: rgba(0, 0, 0, 0.3);
|
| 262 |
+
border: 1px solid var(--border);
|
| 263 |
+
font-size: 13px;
|
| 264 |
+
white-space: pre-wrap;
|
| 265 |
+
word-break: break-word;
|
| 266 |
+
}
|
| 267 |
+
.muted {
|
| 268 |
+
color: var(--muted);
|
| 269 |
+
font-size: 13px;
|
| 270 |
+
}
|
| 271 |
+
.file {
|
| 272 |
+
color: var(--muted);
|
| 273 |
+
font-size: 13px;
|
| 274 |
+
max-width: 280px;
|
| 275 |
+
}
|
| 276 |
+
.stats {
|
| 277 |
+
display: flex;
|
| 278 |
+
flex-direction: column;
|
| 279 |
+
gap: 12px;
|
| 280 |
+
}
|
| 281 |
+
.statsRow {
|
| 282 |
+
display: flex;
|
| 283 |
+
justify-content: space-between;
|
| 284 |
+
gap: 12px;
|
| 285 |
+
}
|
| 286 |
+
.modelInput {
|
| 287 |
+
width: 100%;
|
| 288 |
+
margin-top: 10px;
|
| 289 |
+
border-radius: 12px;
|
| 290 |
+
border: 1px solid var(--border);
|
| 291 |
+
background: rgba(0, 0, 0, 0.25);
|
| 292 |
+
color: var(--text);
|
| 293 |
+
padding: 10px 12px;
|
| 294 |
+
font-size: 13px;
|
| 295 |
+
outline: none;
|
| 296 |
+
}
|
| 297 |
+
.modelInput:focus {
|
| 298 |
+
border-color: rgba(124, 58, 237, 0.55);
|
| 299 |
+
box-shadow: 0 0 0 3px rgba(124, 58, 237, 0.22);
|
| 300 |
+
}
|
upload.py
CHANGED
|
@@ -2,17 +2,17 @@ from huggingface_hub import HfApi
|
|
| 2 |
|
| 3 |
api = HfApi()
|
| 4 |
|
| 5 |
-
|
| 6 |
repo_id = "vojmahdal/roberta-sentiment-3labels"
|
| 7 |
|
| 8 |
-
print("
|
| 9 |
|
| 10 |
api.upload_folder(
|
| 11 |
-
folder_path="./sentiment_analysis_model", #
|
| 12 |
repo_id=repo_id,
|
| 13 |
repo_type="model",
|
| 14 |
-
|
| 15 |
ignore_patterns=["checkpoint-*", ".venv/*", "upload.py", ".git*"]
|
| 16 |
)
|
| 17 |
|
| 18 |
-
print(f"
|
|
|
|
| 2 |
|
| 3 |
api = HfApi()
|
| 4 |
|
| 5 |
+
# Tady vyplň své údaje
|
| 6 |
repo_id = "vojmahdal/roberta-sentiment-3labels"
|
| 7 |
|
| 8 |
+
print("Nahrávám model na Hugging Face... Může to trvat několik minut.")
|
| 9 |
|
| 10 |
api.upload_folder(
|
| 11 |
+
folder_path="./sentiment_analysis_model", # Nahrát vše v aktuální složce
|
| 12 |
repo_id=repo_id,
|
| 13 |
repo_type="model",
|
| 14 |
+
# Ignorujeme zbytečnosti, co nechceme na internetu
|
| 15 |
ignore_patterns=["checkpoint-*", ".venv/*", "upload.py", ".git*"]
|
| 16 |
)
|
| 17 |
|
| 18 |
+
print(f"Hotovo! Model najdeš na: https://huggingface.co/{repo_id}")
|