| """Extracción de referencias bibliográficas de texto libre. |
| |
| Dos modos: |
| - Con LLM (Groq, gratis): extrae referencias de cualquier formato, incluso |
| citas sueltas dentro de un párrafo, y las estructura (título, autores, año). |
| - Sin LLM (respaldo): trata cada línea o bloque como una referencia y extrae |
| lo que puede con expresiones regulares (DOI, año). Menos preciso, pero la |
| app funciona sin ninguna API key. |
| """ |
|
|
| from __future__ import annotations |
|
|
| import json |
| import os |
| import re |
|
|
| import requests |
|
|
| GROQ_URL = "https://api.groq.com/openai/v1/chat/completions" |
| GROQ_MODEL = "llama-3.3-70b-versatile" |
|
|
| DOI_RE = re.compile(r"\b(10\.\d{4,9}/[^\s,;\"']+)", re.IGNORECASE) |
| YEAR_RE = re.compile(r"\b(19\d{2}|20\d{2})\b") |
|
|
| EXTRACT_PROMPT = """Extrae TODAS las referencias bibliográficas del texto que te paso \ |
| (pueden estar en una bibliografía formal, en notas al pie o citadas dentro del texto). |
| |
| Devuelve SOLO un objeto JSON válido, sin explicaciones, con esta forma exacta: |
| {"references": [{"title": "...", "authors": ["apellido1", "apellido2"], "year": 2020, "doi": null, "raw": "la referencia tal como aparece en el texto"}]} |
| |
| Reglas: |
| - "title" es el título del trabajo citado, no el de la revista. |
| - "authors" son solo los apellidos. |
| - "year" es un número o null si no aparece. |
| - "doi" es el DOI si aparece en el texto, o null. |
| - No inventes datos que no estén en el texto. |
| - Si no hay ninguna referencia, devuelve {"references": []}.""" |
|
|
|
|
| def llm_available() -> bool: |
| return bool(os.environ.get("GROQ_API_KEY")) |
|
|
|
|
| def extract_with_llm(text: str) -> list[dict]: |
| """Extrae referencias usando Groq. Lanza RuntimeError si la API falla.""" |
| response = requests.post( |
| GROQ_URL, |
| headers={"Authorization": f"Bearer {os.environ['GROQ_API_KEY']}"}, |
| json={ |
| "model": GROQ_MODEL, |
| "temperature": 0, |
| "response_format": {"type": "json_object"}, |
| "messages": [ |
| {"role": "system", "content": EXTRACT_PROMPT}, |
| {"role": "user", "content": text[:15000]}, |
| ], |
| }, |
| timeout=60, |
| ) |
| if response.status_code != 200: |
| raise RuntimeError(f"Groq devolvió {response.status_code}: {response.text[:200]}") |
|
|
| content = response.json()["choices"][0]["message"]["content"] |
| references = json.loads(content).get("references", []) |
|
|
| |
| cleaned = [] |
| for ref in references: |
| if not isinstance(ref, dict) or not ref.get("raw"): |
| continue |
| cleaned.append( |
| { |
| "title": ref.get("title") or None, |
| "authors": ref.get("authors") or [], |
| "year": ref.get("year") if isinstance(ref.get("year"), int) else None, |
| "doi": ref.get("doi") or None, |
| "raw": str(ref["raw"]).strip(), |
| } |
| ) |
| return cleaned |
|
|
|
|
| def extract_heuristic(text: str) -> list[dict]: |
| """Modo sin LLM: cada línea (o bloque separado por línea en blanco) es una |
| referencia. Se extraen DOI y año con regex; el resto de la verificación |
| usa la cadena completa contra la búsqueda bibliográfica de Crossref.""" |
| blocks = [b.strip() for b in re.split(r"\n\s*\n|\n(?=\[?\d+[\].])", text) if b.strip()] |
| if len(blocks) <= 1: |
| blocks = [line.strip() for line in text.splitlines() if len(line.strip()) > 20] |
|
|
| references = [] |
| for block in blocks: |
| block = re.sub(r"\s+", " ", block) |
| doi_match = DOI_RE.search(block) |
| year_match = YEAR_RE.search(block) |
| |
| |
| title_match = re.search(r"\(\d{4}\)\.?\s*([^.]{15,250})[.?]", block) |
| references.append( |
| { |
| "title": title_match.group(1).strip() if title_match else None, |
| "authors": [], |
| "year": int(year_match.group(1)) if year_match else None, |
| "doi": doi_match.group(1).rstrip(".") if doi_match else None, |
| "raw": block, |
| } |
| ) |
| return references |
|
|
|
|
| def extract(text: str) -> tuple[list[dict], str]: |
| """Devuelve (referencias, modo). Usa el LLM si hay key; si no, el respaldo.""" |
| if llm_available(): |
| return extract_with_llm(text), "llm" |
| return extract_heuristic(text), "heuristico" |
|
|