File size: 11,911 Bytes
f390b04
 
 
 
2e3f5aa
 
 
 
caf4ed9
 
2e3f5aa
84cd1aa
2e3f5aa
 
f390b04
 
2e3f5aa
 
 
84cd1aa
caf4ed9
 
 
f390b04
 
 
 
caf4ed9
 
f390b04
 
 
2e3f5aa
 
f390b04
 
 
 
 
caf4ed9
f390b04
2e3f5aa
f390b04
 
 
 
 
 
 
caf4ed9
f390b04
 
 
 
 
 
2e3f5aa
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
f390b04
 
 
 
 
 
 
2e3f5aa
 
 
f390b04
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2e3f5aa
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
f390b04
 
 
 
 
 
 
 
 
2e3f5aa
 
 
 
 
 
 
 
 
 
f390b04
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2e3f5aa
 
 
 
f390b04
 
 
 
 
 
caf4ed9
2e3f5aa
 
 
 
 
 
f390b04
caf4ed9
 
 
 
 
 
 
 
f390b04
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
caf4ed9
f390b04
caf4ed9
f390b04
caf4ed9
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
f390b04
caf4ed9
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
f390b04
caf4ed9
 
 
 
 
f390b04
 
 
 
 
 
 
 
 
 
 
84cd1aa
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2e3f5aa
 
f390b04
 
84cd1aa
 
f390b04
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
"""
Conversation Data Extraction System - REST API.

Endpoints:
    GET  /                    -> web dashboard
    GET  /health              -> service + model status
    POST /analyze             -> run full pipeline on a single message
    POST /predict             -> sentiment only (backward compatible)
    POST /ingest               -> start a batch ingest job, returns a job id
    GET  /ingest/status/{id}  -> progress / result of a background ingest job
    GET  /records             -> recent stored (anonymized) records
    GET  /records/export      -> stored records exported as XML, JSON or CSV
    GET  /stats               -> aggregate statistics
    GET  /models              -> default + suggested + currently loaded HF models per task

The full pipeline extracts named entities, classifies the topic, evaluates
sentiment, pseudonymizes the text and stores the structured result. Since V3,
each of the three ML steps (NER, topic classification, sentiment) can use any
compatible Hugging Face Hub model selected by the caller, instead of only the
built-in default model for that step. Since V4, stored records can be
exported in a choice of formats (XML, JSON, CSV) via `/records/export`. Since
V7, `/ingest` runs as a background job (see `jobs.py`) instead of one long
blocking request, so the dashboard can show a progress bar and elapsed timer.
"""

from __future__ import annotations

import threading
import time
from pathlib import Path
from typing import Any

from fastapi import FastAPI, Form, HTTPException, UploadFile, File
from fastapi.responses import FileResponse, Response
from fastapi.staticfiles import StaticFiles
from pydantic import BaseModel

import db
import ingest
import jobs
from pipeline import process_message, process_batch
from processors import ner, topics, sentiment, anonymizer, model_registry

app = FastAPI(
    title="Conversation Data Extraction System",
    description=(
        "Extracts named entities, topics and sentiment from chat conversations, "
        "pseudonymizes personal data (GDPR) and stores structured results."
    ),
    version="7.0.0",
)

BASE_DIR = Path(__file__).resolve().parent
STATIC_DIR = BASE_DIR / "static"
app.mount("/static", StaticFiles(directory=str(STATIC_DIR)), name="static")

# Curated lists of well-known Hugging Face models offered as suggestions in
# the UI, one per pipeline step. Any other compatible model id can still be
# supplied manually - these lists are not a whitelist.
SUGGESTED_MODELS = {
    "sentiment": [
        sentiment.DEFAULT_MODEL_NAME,
        "cardiffnlp/twitter-roberta-base-sentiment-latest",
        "distilbert-base-uncased-finetuned-sst-2-english",
        "nlptown/bert-base-multilingual-uncased-sentiment",
    ],
    "ner": [
        ner.DEFAULT_MODEL_NAME,
        "dslim/bert-large-NER",
        "Jean-Baptiste/roberta-large-ner-english",
        "dbmdz/bert-large-cased-finetuned-conll03-english",
    ],
    "topics": [
        topics.DEFAULT_MODEL_NAME,
        "MoritzLaurer/deberta-v3-base-zeroshot-v1.1-all-33",
        "valhalla/distilbart-mnli-12-3",
    ],
}


# ---------------------------------------------------------------------------
# Request models
# ---------------------------------------------------------------------------
class TextRequest(BaseModel):
    text: str
    topic_labels: list[str] | None = None
    sentiment_model: str | None = None
    ner_model: str | None = None
    topic_model: str | None = None


# ---------------------------------------------------------------------------
# Pages
# ---------------------------------------------------------------------------
@app.get("/")
def home():
    index_file = STATIC_DIR / "index.html"
    if index_file.exists():
        return FileResponse(str(index_file))
    return {"message": "Conversation Data Extraction System is running. See /docs."}


@app.get("/health")
def health():
    return {
        "status": "ok",
        "models": {
            "ner": {"name": ner.model_name(), "ready": ner.is_ready()},
            "topics": {"name": topics.model_name(), "ready": topics.is_ready()},
            "sentiment": {"name": sentiment.model_name(), "ready": sentiment.is_ready()},
            "anonymizer": {"backend": anonymizer.backend_name()},
        },
    }


@app.get("/models")
def list_models():
    """Default, suggested and currently warm-cached models, per pipeline step."""
    cached = model_registry.cached_models()
    return {
        "sentiment": {
            "default": sentiment.DEFAULT_MODEL_NAME,
            "suggested": SUGGESTED_MODELS["sentiment"],
            "cached": cached.get("sentiment-analysis", []),
        },
        "ner": {
            "default": ner.DEFAULT_MODEL_NAME,
            "suggested": SUGGESTED_MODELS["ner"],
            "cached": cached.get("token-classification", []),
        },
        "topics": {
            "default": topics.DEFAULT_MODEL_NAME,
            "suggested": SUGGESTED_MODELS["topics"],
            "cached": cached.get("zero-shot-classification", []),
        },
    }


# ---------------------------------------------------------------------------
# Core analysis
# ---------------------------------------------------------------------------
@app.post("/analyze")
def analyze(payload: TextRequest):
    """Run the full pipeline on a single message and store the result."""
    if not payload.text or not payload.text.strip():
        raise HTTPException(status_code=400, detail="Text cannot be empty.")

    try:
        result = process_message(
            payload.text,
            topic_labels=payload.topic_labels,
            sentiment_model=payload.sentiment_model,
            ner_model=payload.ner_model,
            topic_model=payload.topic_model,
        )
    except RuntimeError as e:
        raise HTTPException(status_code=400, detail=str(e))

    try:
        db.save_record(result, source="single")
    except Exception as e:
        print(f"[main] Failed to store record: {e}")

    # do not return the raw text in a way that encourages storing it client-side;
    # we return both for the immediate UI, but only anonymized is persisted.
    return result


@app.post("/predict")
def predict(payload: TextRequest):
    """
    Backward-compatible sentiment-only endpoint.
    Kept so existing clients of the original API keep working.
    """
    if not payload.text or not payload.text.strip():
        raise HTTPException(status_code=400, detail="Text cannot be empty.")

    try:
        s = sentiment.analyze_sentiment(payload.text, model_id=payload.sentiment_model)
    except RuntimeError as e:
        raise HTTPException(status_code=400, detail=str(e))
    return {"text": payload.text, "label": s["label"], "score": s["score"]}


# ---------------------------------------------------------------------------
# Ingest (batch)
# ---------------------------------------------------------------------------
@app.post("/ingest", status_code=202)
async def ingest_file(
    file: UploadFile = File(...),
    sentiment_model: str | None = Form(None),
    ner_model: str | None = Form(None),
    topic_model: str | None = Form(None),
):
    """
    Start a background job that ingests a CSV or JSON file of conversations
    and runs the full pipeline on each message. Returns immediately with a
    job id; poll ``GET /ingest/status/{job_id}`` for progress and, once
    finished, the same summary this endpoint used to return directly
    (``received``, ``processed``, ``stored``, ``truncated``, ``items``).

    Parsing/validating the file happens synchronously here (fast, no model
    calls); only the actual NLP processing runs in the background.
    """
    raw = await file.read()
    if not raw:
        raise HTTPException(status_code=400, detail="Uploaded file is empty.")

    try:
        messages = ingest.parse_upload(file.filename or "", raw)
    except Exception as e:
        raise HTTPException(status_code=400, detail=f"Could not parse file: {e}")

    if not messages:
        raise HTTPException(
            status_code=400,
            detail="No messages found. Expected a 'text'/'message' column or field.",
        )

    # Cap batch size to keep the demo responsive on limited hardware.
    MAX_BATCH = 200
    truncated = len(messages) > MAX_BATCH
    messages = messages[:MAX_BATCH]

    job_id = jobs.create_job(total=len(messages))

    def run_job() -> None:
        try:
            results = process_batch(
                messages,
                sentiment_model=sentiment_model,
                ner_model=ner_model,
                topic_model=topic_model,
                on_progress=jobs.progress_callback(job_id),
            )
        except RuntimeError as e:
            jobs.fail_job(job_id, str(e))
            return
        except Exception as e:  # pragma: no cover - safety net so a job never hangs
            jobs.fail_job(job_id, f"Unexpected error: {e}")
            return

        stored = 0
        for r in results:
            try:
                db.save_record(r, source="ingest")
                stored += 1
            except Exception as e:
                print(f"[main] Failed to store ingest record: {e}")

        jobs.finish_job(job_id, {
            "received": len(messages),
            "processed": len(results),
            "stored": stored,
            "truncated": truncated,
            "items": results,
        })

    # Runs the (blocking, CPU-bound) pipeline in a plain thread rather than
    # an asyncio task, so it doesn't block the event loop and status polls
    # keep being served while it runs.
    threading.Thread(target=run_job, daemon=True).start()

    return {"job_id": job_id, "total": len(messages)}


@app.get("/ingest/status/{job_id}")
def ingest_status(job_id: str):
    """
    Progress of a background ingest job started via ``POST /ingest``.

    ``status`` is one of ``running``, ``done`` or ``error``. Once ``done``,
    ``result`` holds the same summary the old synchronous ``/ingest``
    returned directly.
    """
    job = jobs.get_job(job_id)
    if job is None:
        raise HTTPException(
            status_code=404,
            detail="Unknown job id (it may have finished long ago, or the server restarted).",
        )

    finished_at = job["finished_at"] or time.time()
    response: dict[str, Any] = {
        "status": job["status"],
        "processed": job["processed"],
        "total": job["total"],
        "elapsed_seconds": round(finished_at - job["started_at"], 1),
    }
    if job["status"] == "done":
        response["result"] = job["result"]
    elif job["status"] == "error":
        response["error"] = job["error"]
    return response


# ---------------------------------------------------------------------------
# Stored data
# ---------------------------------------------------------------------------
@app.get("/records")
def records(limit: int = 100):
    """Return recent stored records (anonymized only)."""
    return db.get_records(limit=limit)


@app.get("/records/export")
def export_records(format: str = "xml", limit: int | None = None):
    """
    Export stored (anonymized) records. ``format`` is one of the keys in
    ``db.EXPORT_FORMATS`` (currently ``xml``, ``json``, ``csv``).
    """
    try:
        content, media_type = db.export_records(format, limit=limit)
    except ValueError as e:
        raise HTTPException(status_code=400, detail=str(e))

    filename = f"records.{format.lower()}"
    return Response(
        content=content,
        media_type=media_type,
        headers={"Content-Disposition": f'attachment; filename="{filename}"'},
    )


@app.get("/stats")
def get_stats():
    """Aggregate statistics for the dashboard, plus the supported export formats."""
    return {**db.stats(), "export_formats": sorted(db.EXPORT_FORMATS)}


if __name__ == "__main__":
    import uvicorn

    uvicorn.run(app, host="0.0.0.0", port=8000)