Spaces:
Sleeping
Sleeping
Download api_ner.py from alezsd/lancaster-ner-api: direct link, hf CLI and curl.
- Browser
- Download file 14.9 kB
-
https://huggingface.co/spaces/alezsd/lancaster-ner-api/resolve/main/api_ner.py
- Command line
-
hf download hf://spaces/alezsd/lancaster-ner-api/api_ner.py
-
curl -L -o api_ner.py https://huggingface.co/spaces/alezsd/lancaster-ner-api/resolve/main/api_ner.py
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 βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| 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], | |
| } | |
| 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) | |
| 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) | |
| 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 <tu-token></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 <token>" \\ | |
| -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 <token>" \\ | |
| -H "Content-Type: application/json" \\ | |
| -d '{{"text": "El municipio de Atengo se encuentra en Jalisco."}}'</pre> | |
| </section> | |
| </main> | |
| </body> | |
| </html>""" | |
| return HTMLResponse(content=html) |