""" 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("""
RAG system of Spanish correction from the 16th century