Spaces:
Running
Running
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)
|