citation-checker / checker /extractor.py
aitziberluis's picture
Upload 8 files
4275a6e verified
Raw
History Blame Contribute Delete
4.46 kB
"""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", [])
# Normalizar: que ninguna clave falte, pase lo que pase con el LLM.
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)
# En formato APA el título va justo después de "(año).": aprovecharlo
# mejora mucho la búsqueda (sobre todo en arXiv).
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"