"""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"