lancaster-ner-api / api_ner.py
alezsd's picture
adaptacion JSON de Leo
dc68f22
Raw History Blame Contribute Delete
14.9 kB
#!/usr/bin/env python
# coding: utf-8
"""
FastAPI endpoint para el proyecto lanc-ner
Recibe un archivo .txt y devuelve las anotaciones NER en formato JSON
por lΓ­nea, con el esquema:
[
{ "Anotacion": "<lΓ­nea>", "entities": [
{ "start": int, "end": int, "entity": str, "fields": [str] }
]},
...
]
AutenticaciΓ³n: Bearer token via variable de entorno API_TOKEN.
"""
from fastapi import FastAPI, File, UploadFile, HTTPException, Depends, Security, Query
from fastapi.responses import JSONResponse, HTMLResponse
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
from fastapi.openapi.utils import get_openapi
import spacy
from spacy import displacy
import os
import logging
# ── Logging ───────────────────────────────────────────────────────────────────
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)
# ── ConfiguraciΓ³n ─────────────────────────────────────────────────────────────
API_TOKEN = os.getenv("API_TOKEN", "")
if not API_TOKEN:
logger.warning("⚠️ API_TOKEN no definido β€” la API estΓ‘ desprotegida.")
MODELS = {
"ruler": "./models/model_entities_ruler/model-last/",
"toponyms": "./models/model_toponyms_patterns/model-last/",
}
# ── Carga de modelos ──────────────────────────────────────────────────────────
loaded_models = {}
for name, path in MODELS.items():
try:
loaded_models[name] = spacy.load(path)
logger.info(f"Modelo '{name}' cargado desde {path}")
except OSError:
logger.error(f"Modelo '{name}' no encontrado en {path}")
# ── App ───────────────────────────────────────────────────────────────────────
app = FastAPI(
title="Lancaster NER API",
description=(
"API REST para detecciΓ³n de entidades nombradas (NER) basada en spaCy.\n\n"
"**Modelos disponibles:**\n"
"- `ruler` β€” Spacy Ruler (Sentences): entidades generales\n"
"- `toponyms` β€” DECM annotations with Paragraphs: topΓ³nimos\n\n"
"**AutenticaciΓ³n:** todos los endpoints de anotaciΓ³n requieren "
"`Authorization: Bearer <token>` en la cabecera.\n\n"
"**Formato de salida:** lista de objetos por lΓ­nea del documento, "
"con campo `Anotacion` (texto de la lΓ­nea) y `entities` (entidades detectadas)."
),
version="1.3.0",
docs_url="/docs",
redoc_url="/redoc",
)
# ── Seguridad: Bearer token ───────────────────────────────────────────────────
bearer_scheme = HTTPBearer(auto_error=True)
def verify_token(credentials: HTTPAuthorizationCredentials = Security(bearer_scheme)):
if API_TOKEN and credentials.credentials != API_TOKEN:
raise HTTPException(
status_code=401,
detail="Token invΓ‘lido o expirado.",
headers={"WWW-Authenticate": "Bearer"},
)
return credentials.credentials
# ── FunciΓ³n NER ───────────────────────────────────────────────────────────────
def run_ner(text: str, model_name: str) -> list:
"""
Procesa el texto lΓ­nea a lΓ­nea y devuelve una lista con el formato:
[
{
"Anotacion": "<lΓ­nea>",
"entities": [
{ "start": int, "end": int, "entity": str, "fields": [str] }
]
},
...
]
"""
if model_name not in loaded_models:
raise HTTPException(
status_code=400,
detail=f"Modelo '{model_name}' no disponible. Opciones: {list(loaded_models.keys())}",
)
nlp = loaded_models[model_name]
lines = text.splitlines()
result = []
for line in lines:
doc = nlp(line)
entities = []
for ent in doc.ents:
entities.append({
"start": ent.start_char,
"end": ent.end_char,
"entity": ent.label_,
"fields": [],
})
result.append({
"Anotacion": line,
"entities": entities,
})
return result
# ── Endpoints ─────────────────────────────────────────────────────────────────
@app.get("/", tags=["Health"], summary="Health check")
def health_check():
"""Estado de la API y modelos cargados. **No requiere autenticaciΓ³n.**"""
return {
"status": "ok",
"auth_enabled": bool(API_TOKEN),
"models_available": list(loaded_models.keys()),
"models_missing": [k for k in MODELS if k not in loaded_models],
}
@app.post(
"/annotate",
tags=["NER"],
summary="Anotar documento .txt",
response_description="Lista de anotaciones NER por lΓ­nea del documento.",
)
async def annotate(
file: UploadFile = File(..., description="Archivo de texto plano (.txt) a anotar."),
model: str = Query(
default="ruler",
description="Modelo NER a usar: `ruler` o `toponyms`.",
enum=["ruler", "toponyms"],
),
token: str = Depends(verify_token),
):
"""
Recibe un archivo `.txt` y devuelve una lista de anotaciones NER por lΓ­nea.
**Cabecera requerida:**
```
Authorization: Bearer <tu-token>
```
**Formato de respuesta:**
```json
[
{
"Anotacion": "Registraron Baltasar Perez y Ernando de Sanctiago vesinos de Sevilla",
"entities": [
{ "start": 12, "end": 26, "entity": "Persona", "fields": ["Nombre", "Hombre"] },
{ "start": 61, "end": 68, "entity": "LocalizaciΓ³n", "fields": ["TopΓ³nimo"] }
]
}
]
```
"""
if not file.filename.lower().endswith(".txt"):
raise HTTPException(status_code=415, detail="Solo se aceptan archivos .txt.")
try:
text = (await file.read()).decode("utf-8")
except UnicodeDecodeError:
raise HTTPException(status_code=422, detail="El archivo no es UTF-8 vΓ‘lido.")
if not text.strip():
raise HTTPException(status_code=422, detail="El archivo estΓ‘ vacΓ­o.")
result = run_ner(text, model)
return JSONResponse(content=result)
@app.post(
"/annotate/text",
tags=["NER"],
summary="Anotar texto plano (JSON)",
response_description="Lista de anotaciones NER por lΓ­nea.",
)
async def annotate_text(
payload: dict,
model: str = Query(
default="ruler",
description="Modelo NER a usar: `ruler` o `toponyms`.",
enum=["ruler", "toponyms"],
),
token: str = Depends(verify_token),
):
"""
EnvΓ­a el texto directamente en el body JSON.
**Body:**
```json
{ "text": "El municipio de Atengo se encuentra en Jalisco." }
```
"""
text = payload.get("text", "").strip()
if not text:
raise HTTPException(status_code=422, detail="El campo 'text' estΓ‘ vacΓ­o.")
result = run_ner(text, model)
return JSONResponse(content=result)
@app.get(
"/docu",
tags=["DocumentaciΓ³n"],
summary="DocumentaciΓ³n HTML del servicio",
response_class=HTMLResponse,
include_in_schema=False,
)
def docu():
"""PΓ‘gina HTML autodocumentada. **No requiere autenticaciΓ³n.**"""
schema = get_openapi(
title=app.title,
version=app.version,
description=app.description,
routes=app.routes,
)
endpoints_html = ""
for path, methods in schema.get("paths", {}).items():
for method, info in methods.items():
badge_color = {
"get": "#61affe", "post": "#49cc90",
"put": "#fca130", "delete": "#f93e3e",
}.get(method.lower(), "#888")
params_html = ""
for p in info.get("parameters", []):
params_html += f"""
<tr>
<td><code>{p.get('name')}</code></td>
<td>{p.get('in')}</td>
<td>{'βœ…' if p.get('required') else 'β€”'}</td>
<td>{p.get('description','')}</td>
</tr>"""
params_section = (
f"""<table class="param-table">
<thead><tr><th>Nombre</th><th>En</th><th>Req.</th><th>DescripciΓ³n</th></tr></thead>
<tbody>{params_html}</tbody>
</table>"""
if params_html else ""
)
endpoints_html += f"""
<div class="endpoint">
<div class="ep-header">
<span class="badge" style="background:{badge_color}">{method.upper()}</span>
<span class="ep-path">{path}</span>
<span class="ep-summary">{info.get('summary','')}</span>
</div>
<p class="ep-desc">{info.get('description','').strip().split(chr(10))[0]}</p>
{params_section}
</div>"""
html = f"""<!DOCTYPE html>
<html lang="es">
<head>
<meta charset="UTF-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
<title>{app.title} β€” DocumentaciΓ³n</title>
<style>
*, *::before, *::after {{ box-sizing: border-box; margin: 0; padding: 0; }}
body {{ font-family: 'Segoe UI', system-ui, sans-serif; background: #f5f7fa; color: #1a1a2e; line-height: 1.6; }}
header {{ background: linear-gradient(135deg,#1a1a2e,#16213e); color:#fff; padding: 2.5rem 2rem; }}
header h1 {{ font-size: 2rem; margin-bottom:.4rem; }}
header p {{ opacity:.8; font-size:1rem; }}
.version {{ display:inline-block; background:#e94560; color:#fff; font-size:.75rem; padding:.2rem .6rem; border-radius:12px; margin-left:.6rem; vertical-align:middle; }}
main {{ max-width: 860px; margin: 2rem auto; padding: 0 1rem 4rem; }}
section {{ margin-bottom: 2.5rem; }}
h2 {{ font-size: 1.25rem; border-bottom: 2px solid #e94560; padding-bottom:.4rem; margin-bottom:1rem; color:#16213e; }}
.auth-box {{ background:#fff3cd; border-left:4px solid #ffc107; padding:1rem 1.2rem; border-radius:6px; margin-bottom:1.5rem; }}
.auth-box code {{ background:#ffe69c; padding:.1rem .4rem; border-radius:3px; }}
.model-box {{ background:#e8f4fd; border-left:4px solid #61affe; padding:1rem 1.2rem; border-radius:6px; margin-bottom:1rem; }}
.model-box code {{ background:#cce5ff; padding:.1rem .4rem; border-radius:3px; }}
.format-box {{ background:#f0fff4; border-left:4px solid #49cc90; padding:1rem 1.2rem; border-radius:6px; margin-bottom:1rem; }}
.endpoint {{ background:#fff; border:1px solid #e0e4ef; border-radius:8px; padding:1.2rem 1.4rem; margin-bottom:1rem; box-shadow:0 1px 4px rgba(0,0,0,.06); }}
.ep-header {{ display:flex; align-items:center; gap:.8rem; flex-wrap:wrap; margin-bottom:.5rem; }}
.badge {{ color:#fff; font-size:.7rem; font-weight:700; padding:.25rem .6rem; border-radius:4px; letter-spacing:.05em; }}
.ep-path {{ font-family:monospace; font-size:1rem; font-weight:600; }}
.ep-summary {{ color:#555; font-size:.9rem; }}
.ep-desc {{ color:#444; font-size:.9rem; margin-bottom:.6rem; }}
.param-table {{ width:100%; border-collapse:collapse; font-size:.85rem; margin-top:.5rem; }}
.param-table th {{ background:#f0f2f8; text-align:left; padding:.4rem .6rem; border:1px solid #dde; }}
.param-table td {{ padding:.4rem .6rem; border:1px solid #eef; }}
pre {{ background:#1e2235; color:#cdd6f4; padding:1rem; border-radius:6px; overflow-x:auto; font-size:.85rem; margin-top:.5rem; }}
.links {{ display:flex; gap:1rem; flex-wrap:wrap; }}
.links a {{ display:inline-block; background:#16213e; color:#fff; text-decoration:none; padding:.5rem 1.1rem; border-radius:6px; font-size:.9rem; }}
.links a:hover {{ background:#e94560; }}
</style>
</head>
<body>
<header>
<h1>{app.title} <span class="version">v{app.version}</span></h1>
<p>DetecciΓ³n de entidades nombradas (NER) sobre documentos de texto histΓ³ricos.</p>
</header>
<main>
<section>
<h2>DocumentaciΓ³n interactiva</h2>
<div class="links">
<a href="/docs">Swagger UI</a>
<a href="/redoc">ReDoc</a>
</div>
</section>
<section>
<h2>Formato de respuesta</h2>
<div class="format-box">
El documento se procesa <strong>lΓ­nea a lΓ­nea</strong>. Cada lΓ­nea genera un objeto con:<br/><br/>
<code>Anotacion</code> β€” texto original de la lΓ­nea<br/>
<code>entities</code> β€” lista de entidades detectadas con <code>start</code>, <code>end</code>, <code>entity</code> y <code>fields</code>
</div>
<pre>[
{{
"Anotacion": "Registraron Baltasar Perez y Ernando de Sanctiago vesinos de Sevilla",
"entities": [
{{ "start": 12, "end": 26, "entity": "Persona", "fields": ["Nombre", "Hombre"] }},
{{ "start": 61, "end": 68, "entity": "LocalizaciΓ³n", "fields": ["TopΓ³nimo"] }}
]
}}
]</pre>
</section>
<section>
<h2>Modelos disponibles</h2>
<div class="model-box">
<p><code>ruler</code> β€” <strong>Spacy Ruler (Sentences)</strong><br/>
Detecta entidades generales con reglas sobre oraciones.</p>
<br/>
<p><code>toponyms</code> β€” <strong>DECM annotations with Paragraphs</strong><br/>
Especializado en topΓ³nimos y anotaciones geogrΓ‘ficas por pΓ‘rrafos.</p>
</div>
</section>
<section>
<h2>AutenticaciΓ³n</h2>
<div class="auth-box">
Todos los endpoints de anotaciΓ³n requieren un <strong>Bearer token</strong>:<br/><br/>
<code>Authorization: Bearer &lt;tu-token&gt;</code>
</div>
</section>
<section>
<h2>Endpoints</h2>
{endpoints_html}
</section>
<section>
<h2>Ejemplos de uso</h2>
<p><strong>Subir archivo .txt:</strong></p>
<pre>curl -X POST "https://alezsd-lancaster-ner-api.hf.space/annotate?model=ruler" \\
-H "Authorization: Bearer &lt;token&gt;" \\
-F "file=@documento.txt"</pre>
<p style="margin-top:1rem"><strong>Enviar texto como JSON:</strong></p>
<pre>curl -X POST "https://alezsd-lancaster-ner-api.hf.space/annotate/text?model=toponyms" \\
-H "Authorization: Bearer &lt;token&gt;" \\
-H "Content-Type: application/json" \\
-d '{{"text": "El municipio de Atengo se encuentra en Jalisco."}}'</pre>
</section>
</main>
</body>
</html>"""
return HTMLResponse(content=html)