File size: 6,618 Bytes
81f30c1
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
"""Genera docs/qa_ingest_<doc_id>.md a partir de ingest_report.json, figures.jsonl e
informal.jsonl (docs/PLAN_RAG.md Fase 2, tarea 10). La rúbrica manual de las 15
descripciones de figura se completa a mano en el .md generado.

SEC-2 (docs/AUDIT_2026-09-23.md, docs/PLAN_CORRECCIONES.md Fase 2): este reporte
se publica (git, HF). Si el documento tiene `redistributable=false`, se redacta
todo contenido literal derivado de su texto — fórmulas, transcripciones de
huecos y descripciones de figura — y sólo quedan conteos y metadatos
estructurales; mismo criterio que ya aplica `scripts/publish_hf.py` a las
tablas `informal`/`figures` (no las sube si el documento no es redistribuible).

Uso: python scripts/qa_ingest_report.py --doc-id edd3436ca148
"""

from __future__ import annotations

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

QA_GATE = (8, 16, 17, 22, 24, 37, 99)
_REDACTED_NOTE = "Contenido derivado del documento redactado (`redistributable=false`); disponible localmente en `data/parsed/` (gitignored), nunca en git/HF."


def main() -> None:
    ap = argparse.ArgumentParser()
    ap.add_argument("--doc-id", default="edd3436ca148")
    args = ap.parse_args()
    parsed = Path("data/parsed") / args.doc_id
    report = json.loads((parsed / "ingest_report.json").read_text(encoding="utf-8"))
    chunks = [json.loads(l) for l in open("data/corpus/informal.jsonl", encoding="utf-8") if l.strip()]
    figures = [json.loads(l) for l in open("data/corpus/figures.jsonl", encoding="utf-8") if l.strip()]
    cards = [c for c in chunks if c["unit_type"] == "problem_card"]
    by_n = {c["item_number"]: c for c in cards}
    doc_path = parsed / "document.json"
    redistributable = json.loads(doc_path.read_text(encoding="utf-8")).get("redistributable", False) if doc_path.exists() else False

    diffs = report.get("formula_diffs", [])
    accepted = [d for d in diffs if "after" in d]
    rejected = [d for d in diffs if "rejected" in d]
    areas = Counter(a for c in cards for a in c["math_area"])
    fig_by_id = {f["figure_id"]: f for f in figures}

    lines = [
        f"# QA de ingesta — `{args.doc_id}` (problemas resueltos olimpiadas.pdf)",
        "",
        "Generado por `scripts/qa_ingest_report.py`; criterios en `docs/PLAN_RAG.md` Fase 2, tarea 10.",
        "",
        "## Resumen",
        "",
        f"- Páginas: {report['n_pages']} · cards: {report['n_cards']} · **emparejadas problema↔solución: {report['n_paired']}/100** · chunks: {report['n_chunks']} (cards + figuras)",
        f"- Figuras recortadas: {report['n_figures_detected']} · con descripción VLM: {sum(1 for f in figures if f['description'])} · avisos red de seguridad vectorial: {len(report['figure_warnings'])}",
        f"- Huecos del layout transcritos por VLM: {len(report.get('gaps', []))} regiones",
        f"- QA de fórmulas: {len(accepted)} correcciones aplicadas, {len(rejected)} propuestas rechazadas por las guardas",
        f"- Llamadas al VLM/LLM en esta corrida (sin caché): {report['llm_calls']}",
        f"- Áreas (cards): {dict(areas.most_common())}",
        "",
        "## Gate 1 — emparejamiento problema↔solución (objetivo 100/100)",
        "",
        f"Resultado: **{report['n_paired']}/100**. Avisos: {report['warnings'] or 'ninguno'}.",
        "",
        "Notas del split por LLM:" if redistributable else f"Notas del split por LLM: {len(report.get('split_notes', []))}. {_REDACTED_NOTE}",
        *([f"- {n}" for n in report.get("split_notes", [])] if redistributable else []),
        "",
        "## Gate 2 — figuras de los problemas 8, 16, 17, 22, 24, 37, 99",
        "",
    ]
    if redistributable:
        lines += ["| Problema | Figuras | detected_by | Descripción (inicio) |", "|---|---|---|---|"]
        for n in QA_GATE:
            for fid in by_n[n]["figure_ids"]:
                f = fig_by_id.get(fid)
                if f:
                    lines.append(f"| {n} | `{fid}` | {f['detected_by']} | {f['description'][:110]}… |")
    else:
        lines += [f"_{_REDACTED_NOTE} (las descripciones se generan a partir de las figuras del documento)._", "", "| Problema | Figuras | detected_by |", "|---|---|---|"]
        for n in QA_GATE:
            for fid in by_n[n]["figure_ids"]:
                f = fig_by_id.get(fid)
                if f:
                    lines.append(f"| {n} | `{fid}` | {f['detected_by']} |")
    lines += [
        "",
        "## Rúbrica manual — 15 descripciones (sí/no: ¿nombra todos los puntos?, ¿relaciones correctas?, ¿valores dados?)",
        "",
        "| # | Figura | Puntos | Relaciones | Valores | Observación |",
        "|---|---|---|---|---|---|",
        *[f"| {i+1} | `{f['figure_id']}` |  |  |  |  |" for i, f in enumerate(figures[:15])],
        "",
        "## Huecos del layout de MinerU recuperados por VLM",
        "",
    ]
    if redistributable:
        lines += [
            "| Página | Capa de texto (evidencia) | Transcripción (inicio) |",
            "|---|---|---|",
            *[f"| {g['page']} | {g['text_layer'][:70]} | {g['transcription'][:90].replace(chr(10), ' ')} |" for g in report.get("gaps", [])],
        ]
    else:
        gap_pages = ", ".join(str(g["page"]) for g in report.get("gaps", []))
        lines.append(f"{len(report.get('gaps', []))} regiones recuperadas (páginas: {gap_pages or '—'}). {_REDACTED_NOTE}")
    lines += [
        "",
        "## QA de fórmulas (6b) — correcciones aplicadas",
        "",
    ]
    if redistributable:
        lines += ["| Página | Antes | Después |", "|---|---|---|", *[f"| {d['page']} | `{d['before'][:60]}` | `{d['after'][:60]}` |" for d in accepted]]
    else:
        pages = Counter(d["page"] for d in accepted)
        lines.append(f"{len(accepted)} correcciones en {len(pages)} páginas (páginas: {', '.join(str(p) for p in sorted(pages))}). {_REDACTED_NOTE}")
    lines += [
        "",
        f"Rechazadas por las guardas (cambio sólo cosmético o absorción de texto vecino): {len(rejected)} — detalle en `data/parsed/{args.doc_id}/ingest_report.json` (local, gitignored).",
        "",
        "## Gold de OCR (10 páginas transcritas a mano)",
        "",
        "Pendiente: requiere transcripción manual humana (no se fabrica). Sustituto disponible hoy: las 5 correcciones reales detectadas por el VLM en 2 páginas de muestra (docs/DECISIONS.md #19).",
        "",
    ]
    out = Path("docs") / f"qa_ingest_{args.doc_id}.md"
    out.write_text("\n".join(lines), encoding="utf-8")
    print(out)


if __name__ == "__main__":
    main()