File size: 22,324 Bytes
9abe007
 
 
 
 
 
 
 
 
 
 
 
 
 
 
0c0e3c8
9abe007
 
 
 
 
 
 
 
 
 
 
 
 
445a455
9abe007
2710330
9abe007
 
 
2710330
 
 
 
 
 
 
 
 
9abe007
 
 
 
563ceb8
9abe007
 
 
 
 
 
 
 
 
 
 
 
0c0e3c8
 
 
 
 
 
 
 
 
 
 
 
7e81e3e
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
c64e5be
9abe007
0c0e3c8
9abe007
0c0e3c8
9abe007
0c0e3c8
9abe007
0c0e3c8
9abe007
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
0c0e3c8
 
 
 
 
 
9abe007
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
563ceb8
9abe007
b07c8a2
 
 
 
 
 
 
 
 
445a455
9abe007
0c0e3c8
9abe007
 
 
 
0c0e3c8
9abe007
0c0e3c8
 
9abe007
 
445a455
9abe007
 
0c0e3c8
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
9abe007
 
 
0c0e3c8
9abe007
0c0e3c8
 
 
 
 
 
 
9abe007
0c0e3c8
9abe007
 
 
563ceb8
9abe007
445a455
9abe007
563ceb8
9abe007
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
f1d274f
563ceb8
9abe007
 
 
 
 
 
f1d274f
9abe007
 
 
f1d274f
 
9abe007
 
 
 
 
f1d274f
9abe007
c64e5be
 
0c0e3c8
c64e5be
 
7e81e3e
 
0c0e3c8
7e81e3e
 
f1d274f
 
0c0e3c8
9abe007
 
0c0e3c8
 
9abe007
 
 
0c0e3c8
9abe007
f1d274f
9abe007
0c0e3c8
445a455
9abe007
 
 
c64e5be
445a455
9abe007
7e81e3e
0c0e3c8
7e81e3e
0c0e3c8
f1d274f
0c0e3c8
 
 
 
9abe007
f1d274f
 
 
9abe007
 
 
0c0e3c8
3c7aafa
0c0e3c8
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
f1d274f
 
9abe007
0c0e3c8
 
9abe007
0c0e3c8
 
 
83f560f
1dc4b48
0c0e3c8
1dc4b48
0c0e3c8
 
 
9abe007
 
b07c8a2
9abe007
 
 
0c0e3c8
3c7aafa
9abe007
f1d274f
9abe007
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
0c0e3c8
9abe007
 
0c0e3c8
9abe007
0c0e3c8
 
 
 
9abe007
 
 
 
 
 
0c0e3c8
9abe007
 
 
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
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
"""
app.py
──────
Interfaz web Gradio para el sistema RAG de correcciΓ³n de castellano s.XVI.

Arranque:
    python app.py

Requiere:
    - .env con OPENAI_API_KEY
    - (opcional) corpus en ./corpus/
"""

import os
import json
import random
import gradio as gr
from dotenv import load_dotenv

from knowledge_base import SAMPLE_PAIRS
from corpus_loader import CorpusLoader
from vector_store import VectorStore
from rag_corrector import RAGCorrector
from evaluator import Evaluator

load_dotenv()

# ── InicializaciΓ³n ────────────────────────────────────────────────────────────

print(" Inicializando Scriptorium RAG...")

loader     = CorpusLoader(os.getenv("CORPUS_PATH", "./corpus"))
disk_pairs = loader.load()
all_pairs  = SAMPLE_PAIRS + disk_pairs

current_embed_model = "openai"
vs = VectorStore(embedding_model="openai")

if vs.count() == 0:
    print(f"⏳ Índice vacΓ­o β€” indexando {len(all_pairs)} pares, espera...")
    vs.index(all_pairs)
    print(f"βœ“ Índice listo: {vs.count()} documentos")
else:
    print(f"βœ“ Índice existente cargado: {vs.count()} documentos")

corrector = RAGCorrector(vs)
evaluator = Evaluator()

print(f" Sistema listo. Documentos en vector store: {vs.count()}")

# ── Ejemplos de demostraciΓ³n ──────────────────────────────────────────────────

DEMO_EXAMPLES = [
    "q̃ fizo merçed al dho lugar de las alcaualas del anno de mill e quinientos",
    "el escriuano del cabildo faze fe y da testimouio verdadero de todo lo sobredho",
    "en la muy noble Γ§ibdad de burgos a veynte dias del mes de marΓ§o anno dho",
    "yo juan de la torre vezino desta uilla de toledo otorgo e conosco",
    "sepan quantos esta carta de poder vieren como yo pero lopez vezino dela villa",
    "fizo pareçer ante si a los testigos q̃ dixeron ser mayores de veynte annos",
]

# ── CaligrafΓ­as disponibles en el corpus ──────────────────────────────────────

def get_caligrafias():
    cals = set()
    for p in all_pairs:
        c = p.get("caligrafia", "desconocida")
        if c: cals.add(c)
    return ["Todas"] + sorted(cals)


# ── Funciones ─────────────────────────────────────────────────────────────────

def cambiar_embedding(embed_model: str):
    global vs, corrector, current_embed_model
    if embed_model == current_embed_model:
        return f"β„Ή Ya estΓ‘s usando **{embed_model}**"
    try:
        current_embed_model = embed_model
        vs = VectorStore(embedding_model=embed_model)
        if vs.count() == 0:
            vs.index(all_pairs)
            msg = f" Re-indexado con **{embed_model}** Β· {vs.count()} docs"
        else:
            msg = f" Cargado Γ­ndice existente **{embed_model}** Β· {vs.count()} docs"
        corrector = RAGCorrector(vs)
        return msg
    except Exception as e:
        return f" Error cambiando embedding: {e}"


def corregir(htr_text: str, top_k: int, mostrar_prompt: bool, model: str):
    if not htr_text.strip():
        return "", "", "", "", " Introduce un texto HTR para corregir.", ""
    if not os.getenv("OPENAI_API_KEY"):
        return "", "", "", "", " Falta OPENAI_API_KEY en el fichero .env", ""
    try:
        result = corrector.correct(htr_text, top_k=int(top_k), model=model)
    except Exception as e:
        return "", "", "", "", f" Error al llamar a la API: {e}", ""

    corrected  = result["corrected"]
    retrieved  = result["retrieved"]
    htr_errors = result["htr_errors"]
    grafia_w   = result["grafia_warns"]

    docs_md = f"### Top-{len(retrieved)} documentos recuperados\n\n"
    for i, doc in enumerate(retrieved, 1):
        docs_md += (
            f"**{i}. [{doc['type']} Β· {doc['region']} Β· {doc['date']}]**  "
            f"*similitud: {doc['score']}*\n\n"
            f"- **HTR:** `{doc['htr']}`\n"
            f"- **GT:**  `{doc['gt']}`\n"
        )
        if doc["corrections"]:
            docs_md += f"- **Correcciones:** {', '.join(doc['corrections'])}\n"
        docs_md += "\n---\n"

    analysis_md = "### AnΓ‘lisis del texto\n\n"
    if htr_errors:
        analysis_md += "**⚠ Posibles errores HTR detectados:**\n"
        for e in htr_errors:
            examples = e.get("examples", e.get("example", ""))
            if isinstance(examples, list):
                examples = "; ".join(examples[:2])
            sev = e.get("severity", "")
            sev_label = f" `[{sev.upper()}]`" if sev else ""
            analysis_md += f"- `{e['htr']}` β†’ `{e['gt']}`{sev_label}: {e['context']}  \n  *Ej: {examples}*\n"
        analysis_md += "\n"
    if grafia_w:
        analysis_md += "**✦ Alertas de grafía (NO modernizar):**\n"
        for g in grafia_w:
            analysis_md += f"- `{g['modern']}` β†’ mantener `{g['ancient']}`: {g['rule']}\n"
        analysis_md += "\n"
    if not htr_errors and not grafia_w:
        analysis_md += "*No se detectaron patrones conocidos de error en el texto.*\n"

    diff_md = "### Diferencias HTR β†’ Corregido\n\n"
    orig_words = htr_text.split()
    corr_words = corrected.split()
    diff_parts = []
    max_len = max(len(orig_words), len(corr_words))
    changed = 0
    for i in range(max_len):
        o = orig_words[i] if i < len(orig_words) else "β€”"
        c = corr_words[i] if i < len(corr_words) else "β€”"
        if o != c:
            diff_parts.append(f"~~{o}~~ β†’ **{c}**")
            changed += 1
        else:
            diff_parts.append(c)
    diff_md += " ".join(diff_parts)
    diff_md += f"\n\n*{changed} palabra(s) modificada(s) de {len(orig_words)} totales.*"

    status = f" CorrecciΓ³n completada con **{result['model']}** Β· {vs.count()} docs en Γ­ndice"

    prompt_visible = ""
    if mostrar_prompt:
        prompt_visible = (
            "### System Prompt\n\n"
            f"```\n{result.get('_system', '(ver rag_corrector.py)')}\n```\n\n"
            "### User Prompt (dinΓ‘mico)\n\n"
            f"```\n{result['prompt']}\n```"
        )

    return corrected, docs_md, analysis_md, diff_md, status, prompt_visible


def evaluar_par(htr_text: str, gt_text: str):
    if not htr_text.strip() or not gt_text.strip():
        return "⚠ Introduce tanto el texto HTR como el groundtruth."
    try:
        result  = corrector.correct(htr_text)
        metrics = evaluator.evaluate_pair(htr_text, result["corrected"], gt_text)
        report  = evaluator.format_pair_report(metrics)
        report += f"\n\n**Texto corregido por RAG:**\n> {result['corrected']}"
        return report
    except Exception as e:
        return f" Error: {e}"


def evaluar_batch(n_samples: int, caligrafia_filtro: str, model: str):
    """
    EvalΓΊa el sistema sobre N pares aleatorios del corpus que tengan GT.
    Muestra las tres comparaciones: GT vs HTR, GT vs Corregido, HTR vs Corregido.
    """
    # Filtrar pares con HTR y GT no vacΓ­os
    pares_validos = [
        p for p in all_pairs
        if p.get("htr", "").strip() and p.get("gt", "").strip()
    ]

    # Filtrar por caligrafΓ­a si se especifica
    if caligrafia_filtro and caligrafia_filtro != "Todas":
        pares_validos = [
            p for p in pares_validos
            if p.get("caligrafia", "") == caligrafia_filtro
        ]

    if not pares_validos:
        return "⚠ No hay pares con GT disponibles para evaluar con ese filtro."

    # Muestra aleatoria
    n = min(int(n_samples), len(pares_validos))
    muestra = random.sample(pares_validos, n)

    yield f"⏳ Evaluando {n} pares con modelo **{model}**...\n\n"

    results = []
    errores = []
    for i, pair in enumerate(muestra, 1):
        try:
            out     = corrector.correct(pair["htr"], model=model)
            metrics = evaluator.evaluate_pair(
                htr=pair["htr"],
                corrected=out["corrected"],
                gt=pair["gt"],
            )
            metrics["id"]        = pair.get("id", f"par_{i}")
            metrics["htr"]       = pair["htr"]
            metrics["corrected"] = out["corrected"]
            metrics["gt"]        = pair["gt"]
            metrics["caligrafia"]= pair.get("caligrafia", "desconocida")
            results.append(metrics)
        except Exception as e:
            errores.append(f"  - {pair.get('id','?')}: {e}")

        # Progreso intermedio cada 5 pares
        if i % 5 == 0:
            yield f"⏳ Procesados {i}/{n}...\n\n"

    if not results:
        yield "❌ No se obtuvieron resultados.\n" + "\n".join(errores)
        return

    # ── Resumen global ────────────────────────────────────────────────────────
    def avg(key):
        return sum(r[key] for r in results) / len(results)

    n_res       = len(results)
    mejorados   = sum(1 for r in results if r["cer_improvement"] >  0.02)
    empeorados  = sum(1 for r in results if r["cer_improvement"] < -0.02)
    sin_cambio  = n_res - mejorados - empeorados

    md  = f"## πŸ“Š EvaluaciΓ³n por lotes β€” {n_res} pares\n\n"
    if caligrafia_filtro != "Todas":
        md += f"**Filtro caligrafΓ­a:** {caligrafia_filtro}  |  "
    md += f"**Modelo:** {model}\n\n"
    md += "---\n\n"

    # ComparaciΓ³n 1 β€” GT vs HTR
    md += "### β‘  Error de partida  (GT vs HTR original)\n\n"
    md += f"| CER medio | WER medio |\n|---|---|\n"
    md += f"| {avg('cer_before'):.2%} | {avg('wer_before'):.2%} |\n\n"

    # ComparaciΓ³n 2 β€” GT vs Corregido
    md += "### β‘‘ Error final  (GT vs Texto corregido)\n\n"
    md += f"| CER medio | WER medio | Mejora CER | Mejora WER |\n|---|---|---|---|\n"
    md += (
        f"| {avg('cer_after'):.2%} "
        f"| {avg('wer_after'):.2%} "
        f"| {avg('cer_improvement'):+.2%} "
        f"| {avg('wer_improvement'):+.2%} |\n\n"
    )
    md += (
        f"| βœ“ Mejorados | βœ— Empeorados | ~ Sin cambio |\n|---|---|---|\n"
        f"| {mejorados} ({mejorados/n_res:.0%}) "
        f"| {empeorados} ({empeorados/n_res:.0%}) "
        f"| {sin_cambio} ({sin_cambio/n_res:.0%}) |\n\n"
    )

    # ComparaciΓ³n 3 β€” HTR vs Corregido (modernismos)
    md += "### β‘’ Modernismos introducidos  (HTR vs Corregido)\n\n"
    md += f"Score promedio: **{avg('modernism_score'):.2%}** (1.0 = sin modernismos)\n\n"
    modernismos_total = sum(r["modernism"]["count"] for r in results)
    if modernismos_total == 0:
        md += "βœ“ El LLM no introdujo modernismos en ningΓΊn par.\n\n"
    else:
        md += f"βœ— {modernismos_total} modernismo(s) introducidos en total.\n\n"

    # ── Desglose por caligrafΓ­a ───────────────────────────────────────────────
    from collections import defaultdict
    by_cal = defaultdict(list)
    for r in results:
        by_cal[r["caligrafia"]].append(r)

    if len(by_cal) > 1:
        md += "### Desglose por caligrafΓ­a\n\n"
        md += "| CaligrafΓ­a | N | CER antes | CER despuΓ©s | Mejora CER | Modernismos |\n"
        md += "|---|---|---|---|---|---|\n"
        for cal, rs in sorted(by_cal.items()):
            a_cer  = sum(r["cer_before"] for r in rs) / len(rs)
            d_cer  = sum(r["cer_after"]  for r in rs) / len(rs)
            imp    = a_cer - d_cer
            mods   = sum(r["modernism"]["count"] for r in rs)
            md += f"| {cal} | {len(rs)} | {a_cer:.2%} | {d_cer:.2%} | {imp:+.2%} | {mods} |\n"
        md += "\n"

    # ── Detalle por par ───────────────────────────────────────────────────────
    md += "### Detalle por par\n\n"
    md += "| ID | Cal | CER antes | CER despuΓ©s | Mejora | Veredicto | Modernismos |\n"
    md += "|---|---|---|---|---|---|---|\n"
    for r in results:
        md += (
            f"| `{r['id'][:25]}` "
            f"| {r['caligrafia'][:12]} "
            f"| {r['cer_before']:.2%} "
            f"| {r['cer_after']:.2%} "
            f"| {r['cer_improvement']:+.2%} "
            f"| {r['verdict']} "
            f"| {r['modernism']['count']} |\n"
        )

    # Errores
    if errores:
        md += f"\n\n⚠ {len(errores)} pares fallaron:\n" + "\n".join(errores)

    yield md


def add_to_corpus(htr_text, gt_text, doc_type, region, date, caligrafia):
    if not htr_text.strip() or not gt_text.strip():
        return "⚠ HTR y GT son obligatorios."
    try:
        pair_id  = f"user_{abs(hash(htr_text)) % 100000:05d}"
        new_pair = {
            "id":          pair_id,
            "htr":         htr_text.strip(),
            "gt":          gt_text.strip(),
            "type":        doc_type or "desconocido",
            "region":      region   or "desconocida",
            "date":        date     or "",
            "caligrafia":  caligrafia or "desconocida",
            "corrections": [],
            "source":      "user_added",
        }
        added = vs.index([new_pair])
        if added:
            return f" Par aΓ±adido al corpus con id `{pair_id}`. Total: {vs.count()} docs."
        else:
            return f" Par ya existΓ­a en el corpus (id: `{pair_id}`)."
    except Exception as e:
        return f" Error: {e}"


# ── Interfaz Gradio ───────────────────────────────────────────────────────────

with gr.Blocks(
    title="Scriptorium RAG",
    theme=gr.themes.Base(
        primary_hue="amber",
        secondary_hue="stone",
        neutral_hue="stone",
        font=gr.themes.GoogleFont("IM Fell English"),
    ),
    css="""
    .header { text-align: center; padding: 20px 0 10px; }
    .header h1 { font-size: 2.2em; color: #92400e; letter-spacing: 0.15em; }
    .header p  { color: #78716c; font-style: italic; }
    .status-bar { font-size: 0.85em; padding: 6px 12px; border-radius: 6px; }
    """,
) as demo:

    gr.HTML("""
    <div class="header">
      <h1>RAG CODEX for Historical Spanish</h1>
      <p>RAG system of Spanish correction from the 16th century</p>
    </div>
    """)

    with gr.Tabs():

        # ── PestaΓ±a 1: CorrecciΓ³n ─────────────────────────────────────────────
        with gr.TabItem(" HTR Correction"):
            with gr.Row():
                with gr.Column(scale=2):
                    htr_input = gr.Textbox(
                        label="HTR text (recognizer input)",
                        placeholder="Paste the HTR result here…",
                        lines=6,
                    )
                    with gr.Row():
                        top_k_slider = gr.Slider(
                            minimum=1, maximum=10, value=5, step=1,
                            label="Documents retrieved (k)",
                        )
                        model_selector = gr.Dropdown(
                            label="Modelo LLM",
                            choices=["llama-3.3-70b-versatile", "openai/gpt-oss-120b"],
                            value="llama-3.3-70b-versatile",
                        )
                        embedding_selector = gr.Dropdown(
                            label="Modelo de Embedding",
                            choices=["openai", "mpnet", "mt5"],
                            value="openai",
                        )
                        show_prompt = gr.Checkbox(label="Show RAG prompt", value=False)
                    btn_corregir = gr.Button("✦ Correct with RAG", variant="primary")
                    gr.Examples(examples=DEMO_EXAMPLES, inputs=htr_input, label="Demonstration examples")

                with gr.Column(scale=2):
                    corrected_out = gr.Textbox(label="Corrected text (RAG output)", lines=6, interactive=False)
                    status_out    = gr.Markdown(elem_classes=["status-bar"])

            with gr.Row():
                with gr.Column():
                    docs_out     = gr.Markdown(label="Documents recovered from the corpus")
                with gr.Column():
                    analysis_out = gr.Markdown(label="Pattern analysis")

            diff_out   = gr.Markdown(label="Word-by-word differences")
            prompt_out = gr.Markdown(label="Prompt sent to the LLM", visible=True)

            btn_corregir.click(
                fn=corregir,
                inputs=[htr_input, top_k_slider, show_prompt, model_selector],
                outputs=[corrected_out, docs_out, analysis_out, diff_out, status_out, prompt_out],
            )
            embed_status = gr.Markdown()
            embedding_selector.change(fn=cambiar_embedding, inputs=[embedding_selector], outputs=[embed_status])

        # ── PestaΓ±a 2: EvaluaciΓ³n individual ─────────────────────────────────
        with gr.TabItem(" Evaluation with GT"):
            gr.Markdown(
                "Compara la correcciΓ³n del RAG con el groundtruth real para medir "
                "CER/WER y detectar modernismos introducidos por el LLM."
            )
            with gr.Row():
                eval_htr = gr.Textbox(label="HTR text", lines=4)
                eval_gt  = gr.Textbox(label="Groundtruth (reference)", lines=4)
            btn_eval = gr.Button("Evaluate", variant="primary")
            eval_out = gr.Markdown()
            btn_eval.click(fn=evaluar_par, inputs=[eval_htr, eval_gt], outputs=eval_out)

        # ── PestaΓ±a 3: EvaluaciΓ³n por lotes ──────────────────────────────────
        with gr.TabItem(" Batch Evaluation"):
            gr.Markdown(
                "EvalΓΊa el sistema sobre una muestra aleatoria del corpus. "
                "Muestra las tres comparaciones: **GT vs HTR** (error de partida), "
                "**GT vs Corregido** (error final) y **HTR vs Corregido** (modernismos)."
            )
            with gr.Row():
                batch_n = gr.Slider(
                    minimum=5, maximum=100, value=20, step=5,
                    label="NΓΊmero de pares a evaluar",
                )
                batch_cal = gr.Dropdown(
                    label="Filtrar por caligrafΓ­a",
                    choices=get_caligrafias(),
                    value="Todas",
                )
                batch_model = gr.Dropdown(
                    label="Modelo LLM",
                    choices=["llama-3.3-70b-versatile", "openai/gpt-oss-120b"],
                    value="llama-3.3-70b-versatile",
                )

            with gr.Row():
                gr.Markdown(
                    f"β„Ή Corpus disponible: **{len([p for p in all_pairs if p.get('gt','').strip()])} "
                    f"pares con GT** de {len(all_pairs)} totales."
                )

            btn_batch = gr.Button("β–Ά Ejecutar evaluaciΓ³n por lotes", variant="primary")
            batch_out = gr.Markdown()

            btn_batch.click(
                fn=evaluar_batch,
                inputs=[batch_n, batch_cal, batch_model],
                outputs=batch_out,
            )

        # ── PestaΓ±a 4: AΓ±adir al corpus ───────────────────────────────────────
        with gr.TabItem("βž• Add to corpus"):
            gr.Markdown("Add new pairs to the vector store to improve the RAG continuously.")
            with gr.Row():
                add_htr = gr.Textbox(label="Texto HTR", lines=4)
                add_gt  = gr.Textbox(label="Groundtruth corregido", lines=4)
            with gr.Row():
                add_type      = gr.Textbox(label="Document type", placeholder="notarial / judicial / eclesiastico")
                add_region    = gr.Textbox(label="Region", placeholder="Castilla, AndalucΓ­a…")
                add_date      = gr.Textbox(label="Date", placeholder="1542")
                add_caligrafia = gr.Dropdown(
                    label="CaligrafΓ­a",
                    choices=["desconocida", "Procesal", "Encadenada", "Italica_cursiva", "Redonda"],
                    value="desconocida",
                )
            btn_add = gr.Button("Add to corpus", variant="primary")
            add_out = gr.Markdown()
            btn_add.click(
                fn=add_to_corpus,
                inputs=[add_htr, add_gt, add_type, add_region, add_date, add_caligrafia],
                outputs=add_out,
            )

        # ── PestaΓ±a 5: Info del sistema ───────────────────────────────────────
        with gr.TabItem("INFO: System"):
            gr.Markdown(f"""
## System status

- **Modelo LLM:** {os.getenv('OPENAI_MODEL', 'gpt-4o')}
- **Vector store:** ChromaDB (persistente en `{os.getenv('CHROMA_PATH','./chroma_db')}`)
- **Documentos indexados:** {vs.count()}
- **Corpus cargado desde disco:** {len(disk_pairs)} pares
- **Pares de ejemplo embebidos:** {len(SAMPLE_PAIRS)}

## Arquitectura

```
Texto HTR
   β”‚
   β”œβ”€β–Ί Detector de patrones HTR (knowledge_base.py)
   β”œβ”€β–Ί Detector de grafΓ­as modernas (knowledge_base.py)
   β”‚
   β”œβ”€β–Ί Embedding (text-embedding-3-small)
   β”‚        β”‚
   β”‚        └─► BΓΊsqueda top-k en ChromaDB ──► Few-shot dinΓ‘mico
   β”‚
   └─► Prompt constructor ──► LLM ──► Texto corregido
```

## EvaluaciΓ³n por lotes

Las tres comparaciones:
1. **GT vs HTR** β€” error de partida (cuΓ‘nto se equivocΓ³ el HTR)
2. **GT vs Corregido** β€” error final (cuΓ‘nto mejorΓ³ el RAG)
3. **HTR vs Corregido** β€” modernismos (quΓ© cambiΓ³ el LLM que no debΓ­a)
""")

if __name__ == "__main__":
    demo.launch(
        server_name="0.0.0.0",
        server_port=7860,
        auth=("admin", "admin"),
        share=False,
        show_error=True,
    )