File size: 4,464 Bytes
4275a6e
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
"""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"