alezsd commited on
Commit
26ea364
·
1 Parent(s): 82b1b05

documentacion añadida

Browse files
Files changed (1) hide show
  1. api_ner.py +258 -68
api_ner.py CHANGED
@@ -4,66 +4,77 @@
4
  FastAPI endpoint para el proyecto lanc-ner
5
  Recibe un archivo .txt y devuelve las anotaciones NER en formato JSON.
6
 
7
- Uso:
8
- uvicorn api_ner:app --host 0.0.0.0 --port 8000 --reload
9
 
10
- Endpoint:
11
- POST /annotate
12
- - Body: multipart/form-data con campo "file" (archivo .txt)
13
- - Respuesta: JSON con anotaciones NER
14
  """
15
 
16
- from fastapi import FastAPI, File, UploadFile, HTTPException
17
- from fastapi.responses import JSONResponse
 
 
18
  import spacy
19
  from spacy import displacy
20
  import os
21
  import logging
22
- from typing import Optional
23
 
24
- # ── Logging ──────────────────────────────────────────────────────────────────
25
  logging.basicConfig(level=logging.INFO)
26
  logger = logging.getLogger(__name__)
27
 
 
 
 
 
 
 
 
28
  # ── App ───────────────────────────────────────────────────────────────────────
29
  app = FastAPI(
30
  title="Lancaster NER API",
31
- description="Recibe un documento .txt y devuelve sus anotaciones NER.",
32
- version="1.0.0",
 
 
 
 
 
 
 
 
33
  )
34
 
35
- # ── Carga del modelo spaCy ────────────────────────────────────────────────────
36
- # Ajusta el nombre del modelo al que uses en tu proyecto (lanc-ner).
37
- # Si tienes un modelo entrenado propio en ./models/, cámbialo aquí:
38
- # NLP_MODEL_PATH = "./models/mi_modelo"
39
- NLP_MODEL_PATH = os.getenv("NER_MODEL", "en_core_web_sm")
40
-
41
  try:
42
  nlp = spacy.load(NLP_MODEL_PATH)
43
  logger.info(f"Modelo cargado: {NLP_MODEL_PATH}")
44
  except OSError:
45
- logger.error(
46
- f"No se encontró el modelo '{NLP_MODEL_PATH}'. "
47
- "Ejecuta: python -m spacy download en_core_web_sm"
48
- )
49
  nlp = None
50
 
 
 
 
 
 
 
 
 
 
 
 
 
51
 
52
- # ── Función NER (misma lógica que views/NER.py del repo) ─────────────────────
 
53
  def run_ner(text: str) -> dict:
54
- """
55
- Aplica el pipeline NER de spaCy sobre el texto y devuelve
56
- un diccionario con:
57
- - entities : lista de entidades detectadas
58
- - tokens : lista de todos los tokens con sus etiquetas IOB
59
- - html : visualización HTML (displaCy) — opcional
60
- """
61
  if nlp is None:
62
  raise RuntimeError("El modelo NLP no está disponible.")
63
 
64
  doc = nlp(text)
65
 
66
- # Entidades detectadas
67
  entities = [
68
  {
69
  "text": ent.text,
@@ -75,7 +86,6 @@ def run_ner(text: str) -> dict:
75
  for ent in doc.ents
76
  ]
77
 
78
- # Tokens con etiquetas IOB
79
  tokens = [
80
  {
81
  "token": token.text,
@@ -88,7 +98,6 @@ def run_ner(text: str) -> dict:
88
  for token in doc
89
  ]
90
 
91
- # Visualización HTML de displaCy (útil para depuración/front)
92
  html = displacy.render(doc, style="ent", page=False, minify=True)
93
 
94
  return {
@@ -101,71 +110,98 @@ def run_ner(text: str) -> dict:
101
 
102
  # ── Endpoints ─────────────────────────────────────────────────────────────────
103
 
104
- @app.get("/", tags=["Health"])
 
 
 
 
 
105
  def health_check():
106
- """Comprueba que la API está en marcha."""
107
- model_status = "loaded" if nlp is not None else "not loaded"
108
- return {"status": "ok", "model": NLP_MODEL_PATH, "model_status": model_status}
 
 
 
 
 
 
 
109
 
110
 
111
- @app.post("/annotate", tags=["NER"])
112
- async def annotate(file: UploadFile = File(..., description="Archivo .txt a anotar")):
 
 
 
 
 
 
 
 
113
  """
114
- Recibe un archivo de texto plano (.txt) y devuelve las anotaciones
115
- NER producidas por el modelo spaCy configurado en el proyecto.
116
 
117
- **Respuesta JSON:**
 
 
 
 
 
118
  ```json
119
  {
120
  "filename": "documento.txt",
121
- "num_entities": 3,
122
  "entities": [
123
- {"text": "London", "label": "GPE", "label_description": "Countries, cities, states",
124
- "start": 5, "end": 11}
 
 
 
 
 
125
  ],
126
  "tokens": [...],
127
  "html_visualization": "<div>...</div>"
128
  }
129
  ```
130
  """
131
- # Validación del tipo de archivo
132
  if not file.filename.lower().endswith(".txt"):
133
- raise HTTPException(
134
- status_code=415,
135
- detail="Solo se aceptan archivos .txt",
136
- )
137
 
138
- # Lectura del contenido
139
  try:
140
- content_bytes = await file.read()
141
- text = content_bytes.decode("utf-8")
142
  except UnicodeDecodeError:
143
- raise HTTPException(
144
- status_code=422,
145
- detail="No se pudo decodificar el archivo como UTF-8.",
146
- )
147
 
148
  if not text.strip():
149
- raise HTTPException(
150
- status_code=422,
151
- detail="El archivo está vacío.",
152
- )
153
 
154
- # NER
155
  try:
156
  result = run_ner(text)
157
  except RuntimeError as e:
158
  raise HTTPException(status_code=503, detail=str(e))
159
 
160
- return JSONResponse(
161
- content={"filename": file.filename, **result}
162
- )
163
 
164
 
165
- @app.post("/annotate/text", tags=["NER"])
166
- async def annotate_text(payload: dict):
 
 
 
 
 
 
 
 
167
  """
168
- Alternativa: envía el texto directamente como JSON.
 
 
 
 
 
169
 
170
  **Body:**
171
  ```json
@@ -182,3 +218,157 @@ async def annotate_text(payload: dict):
182
  raise HTTPException(status_code=503, detail=str(e))
183
 
184
  return JSONResponse(content=result)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
4
  FastAPI endpoint para el proyecto lanc-ner
5
  Recibe un archivo .txt y devuelve las anotaciones NER en formato JSON.
6
 
7
+ Autenticación: Bearer token via variable de entorno API_TOKEN.
 
8
 
9
+ Uso:
10
+ export API_TOKEN="mi-token-secreto"
11
+ uvicorn api_ner:app --host 0.0.0.0 --port 7860 --reload
 
12
  """
13
 
14
+ from fastapi import FastAPI, File, UploadFile, HTTPException, Depends, Security
15
+ from fastapi.responses import JSONResponse, HTMLResponse
16
+ from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
17
+ from fastapi.openapi.utils import get_openapi
18
  import spacy
19
  from spacy import displacy
20
  import os
21
  import logging
 
22
 
23
+ # ── Logging ───────────────────────────────────────────────────────────────────
24
  logging.basicConfig(level=logging.INFO)
25
  logger = logging.getLogger(__name__)
26
 
27
+ # ── Configuración ─────────────────────────────────────────────────────────────
28
+ NLP_MODEL_PATH = os.getenv("NER_MODEL", "en_core_web_sm")
29
+ API_TOKEN = os.getenv("API_TOKEN", "") # obligatorio en producción
30
+
31
+ if not API_TOKEN:
32
+ logger.warning("⚠️ API_TOKEN no definido — la API está desprotegida.")
33
+
34
  # ── App ───────────────────────────────────────────────────────────────────────
35
  app = FastAPI(
36
  title="Lancaster NER API",
37
+ description=(
38
+ "API REST para detección de entidades nombradas (NER) basada en spaCy.\n\n"
39
+ "**Autenticación:** todos los endpoints protegidos requieren un "
40
+ "`Authorization: Bearer <token>` en la cabecera.\n\n"
41
+ "Consulta `/docs` para la documentación interactiva (Swagger UI) "
42
+ "o `/redoc` para la versión ReDoc."
43
+ ),
44
+ version="1.1.0",
45
+ docs_url="/docs",
46
+ redoc_url="/redoc",
47
  )
48
 
49
+ # ── Modelo spaCy ──────────────────────────────────────────────────────────────
 
 
 
 
 
50
  try:
51
  nlp = spacy.load(NLP_MODEL_PATH)
52
  logger.info(f"Modelo cargado: {NLP_MODEL_PATH}")
53
  except OSError:
54
+ logger.error(f"Modelo '{NLP_MODEL_PATH}' no encontrado.")
 
 
 
55
  nlp = None
56
 
57
+ # ── Seguridad: Bearer token ───────────────────────────────────────────────────
58
+ bearer_scheme = HTTPBearer(auto_error=True)
59
+
60
+ def verify_token(credentials: HTTPAuthorizationCredentials = Security(bearer_scheme)):
61
+ """Valida el Bearer token contra la variable de entorno API_TOKEN."""
62
+ if API_TOKEN and credentials.credentials != API_TOKEN:
63
+ raise HTTPException(
64
+ status_code=401,
65
+ detail="Token inválido o expirado.",
66
+ headers={"WWW-Authenticate": "Bearer"},
67
+ )
68
+ return credentials.credentials
69
 
70
+
71
+ # ── Función NER ───────────────────────────────────────────────────────────────
72
  def run_ner(text: str) -> dict:
 
 
 
 
 
 
 
73
  if nlp is None:
74
  raise RuntimeError("El modelo NLP no está disponible.")
75
 
76
  doc = nlp(text)
77
 
 
78
  entities = [
79
  {
80
  "text": ent.text,
 
86
  for ent in doc.ents
87
  ]
88
 
 
89
  tokens = [
90
  {
91
  "token": token.text,
 
98
  for token in doc
99
  ]
100
 
 
101
  html = displacy.render(doc, style="ent", page=False, minify=True)
102
 
103
  return {
 
110
 
111
  # ── Endpoints ─────────────────────────────────────────────────────────────────
112
 
113
+ @app.get(
114
+ "/",
115
+ tags=["Health"],
116
+ summary="Health check",
117
+ response_description="Estado de la API y del modelo cargado.",
118
+ )
119
  def health_check():
120
+ """
121
+ Comprueba que la API está en marcha.
122
+ **No requiere autenticación.**
123
+ """
124
+ return {
125
+ "status": "ok",
126
+ "model": NLP_MODEL_PATH,
127
+ "model_status": "loaded" if nlp is not None else "not loaded",
128
+ "auth_enabled": bool(API_TOKEN),
129
+ }
130
 
131
 
132
+ @app.post(
133
+ "/annotate",
134
+ tags=["NER"],
135
+ summary="Anotar documento .txt",
136
+ response_description="Entidades NER detectadas, tokens y visualización HTML.",
137
+ )
138
+ async def annotate(
139
+ file: UploadFile = File(..., description="Archivo de texto plano (.txt) a anotar."),
140
+ token: str = Depends(verify_token),
141
+ ):
142
  """
143
+ Recibe un archivo `.txt` y devuelve sus anotaciones NER.
 
144
 
145
+ **Cabecera requerida:**
146
+ ```
147
+ Authorization: Bearer <tu-token>
148
+ ```
149
+
150
+ **Respuesta:**
151
  ```json
152
  {
153
  "filename": "documento.txt",
154
+ "num_entities": 2,
155
  "entities": [
156
+ {
157
+ "text": "London",
158
+ "label": "GPE",
159
+ "label_description": "Countries, cities, states",
160
+ "start": 5,
161
+ "end": 11
162
+ }
163
  ],
164
  "tokens": [...],
165
  "html_visualization": "<div>...</div>"
166
  }
167
  ```
168
  """
 
169
  if not file.filename.lower().endswith(".txt"):
170
+ raise HTTPException(status_code=415, detail="Solo se aceptan archivos .txt.")
 
 
 
171
 
 
172
  try:
173
+ text = (await file.read()).decode("utf-8")
 
174
  except UnicodeDecodeError:
175
+ raise HTTPException(status_code=422, detail="El archivo no es UTF-8 válido.")
 
 
 
176
 
177
  if not text.strip():
178
+ raise HTTPException(status_code=422, detail="El archivo está vacío.")
 
 
 
179
 
 
180
  try:
181
  result = run_ner(text)
182
  except RuntimeError as e:
183
  raise HTTPException(status_code=503, detail=str(e))
184
 
185
+ return JSONResponse(content={"filename": file.filename, **result})
 
 
186
 
187
 
188
+ @app.post(
189
+ "/annotate/text",
190
+ tags=["NER"],
191
+ summary="Anotar texto plano (JSON)",
192
+ response_description="Entidades NER detectadas, tokens y visualización HTML.",
193
+ )
194
+ async def annotate_text(
195
+ payload: dict,
196
+ token: str = Depends(verify_token),
197
+ ):
198
  """
199
+ Alternativa a `/annotate`: envía el texto directamente en el body JSON.
200
+
201
+ **Cabecera requerida:**
202
+ ```
203
+ Authorization: Bearer <tu-token>
204
+ ```
205
 
206
  **Body:**
207
  ```json
 
218
  raise HTTPException(status_code=503, detail=str(e))
219
 
220
  return JSONResponse(content=result)
221
+
222
+
223
+ @app.get(
224
+ "/docu",
225
+ tags=["Documentación"],
226
+ summary="Documentación HTML del servicio",
227
+ response_class=HTMLResponse,
228
+ include_in_schema=False, # no aparece en /docs para no duplicar
229
+ )
230
+ def docu():
231
+ """
232
+ Página HTML autodocumentada con la descripción completa del servicio,
233
+ sus endpoints, parámetros y ejemplos de uso.
234
+ **No requiere autenticación.**
235
+ """
236
+ schema = get_openapi(
237
+ title=app.title,
238
+ version=app.version,
239
+ description=app.description,
240
+ routes=app.routes,
241
+ )
242
+
243
+ endpoints_html = ""
244
+ for path, methods in schema.get("paths", {}).items():
245
+ for method, info in methods.items():
246
+ badge_color = {
247
+ "get": "#61affe", "post": "#49cc90",
248
+ "put": "#fca130", "delete": "#f93e3e",
249
+ }.get(method.lower(), "#888")
250
+
251
+ params_html = ""
252
+ for p in info.get("parameters", []):
253
+ params_html += f"""
254
+ <tr>
255
+ <td><code>{p.get('name')}</code></td>
256
+ <td>{p.get('in')}</td>
257
+ <td>{'✅' if p.get('required') else '—'}</td>
258
+ <td>{p.get('description','')}</td>
259
+ </tr>"""
260
+
261
+ params_section = (
262
+ f"""<table class="param-table">
263
+ <thead><tr><th>Nombre</th><th>En</th><th>Req.</th><th>Descripción</th></tr></thead>
264
+ <tbody>{params_html}</tbody>
265
+ </table>"""
266
+ if params_html else ""
267
+ )
268
+
269
+ endpoints_html += f"""
270
+ <div class="endpoint">
271
+ <div class="ep-header">
272
+ <span class="badge" style="background:{badge_color}">{method.upper()}</span>
273
+ <span class="ep-path">{path}</span>
274
+ <span class="ep-summary">{info.get('summary','')}</span>
275
+ </div>
276
+ <p class="ep-desc">{info.get('description','').strip().split(chr(10))[0]}</p>
277
+ {params_section}
278
+ </div>"""
279
+
280
+ html = f"""<!DOCTYPE html>
281
+ <html lang="es">
282
+ <head>
283
+ <meta charset="UTF-8"/>
284
+ <meta name="viewport" content="width=device-width, initial-scale=1.0"/>
285
+ <title>{app.title} — Documentación</title>
286
+ <style>
287
+ *, *::before, *::after {{ box-sizing: border-box; margin: 0; padding: 0; }}
288
+ body {{ font-family: 'Segoe UI', system-ui, sans-serif; background: #f5f7fa;
289
+ color: #1a1a2e; line-height: 1.6; }}
290
+ header {{ background: linear-gradient(135deg,#1a1a2e,#16213e);
291
+ color:#fff; padding: 2.5rem 2rem; }}
292
+ header h1 {{ font-size: 2rem; margin-bottom:.4rem; }}
293
+ header p {{ opacity:.8; font-size:1rem; }}
294
+ .version {{ display:inline-block; background:#e94560; color:#fff;
295
+ font-size:.75rem; padding:.2rem .6rem; border-radius:12px;
296
+ margin-left:.6rem; vertical-align:middle; }}
297
+ main {{ max-width: 860px; margin: 2rem auto; padding: 0 1rem 4rem; }}
298
+ section {{ margin-bottom: 2.5rem; }}
299
+ h2 {{ font-size: 1.25rem; border-bottom: 2px solid #e94560;
300
+ padding-bottom:.4rem; margin-bottom:1rem; color:#16213e; }}
301
+ .auth-box {{ background:#fff3cd; border-left:4px solid #ffc107;
302
+ padding:1rem 1.2rem; border-radius:6px; margin-bottom:1.5rem; }}
303
+ .auth-box code {{ background:#ffe69c; padding:.1rem .4rem; border-radius:3px; }}
304
+ .endpoint {{ background:#fff; border:1px solid #e0e4ef; border-radius:8px;
305
+ padding:1.2rem 1.4rem; margin-bottom:1rem;
306
+ box-shadow:0 1px 4px rgba(0,0,0,.06); }}
307
+ .ep-header {{ display:flex; align-items:center; gap:.8rem; flex-wrap:wrap;
308
+ margin-bottom:.5rem; }}
309
+ .badge {{ color:#fff; font-size:.7rem; font-weight:700; padding:.25rem .6rem;
310
+ border-radius:4px; letter-spacing:.05em; }}
311
+ .ep-path {{ font-family:monospace; font-size:1rem; font-weight:600; }}
312
+ .ep-summary {{ color:#555; font-size:.9rem; }}
313
+ .ep-desc {{ color:#444; font-size:.9rem; margin-bottom:.6rem; }}
314
+ .param-table {{ width:100%; border-collapse:collapse; font-size:.85rem; margin-top:.5rem; }}
315
+ .param-table th {{ background:#f0f2f8; text-align:left;
316
+ padding:.4rem .6rem; border:1px solid #dde; }}
317
+ .param-table td {{ padding:.4rem .6rem; border:1px solid #eef; }}
318
+ pre {{ background:#1e2235; color:#cdd6f4; padding:1rem; border-radius:6px;
319
+ overflow-x:auto; font-size:.85rem; margin-top:.5rem; }}
320
+ .links {{ display:flex; gap:1rem; flex-wrap:wrap; }}
321
+ .links a {{ display:inline-block; background:#16213e; color:#fff; text-decoration:none;
322
+ padding:.5rem 1.1rem; border-radius:6px; font-size:.9rem; }}
323
+ .links a:hover {{ background:#e94560; }}
324
+ </style>
325
+ </head>
326
+ <body>
327
+ <header>
328
+ <h1>{app.title} <span class="version">v{app.version}</span></h1>
329
+ <p>Detección de entidades nombradas (NER) sobre documentos de texto.</p>
330
+ </header>
331
+ <main>
332
+
333
+ <section>
334
+ <h2>Documentación interactiva</h2>
335
+ <div class="links">
336
+ <a href="/docs">Swagger UI</a>
337
+ <a href="/redoc">ReDoc</a>
338
+ </div>
339
+ </section>
340
+
341
+ <section>
342
+ <h2>Autenticación</h2>
343
+ <div class="auth-box">
344
+ Todos los endpoints de anotación requieren un <strong>Bearer token</strong>
345
+ en la cabecera HTTP:<br/><br/>
346
+ <code>Authorization: Bearer &lt;tu-token&gt;</code><br/><br/>
347
+ El token se configura mediante la variable de entorno <code>API_TOKEN</code>
348
+ en el Space de Hugging Face.
349
+ </div>
350
+ </section>
351
+
352
+ <section>
353
+ <h2>Endpoints</h2>
354
+ {endpoints_html}
355
+ </section>
356
+
357
+ <section>
358
+ <h2>Ejemplos de uso</h2>
359
+ <p><strong>Subir archivo .txt:</strong></p>
360
+ <pre>curl -X POST "https://&lt;space&gt;.hf.space/annotate" \\
361
+ -H "Authorization: Bearer &lt;token&gt;" \\
362
+ -F "file=@documento.txt"</pre>
363
+
364
+ <p style="margin-top:1rem"><strong>Enviar texto como JSON:</strong></p>
365
+ <pre>curl -X POST "https://&lt;space&gt;.hf.space/annotate/text" \\
366
+ -H "Authorization: Bearer &lt;token&gt;" \\
367
+ -H "Content-Type: application/json" \\
368
+ -d '{{"text": "John lives in New York."}}'</pre>
369
+ </section>
370
+
371
+ </main>
372
+ </body>
373
+ </html>"""
374
+ return HTMLResponse(content=html)