vojmahdal commited on
Commit
f390b04
·
verified ·
1 Parent(s): 32a04d6

Upload 28 files

Browse files
.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
- TRANSFORMERS_CACHE=/app/.cache
7
-
8
- WORKDIR /app
9
-
10
- # System deps (git, curl; plus basic build tools for torch if needed)
11
- RUN apt-get update && apt-get install -y --no-install-recommends \
12
- git curl build-essential \
13
- && rm -rf /var/lib/apt/lists/*
14
-
15
- COPY requirements.txt ./
16
- RUN pip install --upgrade pip && pip install -r requirements.txt
17
-
18
- # Copy app code + model
19
- COPY . .
20
-
21
- EXPOSE 8000
22
-
23
- CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"]
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: Sentiment Analysis API
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
- import os
2
- import sqlite3
3
- import threading
4
- import hashlib
5
- import re
6
- from datetime import datetime
7
- from pathlib import Path
8
- from typing import Any
9
-
10
-
11
- # ---------------------------------------------------------------------------
12
- # SQLite "database" for logging / anonymized storage
13
- # ---------------------------------------------------------------------------
14
-
15
- if os.name == "nt":
16
- # Windows (locally) – save to project folder
17
- DB_PATH = os.getenv(
18
- "SENTIMENT_DB_PATH",
19
- str((Path(__file__).resolve().parent / "sentiment_logs.db")),
20
- )
21
- else:
22
- # Linux (e.g. Hugging Face Spaces) – /tmp
23
- DB_PATH = os.getenv("SENTIMENT_DB_PATH", "/tmp/sentiment_logs.db")
24
-
25
- _db_lock = threading.Lock()
26
-
27
-
28
- def _get_connection() -> sqlite3.Connection:
29
- """
30
- Get global SQLite connection; creates table on first use.
31
- Uses /tmp by default so it is compatible with Hugging Face Spaces (data may be wiped on restart, which is fine for demo / thesis).
32
- """
33
- conn = sqlite3.connect(DB_PATH, check_same_thread=False)
34
- conn.execute(
35
- """
36
- CREATE TABLE IF NOT EXISTS logs (
37
- id INTEGER PRIMARY KEY AUTOINCREMENT,
38
- created_at TEXT NOT NULL,
39
- original_hash TEXT NOT NULL,
40
- anonymized_text TEXT NOT NULL,
41
- label TEXT NOT NULL,
42
- score REAL NOT NULL,
43
- client_ip TEXT
44
- )
45
- """
46
- )
47
- return conn
48
-
49
-
50
- _db_conn = _get_connection()
51
-
52
-
53
- _EMAIL_RE = re.compile(r"[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}")
54
- _PHONE_RE = re.compile(r"\+?\d[\d\s\-]{7,}\d")
55
- _DIGITS_RE = re.compile(r"\d{3,}")
56
-
57
- # very simple detection of names: sequence of 2–3 words starting with a capital letter
58
- _NAME_SEQ_RE = re.compile(
59
- r"\b([A-ZÁČĎÉĚÍŇÓŘŠŤÚŮÝŽ][a-záčďéěíňóřšťúůýž]+(?:\s+[A-ZÁČĎÉĚÍŇÓŘŠŤÚŮÝŽ][a-záčďéěíňóřšťúůýž]+){1,2})\b"
60
- )
61
-
62
-
63
- def _anonymize_names_outside_sentence_start(text: str) -> str:
64
-
65
- parts = re.split(r"(\n+|[.!?])", text)
66
- if len(parts) == 1:
67
- return _anonymize_names_skip_first_word(text)
68
-
69
- processed: list[str] = []
70
-
71
- for i in range(0, len(parts), 2):
72
- sentence = parts[i]
73
- terminator = parts[i + 1] if i + 1 < len(parts) else ""
74
- processed_sentence = _anonymize_names_skip_first_word(sentence)
75
- processed.append(processed_sentence + terminator)
76
-
77
- return "".join(processed)
78
-
79
-
80
- def _anonymize_names_skip_first_word(sentence: str) -> str:
81
-
82
- if not sentence:
83
- return sentence
84
-
85
- m = re.match(r"^(\s*\S+)(.*)$", sentence, flags=re.DOTALL)
86
- if not m:
87
- return sentence
88
-
89
- first_word = m.group(1)
90
- rest = m.group(2)
91
-
92
-
93
- rest = _NAME_SEQ_RE.sub("[NAME]", rest)
94
- return first_word + rest
95
-
96
-
97
- def anonymize_text(text: str) -> str:
98
- """
99
- Very simple anonymization:
100
- - e-mails -> [EMAIL]
101
- - phone numbers -> [PHONE]
102
- - longer sequences of digits -> [NUMBER]
103
- - name-like sequences like 'Jan Novák' -> [NAME], but not at the beginning of a sentence
104
- """
105
- t = _EMAIL_RE.sub("[EMAIL]", text)
106
- t = _PHONE_RE.sub("[PHONE]", t)
107
- t = _DIGITS_RE.sub("[NUMBER]", t)
108
- t = _anonymize_names_outside_sentence_start(t)
109
- return t
110
-
111
-
112
- def log_to_db(original_text: str, label: str, score: float, client_ip: str | None) -> None:
113
- """
114
- Save record to SQLite:
115
- - hash original text (SHA256) due to impossibility to reconstruct
116
- - anonymized text according to anonymize_text
117
- """
118
- created_at = datetime.utcnow().isoformat(timespec="seconds") + "Z"
119
- original_hash = hashlib.sha256(original_text.encode("utf-8")).hexdigest()
120
- anonymized = anonymize_text(original_text)
121
-
122
- with _db_lock:
123
- _db_conn.execute(
124
- """
125
- INSERT INTO logs (created_at, original_hash, anonymized_text, label, score, client_ip)
126
- VALUES (?, ?, ?, ?, ?, ?)
127
- """,
128
- (created_at, original_hash, anonymized, label, float(score), client_ip),
129
- )
130
- _db_conn.commit()
131
-
132
-
133
- def get_logs_from_db(limit: int = 100) -> list[dict[str, Any]]:
134
-
135
- with _db_lock:
136
- cur = _db_conn.execute(
137
- """
138
- SELECT id, created_at, anonymized_text, label, score, client_ip
139
- FROM logs
140
- ORDER BY id DESC
141
- LIMIT ?
142
- """,
143
- (limit,),
144
- )
145
- rows = cur.fetchall()
146
-
147
- return [
148
- {
149
- "id": r[0],
150
- "created_at": r[1],
151
- "anonymized_text": r[2],
152
- "label": r[3],
153
- "score": r[4],
154
- "client_ip": r[5],
155
- }
156
- for r in rows
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
- from fastapi import FastAPI, HTTPException, Request
2
- from pydantic import BaseModel
3
- from transformers import pipeline
4
- from pathlib import Path
5
- from fastapi.responses import FileResponse
6
- from fastapi.staticfiles import StaticFiles
7
-
8
- from db import log_to_db, get_logs_from_db
9
-
10
- app = FastAPI(title="RoBERTa Sentiment Analysis API")
11
-
12
- MODEL_PATH = "vojmahdal/roberta-sentiment-3labels"
13
-
14
- # Simple frontend (static files)
15
- BASE_DIR = Path(__file__).resolve().parent
16
- STATIC_DIR = BASE_DIR / "static"
17
- app.mount("/static", StaticFiles(directory=str(STATIC_DIR)), name="static")
18
-
19
-
20
- #
21
- # --- Simple SQLite "database" for logging/anonymized storage ---
22
- #
23
-
24
- try:
25
- print("Loading model, please wait...")
26
- sentiment_pipeline = pipeline(
27
- "sentiment-analysis",
28
- model=MODEL_PATH,
29
- tokenizer=MODEL_PATH
30
- )
31
- print("Model successfully loaded!")
32
- except Exception as e:
33
- print(f"Error loading model: {e}")
34
- sentiment_pipeline = None
35
-
36
- # Definition of the request structure
37
- class TextRequest(BaseModel):
38
- text: str
39
-
40
- @app.get("/")
41
- def home():
42
- index_file = STATIC_DIR / "index.html"
43
- if index_file.exists():
44
- return FileResponse(str(index_file))
45
- return {"message": "Sentiment API is running. Use POST on /predict."}
46
-
47
- @app.get("/health")
48
- def health():
49
- return {"status": "ok", "model_loaded": sentiment_pipeline is not None}
50
-
51
-
52
- @app.get("/logs")
53
- def get_logs(limit: int = 100):
54
- """
55
- Return last `limit` anonymized inputs from SQLite.
56
- Only anonymized_text + metadata, nikdy ne původní text.
57
- """
58
- return get_logs_from_db(limit=limit)
59
-
60
- @app.post("/predict")
61
- async def predict_sentiment(payload: TextRequest, request: Request):
62
- if sentiment_pipeline is None:
63
- raise HTTPException(status_code=500, detail="Model is not available.")
64
-
65
- if not payload.text.strip():
66
- raise HTTPException(status_code=400, detail="Text cannot be empty.")
67
-
68
- # Prediction
69
- result = sentiment_pipeline(payload.text)[0]
70
-
71
- # Try to log anonymized version into SQLite "database"
72
- try:
73
- client_ip = request.client.host if request.client else None
74
- log_to_db(
75
- original_text=payload.text,
76
- label=result["label"],
77
- score=float(result["score"]),
78
- client_ip=client_ip,
79
- )
80
- except Exception as e:
81
- # Logging failure must not break the API
82
- print(f"Logging to DB failed: {e}")
83
-
84
- return {
85
- "text": payload.text,
86
- "label": result["label"],
87
- "score": round(result["score"], 4)
88
- }
89
-
90
- if __name__ == "__main__":
91
- import uvicorn
92
- uvicorn.run(app, host="0.0.0.0", port=8000)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
- transformers
4
- torch
 
 
 
 
 
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 labelEl = document.getElementById("label");
7
- const scoreEl = document.getElementById("score");
 
 
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 showResult({ label, score }) {
20
- labelEl.textContent = label ?? "";
21
- scoreEl.textContent =
22
- typeof score === "number" ? score.toFixed(4) : String(score ?? "");
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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("Text cannot be empty.");
38
  return;
39
  }
40
 
41
  analyzeBtn.disabled = true;
42
  setStatus("Analyzing…");
43
 
 
 
44
  try {
45
- const res = await fetch("/predict", {
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(`Error API (${res.status} ${res.statusText}).${detail}`);
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="cs">
3
  <head>
4
  <meta charset="utf-8" />
5
  <meta name="viewport" content="width=device-width, initial-scale=1" />
6
- <title>Sentiment Analysis</title>
7
  <link rel="stylesheet" href="/static/styles.css" />
8
  </head>
9
  <body>
10
  <main class="container">
11
  <header class="header">
12
- <h1>Sentiment Analysis</h1>
13
  <p class="sub">
14
- Simple web interface over the <code>POST /predict</code> endpoint.
 
15
  </p>
16
  </header>
17
 
 
18
  <section class="card">
19
- <label class="label" for="text">Text</label>
20
  <textarea
21
  id="text"
22
- rows="6"
23
- placeholder="Write the text you want to analyze…"
24
- >This product is great!</textarea>
 
 
 
 
 
 
 
 
 
 
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">Label</span>
35
- <span id="label" class="v"></span>
36
  </div>
37
  <div class="resultRow">
38
- <span class="k">Score</span>
39
- <span id="score" class="v"></span>
 
 
 
 
 
 
 
 
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/logs.html">Logs</a>
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("Uploading model to Hugging Face... It may take a few minutes.")
9
 
10
  api.upload_folder(
11
- folder_path="./sentiment_analysis_model", # Upload all in the current folder
12
  repo_id=repo_id,
13
  repo_type="model",
14
-
15
  ignore_patterns=["checkpoint-*", ".venv/*", "upload.py", ".git*"]
16
  )
17
 
18
- print(f"Done! Model you can find on: https://huggingface.co/{repo_id}")
 
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}")