File size: 6,425 Bytes
bf9f7d1
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
02b6489
bf9f7d1
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
02b6489
 
 
 
 
 
 
 
 
 
 
 
 
 
bf9f7d1
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
02b6489
 
 
 
 
 
bf9f7d1
02b6489
 
 
 
 
 
bf9f7d1
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
02b6489
 
 
 
 
bf9f7d1
 
 
 
 
 
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
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
"""Aplica `data/rag_alcance.json` a un índice RAG ya construido, sin reingerir.

Dos pasadas, las mismas que hace la ingesta:

1. **Descartar** índices alfabéticos, sumarios y preliminares.
2. **Reetiquetar** `especie` en las secciones que no son de perro ni gato.

Reingerir cuesta OCR sobre cientos de MB de PDF; esto sólo toca metadatos y filas, deja los
vectores calculados intactos y corre en segundos. Ver `app/rag/alcance_corpus.py` para por qué
hace falta (el filtro de especie estaba inerte: todos los chunks con `especie` vacía).

    uv run --group rag python ../scripts/curar_indice.py            # simulacro, no escribe
    uv run --group rag python ../scripts/curar_indice.py --aplicar  # escribe

Tras aplicarlo hay que republicar el índice (`make publish-index`) para que las builds y las
demás máquinas se lo lleven; si no, sólo queda arreglado en local.
"""

from __future__ import annotations

import argparse
import json
import sys
from collections import Counter
from pathlib import Path

RAIZ = Path(__file__).resolve().parent.parent
sys.path.insert(0, str(RAIZ / "backend"))

from app.config import obtener_config  # noqa: E402
from app.rag.alcance_corpus import (  # noqa: E402
    cargar_descartes,
    cargar_rangos,
    debe_descartarse,
    especie_de,
)


def actualizar_manifiesto(ruta: Path, n_fragmentos: int) -> None:
    """Deja `manifest.json` de acuerdo con lo que hay realmente en la tabla."""
    destino = ruta / "manifest.json"
    if not destino.exists():
        print("⚠ Sin manifest.json: no se puede dejar constancia del recuento.")
        return
    datos = json.loads(destino.read_text(encoding="utf-8"))
    antes = datos.get("n_fragmentos")
    datos["n_fragmentos"] = n_fragmentos
    datos["curado"] = "data/rag_alcance.json"
    destino.write_text(json.dumps(datos, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
    print(f"manifest.json actualizado: n_fragmentos {antes}{n_fragmentos}")


def main() -> int:
    parser = argparse.ArgumentParser()
    parser.add_argument(
        "--aplicar", action="store_true",
        help="escribe los cambios; sin esta bandera sólo informa de qué haría",
    )
    parser.add_argument("--indice", type=Path, help="ruta del índice (por defecto, la de config)")
    args = parser.parse_args()

    import lancedb

    cfg = obtener_config()
    ruta = args.indice or cfg.rag_index_dir
    if not ruta.exists():
        print(f"❌ No hay índice en {ruta}. Usa `make fetch-index`.")
        return 1

    descartes, rangos = cargar_descartes(), cargar_rangos()
    if not descartes and not rangos:
        print("❌ data/rag_alcance.json no declara nada: no habría qué hacer.")
        return 1

    print(f"Índice: {ruta}")
    print(f"\nDescartes ({len(descartes)}):")
    for d in descartes:
        print(f"  · {d.libro_empieza_por[:42]}… pp. {d.desde}{d.hasta}  ({d.motivo[:58]})")
    print(f"Rangos de especie ({len(rangos)}):")
    for r in rangos:
        print(f"  · {r.libro_empieza_por[:42]}… pp. {r.desde}{r.hasta}{r.especie}")

    db = lancedb.connect(str(ruta))
    tabla = db.open_table("literatura")
    df = tabla.to_pandas()
    total = len(df)

    fuera = df.apply(lambda f: debe_descartarse(f["libro"], f["pagina"], f["texto"]), axis=1)
    df_quedan = df[~fuera].copy()
    nueva = [especie_de(f.libro, f.pagina, "") for f in df_quedan.itertuples()]
    reetiquetados = sum(
        1 for antes, ahora in zip(df_quedan["especie"], nueva, strict=True) if antes != ahora
    )

    print(f"\nchunks: {total}")
    print(f"  a descartar (índices/sumarios/prelim.): {int(fuera.sum())}")
    print(f"  quedan:                                 {len(df_quedan)}")
    print(f"  cambian de especie:                     {reetiquetados}")
    print(f"  especies tras curar:                    {dict(Counter(nueva))}")

    manifiesto = ruta / "manifest.json"
    if manifiesto.exists():
        declarado = json.loads(manifiesto.read_text(encoding="utf-8")).get("n_fragmentos")
        if declarado != len(df_quedan):
            print(f"\n⚠ manifest.json declara {declarado} fragmentos y quedarían {len(df_quedan)}.")

    if not fuera.any() and not reetiquetados:
        if manifiesto.exists() and declarado != len(df_quedan):
            if not args.aplicar:
                print("Repite con --aplicar para corregir sólo el manifiesto.")
                return 0
            actualizar_manifiesto(ruta, len(df_quedan))
            return 0
        print("\nNada que hacer.")
        return 0
    if not args.aplicar:
        print("\nSimulacro. Repite con --aplicar para escribir.")
        return 0

    # Se reescribe la tabla entera desde el DataFrame: `mode='overwrite'` conserva el esquema y
    # los vectores ya calculados, que es justo lo que no queremos recomputar. El índice FTS sí
    # se reconstruye después, porque la sobrescritura lo invalida.
    df_quedan["especie"] = nueva
    db.create_table("literatura", data=df_quedan, mode="overwrite")
    tabla = db.open_table("literatura")
    try:
        tabla.create_fts_index("texto", replace=True)
        print("Índice FTS reconstruido.")
    except Exception as exc:  # noqa: BLE001 - informativo; el denso sigue sirviendo
        print(f"⚠ No se pudo reconstruir el FTS ({exc}). Reconstrúyelo antes de confiar en el híbrido.")

    # Compactar no es cosmético: LanceDB versiona, así que sobrescribir DEJA los datos viejos en
    # disco y el índice CRECE al quitarle filas (medido: 73 MB → 140 MB). Como este artefacto se
    # sube al Hub y se hornea en la imagen, publicarlo sin compactar multiplicaría su tamaño.
    import datetime

    tabla.optimize(cleanup_older_than=datetime.timedelta(0), delete_unverified=True)
    tamano = sum(f.stat().st_size for f in ruta.rglob("*") if f.is_file()) / 1e6
    print(f"Versiones antiguas purgadas. Tamaño en disco: {tamano:.0f} MB")

    # El manifiesto lo escribe la ingesta y aquí acaba de cambiar el número de fragmentos. Si no
    # se actualiza, miente: `fetch-index` lo imprime y `publish-index` lo usa como mensaje de
    # commit, así que el Hub anunciaría un recuento que no es el del índice que sirve.
    actualizar_manifiesto(ruta, len(df_quedan))

    print(f"\n✅ {total}{len(df_quedan)} fragmentos. Recuerda `make publish-index`.")
    return 0


if __name__ == "__main__":
    raise SystemExit(main())